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)
}