const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_ATTEMPTS = 3; const DEFAULT_BASE_DELAY_MS = 250; const DEFAULT_RETRY_AFTER_CAP_MS = 60_000; export type HttpFailureKind = 'status' | 'timeout' | 'network' | 'aborted' | 'invalid-body'; export type RequestJsonInit = RequestInit & { timeoutMs?: number; maxAttempts?: number; /** First-retry backoff, doubled per attempt. Exposed so tests need not wait on real backoff. */ baseDelayMs?: number; /** Upper bound on a honored `Retry-After`, so a hostile header cannot park the process. */ retryAfterCapMs?: number; }; export class HttpRequestError extends Error { readonly url: string; readonly status: number | undefined; readonly attempts: number; readonly kind: HttpFailureKind; constructor( url: string, kind: HttpFailureKind, attempts: number, status?: number, detail?: string ) { const statusPart = status === undefined ? '' : ` status ${status}`; const detailPart = detail === undefined ? '' : `: ${detail}`; super( `HTTP request to ${url} failed after ${attempts} attempt(s) (${kind}${statusPart})${detailPart}` ); this.name = 'HttpRequestError'; this.url = url; this.status = status; this.attempts = attempts; this.kind = kind; } } export const isHttpRequestError = (err: unknown): err is HttpRequestError => err instanceof HttpRequestError; /** * Returns the delay a `Retry-After` header asks for, clamped to `capMs`, or null when the * header is absent or unintelligible. Supports both delay-seconds and HTTP-date forms. */ export const parseRetryAfter = ( headerValue: string | null, capMs: number = DEFAULT_RETRY_AFTER_CAP_MS ): number | null => { if (headerValue === null) { return null; } const raw = headerValue.trim(); if (raw === '') { return null; } if (/^\d+$/.test(raw)) { return Math.min(Number(raw) * 1000, capMs); } const deadline = Date.parse(raw); if (Number.isNaN(deadline)) { return null; } return Math.min(Math.max(deadline - Date.now(), 0), capMs); }; const backoffDelay = (attempt: number, baseDelayMs: number, capMs: number): number => { const ceiling = Math.min(baseDelayMs * 2 ** (attempt - 1), capMs); // Half fixed, half jittered, so concurrent callers do not resynchronize on the same tick. return Math.round(ceiling / 2 + Math.random() * (ceiling / 2)); }; /** Settles early when `signal` fires; the caller re-checks the signal before retrying. */ const sleep = (ms: number, signal: AbortSignal | undefined): Promise => new Promise((resolve) => { if (signal?.aborted === true) { resolve(); return; } let timer: ReturnType; const onAbort = (): void => { clearTimeout(timer); resolve(); }; timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); signal?.addEventListener('abort', onAbort, { once: true }); }); const isRetryableStatus = (status: number): boolean => status === 429 || status >= 500; /** Frees the socket for reuse; a body we never read would otherwise stay pending. */ const discardBody = async (response: Response): Promise => { try { await response.body?.cancel(); } catch { // A already-consumed or errored body is irrelevant to the retry decision. } }; const readJson = async (response: Response, url: string, attempts: number): Promise => { // 204/205 are defined as bodiless, so absence of JSON is the correct outcome, not a failure. if (response.status === 204 || response.status === 205) { return undefined as T; } let text: string; try { text = await response.text(); } catch (err) { throw new HttpRequestError( url, 'invalid-body', attempts, response.status, err instanceof Error ? err.message : 'could not read response body' ); } try { return JSON.parse(text) as T; } catch { throw new HttpRequestError( url, 'invalid-body', attempts, response.status, 'response body was not valid JSON' ); } }; /** * Performs a JSON request, retrying only failures a retry can plausibly fix: 429, 5xx, timeouts * and transport errors. Any other non-2xx fails on the first attempt, since re-sending a 400 or * 401 only spends rate limit against an answer that will not change. * * Resolves `undefined` for bodiless 204/205 responses; any other 2xx whose body is not valid * JSON rejects rather than resolving `undefined` silently. */ export const requestJson = async (url: string, init?: RequestJsonInit): Promise => { const { timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS, retryAfterCapMs = DEFAULT_RETRY_AFTER_CAP_MS, signal, ...requestInit } = init ?? {}; if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { throw new TypeError('requestJson(init.maxAttempts) requires a positive integer'); } const callerSignal = signal ?? undefined; // Read through a call so narrowing never freezes this at its first observed value. const callerAborted = (): boolean => callerSignal !== undefined && callerSignal.aborted; let lastFailure: HttpRequestError | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (callerAborted()) { throw new HttpRequestError(url, 'aborted', attempt - 1, undefined, 'caller aborted'); } const timeoutSignal = AbortSignal.timeout(timeoutMs); const attemptSignal = callerSignal === undefined ? timeoutSignal : AbortSignal.any([callerSignal, timeoutSignal]); let response: Response; try { response = await fetch(url, { ...requestInit, signal: attemptSignal }); } catch (err) { if (callerAborted()) { throw new HttpRequestError(url, 'aborted', attempt, undefined, 'caller aborted'); } const kind: HttpFailureKind = timeoutSignal.aborted ? 'timeout' : 'network'; const detail = timeoutSignal.aborted ? `attempt exceeded ${timeoutMs}ms` : err instanceof Error ? err.message : 'transport error'; lastFailure = new HttpRequestError(url, kind, attempt, undefined, detail); if (attempt === maxAttempts) { throw lastFailure; } await sleep(backoffDelay(attempt, baseDelayMs, retryAfterCapMs), callerSignal); continue; } if (response.ok) { return await readJson(response, url, attempt); } await discardBody(response); if (!isRetryableStatus(response.status)) { throw new HttpRequestError(url, 'status', attempt, response.status); } lastFailure = new HttpRequestError(url, 'status', attempt, response.status); if (attempt === maxAttempts) { throw lastFailure; } const retryAfter = parseRetryAfter(response.headers.get('retry-after'), retryAfterCapMs); await sleep( retryAfter ?? backoffDelay(attempt, baseDelayMs, retryAfterCapMs), callerSignal ); } // Unreachable while maxAttempts >= 1; the loop either returns or throws. throw lastFailure ?? new HttpRequestError(url, 'network', maxAttempts); };