| export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' |
|
|
| export type JsonRecord = Record<string, unknown> |
|
|
| 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 |
| |
| jsonBody?: unknown |
| |
| credentials?: RequestCredentials |
| } |
|
|
| export async function fetchJson<T>(url: string, init?: RequestInit, opts?: FetchJsonOptions): Promise<T> { |
| 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 { |
| |
| } |
|
|
| const detailMessage = |
| details && typeof details === 'object' && 'error' in (details as Record<string, unknown>) |
| ? String((details as Record<string, unknown>).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 |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|