71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<Response> {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|