const defaultRetryAttempts = 4 const defaultRetryBaseDelayMs = 500 const defaultTimeoutMs = 8_000 const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]) export type RetryOptions = { attempts?: number baseDelayMs?: number timeoutMs?: number } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } function retryAfterMs(value: string | null): number | null { if (!value) return null const seconds = Number(value) if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000) const dateMs = Date.parse(value) if (!Number.isFinite(dateMs)) return null return Math.max(0, dateMs - Date.now()) } function backoffMs( attempt: number, baseDelayMs: number, retryAfter: number | null, ): number { if (retryAfter !== null) return retryAfter const jitter = 0.75 + Math.random() * 0.5 return Math.round(baseDelayMs * Math.pow(2, attempt - 1) * jitter) } function shouldRetry(status: number): boolean { return retryableStatuses.has(Number(status)) } async function fetchWithTimeout(url: string, timeoutMs: number) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort('timeout'), timeoutMs) try { return await fetch(url, { signal: controller.signal }) } finally { clearTimeout(timeout) } } /** GET a JSON endpoint with timeout, retry-after handling, and jittered exponential backoff. */ export async function fetchJsonWithRetry( url: string, options: RetryOptions = {}, ): Promise<{ data: any; attempts: number }> { const attempts = Number.isFinite(Number(options.attempts)) ? Math.max(1, Math.floor(Number(options.attempts))) : defaultRetryAttempts const baseDelayMs = Number.isFinite(Number(options.baseDelayMs)) ? Math.max(0, Number(options.baseDelayMs)) : defaultRetryBaseDelayMs const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : defaultTimeoutMs let lastError: Error | null = null for (let attempt = 1; attempt <= attempts; attempt += 1) { let retryAfter: number | null = null try { const response = await fetchWithTimeout(url, timeoutMs) retryAfter = retryAfterMs(response.headers.get('retry-after')) const text = await response.text() let body: any = null if (text) { try { body = JSON.parse(text) } catch { body = null } } if (!response.ok) { const detail = body && typeof body === 'object' ? body.reason || body.error || JSON.stringify(body).slice(0, 300) : text.slice(0, 300) throw Object.assign( new Error( 'Request failed for ' + url + ': HTTP ' + response.status + (detail ? ' - ' + detail : ''), ), { status: response.status, retryable: shouldRetry(response.status), }, ) } if (!body) { throw new Error( 'Request failed for ' + url + ': empty or invalid JSON response.', ) } return { data: body, attempts: attempt } } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) const retryable = error && typeof error === 'object' && 'retryable' in error ? Boolean((error as { retryable: unknown }).retryable) : true if (!retryable || attempt >= attempts) break await sleep(backoffMs(attempt, baseDelayMs, retryAfter)) } } throw lastError || new Error('Request failed for ' + url) }