export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' export type JsonRecord = Record export class ApiError extends Error { name = 'ApiError' url: string status?: number details?: unknown constructor(message: string, opts: { url: string; status?: number; details?: unknown }) { super(message) this.url = opts.url this.status = opts.status this.details = opts.details } } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } function isRetryableStatus(status?: number) { if (!status) return true return status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599) } export type FetchJsonOptions = { timeoutMs?: number retries?: number retryDelayMs?: number retryMethods?: HttpMethod[] headers?: HeadersInit /** If provided, body will be JSON-stringified and content-type set. */ jsonBody?: unknown /** If true, sends cookies/credentials (only for same-site or allowed CORS). */ credentials?: RequestCredentials } export async function fetchJson(url: string, init?: RequestInit, opts?: FetchJsonOptions): Promise { const timeoutMs = opts?.timeoutMs ?? 12000 const retries = opts?.retries ?? 1 const retryDelayMs = opts?.retryDelayMs ?? 600 const retryMethods = opts?.retryMethods ?? ['GET', 'HEAD'] const method = ((init?.method ?? 'GET').toUpperCase() as HttpMethod) || 'GET' const shouldRetryMethod = retryMethods.includes(method) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) const headers: HeadersInit = { ...(opts?.jsonBody !== undefined ? { 'Content-Type': 'application/json' } : null), ...(opts?.headers ?? null), ...(init?.headers ?? null), } const requestInit: RequestInit = { ...init, headers, signal: controller.signal, credentials: opts?.credentials ?? init?.credentials, body: opts?.jsonBody !== undefined ? JSON.stringify(opts.jsonBody) : init?.body, } let lastError: unknown try { for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await fetch(url, requestInit) if (!res.ok) { let details: unknown = undefined const contentType = res.headers.get('content-type') || '' try { details = contentType.includes('application/json') ? await res.json() : await res.text() } catch { // ignore } const detailMessage = details && typeof details === 'object' && 'error' in (details as Record) ? String((details as Record).error) : undefined const err = new ApiError(detailMessage || `API Error: ${res.status}`, { url, status: res.status, details }) if (attempt < retries && shouldRetryMethod && isRetryableStatus(res.status)) { await sleep(retryDelayMs * (attempt + 1)) continue } throw err } const contentType = res.headers.get('content-type') || '' if (contentType.includes('application/json')) { return (await res.json()) as T } // Allow endpoints returning plain text return (await res.text()) as unknown as T } catch (error) { lastError = error const isAbort = error instanceof DOMException && error.name === 'AbortError' if (attempt < retries && shouldRetryMethod && !isAbort) { await sleep(retryDelayMs * (attempt + 1)) continue } if (isAbort) { throw new ApiError(`API Timeout after ${timeoutMs}ms`, { url }) } throw error } } throw lastError instanceof Error ? lastError : new ApiError('Unknown API error', { url }) } finally { clearTimeout(timeout) } }