/** * Thin fetch wrapper with timeout + JSON handling + typed errors. * Keep this dependency-free so every service can share it. */ export class HttpError extends Error { readonly status: number; readonly statusText: string; readonly url: string; readonly body?: unknown; constructor(status: number, statusText: string, url: string, body?: unknown) { super(`HTTP ${status} ${statusText} (${url})`); this.name = 'HttpError'; this.status = status; this.statusText = statusText; this.url = url; this.body = body; } } export interface HttpOptions extends Omit { /** Request body — automatically JSON-stringified when an object. */ body?: unknown; /** Abort the request after N ms. Default 10000. */ timeoutMs?: number; } export async function httpJson(url: string, opts: HttpOptions = {}): Promise { const { body, timeoutMs = 10_000, headers, ...rest } = opts; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...rest, signal: controller.signal, headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...headers, }, body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), }); if (!res.ok) { let parsed: unknown; try { parsed = await res.json(); } catch { parsed = await res.text().catch(() => undefined); } throw new HttpError(res.status, res.statusText, url, parsed); } const ct = res.headers.get('content-type') ?? ''; if (ct.includes('application/json')) return (await res.json()) as T; return (await res.text()) as unknown as T; } finally { clearTimeout(timer); } }