type FetchRetryOptions = { timeoutMs: number; retries?: number; retryStatuses?: number[]; retryMethods?: string[]; baseDelayMs?: number; }; const DEFAULT_RETRY_STATUSES = [408, 425, 429, 502, 503, 504]; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function shouldRetryResponse(method: string, response: Response, options: FetchRetryOptions) { const allowedMethods = options.retryMethods ?? ["GET", "HEAD"]; const retryStatuses = options.retryStatuses ?? DEFAULT_RETRY_STATUSES; return allowedMethods.includes(method) && retryStatuses.includes(response.status); } function shouldRetryError(method: string, error: unknown, options: FetchRetryOptions) { const allowedMethods = options.retryMethods ?? ["GET", "HEAD"]; if (!allowedMethods.includes(method)) { return false; } if (error instanceof Error) { return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch"); } return false; } export async function fetchWithTimeout(input: string | URL, init: RequestInit | undefined, options: FetchRetryOptions) { const method = (init?.method ?? "GET").toUpperCase(); const retries = Math.max(0, options.retries ?? 0); const baseDelayMs = Math.max(150, options.baseDelayMs ?? 350); let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt += 1) { const controller = new AbortController(); const upstreamSignal = init?.signal; let didTimeout = false; const timeout = setTimeout(() => { didTimeout = true; controller.abort(); }, options.timeoutMs); const abortHandler = () => controller.abort(); upstreamSignal?.addEventListener("abort", abortHandler, { once: true }); try { const response = await fetch(input, { ...init, signal: controller.signal, }); if (attempt < retries && shouldRetryResponse(method, response, options)) { await response.text().catch(() => undefined); await sleep(baseDelayMs * (attempt + 1)); continue; } return response; } catch (error) { if (didTimeout) { lastError = new Error(`Request timed out after ${options.timeoutMs}ms`); } else { lastError = error; } if (attempt >= retries || !shouldRetryError(method, lastError, options)) { throw lastError instanceof Error ? lastError : new Error("Request failed"); } await sleep(baseDelayMs * (attempt + 1)); } finally { clearTimeout(timeout); upstreamSignal?.removeEventListener("abort", abortHandler); } } throw lastError instanceof Error ? lastError : new Error("Request failed"); }