export type Fetcher = ( url: string, options?: RequestInit ) => Promise; export class ApiError extends Error { status: number; detail: string | null; constructor(message: string, status: number, detail: string | null) { super(message); this.name = "ApiError"; this.status = status; this.detail = detail; } } export interface ApiRequestOptions { method?: string; body?: unknown; signal?: AbortSignal; /** Human-readable label for the error message, e.g. "Start training". */ action?: string; } /** * Performs a request against the lelab backend and parses the JSON response. * Throws ApiError with FastAPI's `detail` field on non-2xx, or on JSON parse * failure. Use this in place of ad-hoc `r.ok` / `r.json()` branching. */ export async function apiRequest( baseUrl: string, fetcher: Fetcher, path: string, { method = "GET", body, signal, action }: ApiRequestOptions = {} ): Promise { const init: RequestInit = { method, signal }; if (body !== undefined) init.body = JSON.stringify(body); const url = `${baseUrl}${path}`; const r = await fetcher(url, init); if (!r.ok) { let detail: string | null = null; try { const errBody = await r.json(); detail = errBody?.detail ?? errBody?.message ?? null; } catch { // body wasn't JSON } const label = action || `${method} ${path}`; throw new ApiError( `${label} failed: ${detail ?? r.status}`, r.status, detail ); } // 204 No Content if (r.status === 204) return undefined as T; return r.json() as Promise; }