/** * Typed client for the LedgerLens API. * * Every failure is normalised into `ApiRequestError` carrying the server's * error code, so the UI can react to `rate_limited` or `file_too_large` * specifically instead of showing "something went wrong". */ import type { Anomaly, AnomalyStatus, ApiError, AuditEntry, DocumentDetail, DocumentListResponse, DocumentStatusResponse, Health, Stats, UploadResponse, } from "@/types/api"; export const API_BASE_URL = ( process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:7860" ).replace(/\/$/, ""); export class ApiRequestError extends Error { readonly code: string; readonly status: number; readonly details: Record; constructor(message: string, code: string, status: number, details: Record = {}) { super(message); this.name = "ApiRequestError"; this.code = code; this.status = status; this.details = details; } } function isApiError(value: unknown): value is ApiError { if (typeof value !== "object" || value === null || !("error" in value)) return false; const inner = (value as { error: unknown }).error; return typeof inner === "object" && inner !== null && "code" in inner && "message" in inner; } /* -------------------------------------------------------------------------- */ /* Cold starts */ /* -------------------------------------------------------------------------- */ // The API and the database both run on free tiers that suspend when idle. The // first request after a quiet spell has to wake a container *and* a serverless // Postgres, which the host itself warns can take 50 seconds or more. // // That is normal operation here, not a fault — so a read that fails to connect // is retried rather than reported. Without this, the first visitor after an idle // period is told the service is unreachable while it is, in fact, starting up: // the worst possible first impression, and a false one. // // Backoff sums to ~50 s of waiting, which covers the documented worst case. const COLD_START_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 20_000] as const; // 502/503/504 are what a platform returns while it is bringing a service up. const WAKING_STATUSES = new Set([502, 503, 504]); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** Only replay requests that are safe to send twice. */ function isRetryable(init?: RequestInit): boolean { const method = (init?.method ?? "GET").toUpperCase(); return method === "GET" || method === "HEAD"; } async function request(path: string, init?: RequestInit): Promise { const retryable = isRetryable(init); let response: Response | null = null; let lastCause: unknown = null; for (let attempt = 0; ; attempt += 1) { try { response = await fetch(`${API_BASE_URL}${path}`, { ...init, cache: "no-store", headers: { Accept: "application/json", ...(init?.headers ?? {}) }, }); if (!retryable || !WAKING_STATUSES.has(response.status)) break; lastCause = `HTTP ${response.status}`; } catch (cause) { lastCause = cause; response = null; } const delay = COLD_START_BACKOFF_MS[attempt]; if (!retryable || delay === undefined) break; await sleep(delay); } if (response === null) { throw new ApiRequestError( `Cannot reach the LedgerLens API at ${API_BASE_URL}. ` + `It runs on a free tier that sleeps when idle — if it was just woken, give it a moment and retry.`, "network_error", 0, { cause: String(lastCause) }, ); } if (response.status === 204) return undefined as T; const payload: unknown = await response.json().catch(() => null); if (!response.ok) { if (isApiError(payload)) { throw new ApiRequestError( payload.error.message, payload.error.code, response.status, payload.error.details, ); } throw new ApiRequestError( `Request failed with status ${response.status}.`, "unknown_error", response.status, ); } return payload as T; } export const api = { health: () => request("/health"), stats: () => request("/v1/stats"), uploadDocument: (file: File) => { const body = new FormData(); body.append("file", file); return request("/v1/documents", { method: "POST", body }); }, documentStatus: (id: string) => request(`/v1/documents/${id}/status`), document: (id: string) => request(`/v1/documents/${id}`), documentAudit: (id: string) => request(`/v1/documents/${id}/audit`), documents: (params: { limit?: number; offset?: number; status?: string } = {}) => { const query = new URLSearchParams(); if (params.limit !== undefined) query.set("limit", String(params.limit)); if (params.offset !== undefined) query.set("offset", String(params.offset)); if (params.status) query.set("status", params.status); const suffix = query.toString() ? `?${query.toString()}` : ""; return request(`/v1/documents${suffix}`); }, anomalies: (params: { status?: AnomalyStatus; limit?: number } = {}) => { const query = new URLSearchParams(); if (params.status) query.set("status", params.status); if (params.limit !== undefined) query.set("limit", String(params.limit)); const suffix = query.toString() ? `?${query.toString()}` : ""; return request(`/v1/anomalies${suffix}`); }, resolveAnomaly: (id: string, action: "approve" | "reject", note?: string) => request(`/v1/anomalies/${id}/resolve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, note: note ?? null }), }), }; /** Client-side pre-check so an obviously invalid file never costs a round trip. */ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; export const ACCEPTED_TYPES = ["application/pdf", "image/png", "image/jpeg"] as const; export function describeUploadProblem(file: File): string | null { if (file.size === 0) return `${file.name} is empty.`; if (file.size > MAX_UPLOAD_BYTES) { return `${file.name} is ${(file.size / 1024 / 1024).toFixed(1)} MB — the limit is 10 MB.`; } const type = file.type.split(";")[0]?.trim().toLowerCase() ?? ""; const looksAccepted = (ACCEPTED_TYPES as readonly string[]).includes(type) || /\.(pdf|png|jpe?g)$/i.test(file.name); if (!looksAccepted) return `${file.name} is not a PDF, PNG or JPEG.`; return null; }