import { getToken, clearAuth } from './auth'; const BASE = '/api/v1'; export class ApiError extends Error { status: number; code: string; constructor(status: number, code: string, message: string) { super(message); this.status = status; this.code = code; } } const TIMEOUT_MS = 20_000; async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = {}; const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } if (body !== undefined) { headers['Content-Type'] = 'application/json'; } // Abort hung requests so a dead connection surfaces as a friendly error // instead of a spinner that never resolves. // // The timer must stay armed until the BODY has been read, not just the headers. // `fetch` resolves as soon as the response head arrives, so clearing it in a `finally` // around the fetch left `res.text()` below completely uncovered — and no longer // abortable, since the controller had already been disarmed. An upstream that sends // headers and then stalls the body (the shape of a half-dead proxy, or of the pool // saturation this same release adds shedding for) hung that call forever. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let res: Response; try { res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal }); } catch (e) { clearTimeout(timer); if (e instanceof DOMException && e.name === 'AbortError') { throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.'); } throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.'); } if (res.status === 204) { // Must clear on this path too, or every no-content request (logout, delete, like) // leaks a live 20 s timer. clearTimeout(timer); return undefined as T; } // A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing // it directly would throw an opaque SyntaxError. Read text, parse defensively. let raw: string; try { raw = await res.text(); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.'); } throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.'); } finally { clearTimeout(timer); } let data: { error?: string; message?: string } | unknown = null; if (raw) { try { data = JSON.parse(raw); } catch { data = null; } } if (!res.ok) { // An expired/invalid token (401) clears the dead session. Banned users are // NOT logged out — they keep read access by design (USER_JOURNEYS §10) and // simply get a 403 "gesperrt" toast on writes. if (res.status === 401) { clearAuth(); } const d = (data ?? {}) as { error?: string; message?: string }; throw new ApiError( res.status, d.error ?? 'unknown', d.message ?? `Serverfehler (${res.status}).` ); } return data as T; } export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path) };