Spaces:
Running
Running
File size: 6,670 Bytes
c7bece7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | /**
* 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<string, unknown>;
constructor(message: string, code: string, status: number, details: Record<string, unknown> = {}) {
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<T>(path: string, init?: RequestInit): Promise<T> {
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>("/health"),
stats: () => request<Stats>("/v1/stats"),
uploadDocument: (file: File) => {
const body = new FormData();
body.append("file", file);
return request<UploadResponse>("/v1/documents", { method: "POST", body });
},
documentStatus: (id: string) => request<DocumentStatusResponse>(`/v1/documents/${id}/status`),
document: (id: string) => request<DocumentDetail>(`/v1/documents/${id}`),
documentAudit: (id: string) => request<AuditEntry[]>(`/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<DocumentListResponse>(`/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<Anomaly[]>(`/v1/anomalies${suffix}`);
},
resolveAnomaly: (id: string, action: "approve" | "reject", note?: string) =>
request<Anomaly>(`/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;
}
|