/** * Configurable API client factory. * Creates a fetch wrapper with base URL, auth token injection, error handling, * timeout, and retry with exponential backoff for idempotent requests. */ import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Create an API client with a base URL and optional auth token. * * @example * ```ts * const api = createApiClient({ * baseUrl: "/api", * getToken: () => localStorage.getItem("access_token"), * }); * * // Throws on error * const users = await api.fetch("/users"); * * // Never throws * const { data, error } = await api.safeFetch("/users"); * ``` */ export function createApiClient(config: ApiClientConfig): ApiClient { const { baseUrl, getToken, defaultHeaders, timeoutMs = 10_000, retries = 2, retryDelayMs = 500, } = config; function buildHeaders(options?: RequestInit): HeadersInit { const headers: Record = { 'Content-Type': 'application/json', 'x-request-id': typeof globalThis.crypto?.randomUUID === 'function' ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, ...defaultHeaders, }; if (getToken) { const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } } if (options?.headers) { const extra: Record = {}; if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { extra[key] = value; }); } else if (Array.isArray(options.headers)) { Object.assign(extra, Object.fromEntries(options.headers)); } else { Object.assign(extra, options.headers); } Object.assign(headers, extra); } return headers; } function buildInit(options?: RequestInit): RequestInit { const init: RequestInit = { ...options, headers: buildHeaders(options), }; // AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0) if (timeoutMs > 0 && !options?.signal) { const controller = new AbortController(); init.signal = controller.signal; setTimeout(() => controller.abort(), timeoutMs); } return init; } function isRetryable(method: string | undefined): boolean { return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()); } async function fetchWithRetry(url: string, init: RequestInit): Promise { const maxAttempts = isRetryable(init.method) ? retries + 1 : 1; let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const res = await globalThis.fetch(url, init); // Only retry on 502/503/504 for idempotent methods if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) { await sleep(retryDelayMs * 2 ** attempt); continue; } return res; } catch (err) { lastError = err; if (attempt < maxAttempts - 1) { await sleep(retryDelayMs * 2 ** attempt); continue; } } } throw lastError; } return { async fetch(path: string, options?: RequestInit): Promise { const init = buildInit(options); const res = await fetchWithRetry(`${baseUrl}${path}`, init); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || `HTTP ${res.status}`); } return res.json() as Promise; }, async safeFetch(path: string, options?: RequestInit): Promise> { try { const init = buildInit(options); const res = await fetchWithRetry(`${baseUrl}${path}`, init); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); return { data: null, error: body.error || `HTTP ${res.status}` }; } const data = (await res.json()) as T; return { data, error: null }; } catch { return { data: null, error: 'API unavailable' }; } }, }; }