/** * Fetch with a hard wall-clock timeout. Wraps `globalThis.fetch` with an * AbortController so a hung upstream cannot pin a worker concurrency slot * indefinitely (per docs/audit-comprehensive-2026-05-05.md HIGH §§5–6 and * MED §13 — Documenso, OCR, and IMAP all needed this). * * - Default timeout is 30s; pass `timeoutMs` to override. * - When the caller already supplies an AbortSignal via `init.signal`, both * sources can abort the request — first one to fire wins. * - On timeout the rejection is a `DOMException('TimeoutError')` from * AbortController; callers can introspect via `err.name === 'AbortError'` * plus the timeout flag we attach. */ export interface FetchWithTimeoutOptions extends RequestInit { /** Hard timeout in ms. Default 30_000. */ timeoutMs?: number; } export class FetchTimeoutError extends Error { override readonly name = 'FetchTimeoutError'; constructor( public readonly url: string, public readonly timeoutMs: number, ) { super(`Request to ${url} exceeded ${timeoutMs}ms timeout`); } } export async function fetchWithTimeout( url: string, init: FetchWithTimeoutOptions = {}, ): Promise { const { timeoutMs = 30_000, signal: callerSignal, ...rest } = init; const controller = new AbortController(); // Compose: if the caller already passed a signal, abort our controller // when theirs aborts. We can't reuse theirs directly because we still // own the timeout lifecycle. if (callerSignal) { if (callerSignal.aborted) { controller.abort(callerSignal.reason); } else { callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), { once: true, }); } } const timeoutId = setTimeout(() => { controller.abort(new FetchTimeoutError(url, timeoutMs)); }, timeoutMs); try { return await fetch(url, { ...rest, signal: controller.signal }); } catch (err) { // If we hit our own timeout, surface a typed error instead of the // generic AbortError so call sites can branch on it. if ( err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError') && controller.signal.aborted ) { const reason = controller.signal.reason; if (reason instanceof FetchTimeoutError) throw reason; } throw err; } finally { clearTimeout(timeoutId); } }