Files
ThothII/frontend/src/api/client.ts
T
marcopanandClaude Opus 4.8 3453f3ae23 feat: pre-check DWH reachability before creating a session (local dev only)
New session now refuses to spawn a Pi runtime that would only die in bootstrap
retrieval when the DWH/vector host is unreachable (e.g. a dropped VPN). Before
`session new`, POST /sessions probes the DWH via `tht db ping`; if it is down it
returns 503 {code:"dwh_unreachable"} with a clear message and creates nothing.

- Gated behind the THT_DWH_PRECHECK flag (default off), enabled only by the local
  dev launcher (run-stack.sh) — containers/CI never pay the probe, and existing
  tests that don't set it are unaffected.
- ThtRunner.dbPing() runs `tht db ping` with a 10s timeout (run() gains an optional
  timeout that SIGKILLs a hung child).
- Frontend: apiFetch throws a typed ApiError (status + parsed payload); the new-
  session composer shows the specific alert on `dwh_unreachable` instead of the
  generic retry hint, keeping the question for retry.

Verified live on an isolated backend (precheck on + broken DWH host → 503
dwh_unreachable, no session created) and via unit tests (backend 228, frontend 308).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:08:47 +02:00

40 lines
1.7 KiB
TypeScript

import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config";
/**
* Error thrown for non-2xx responses. `.message` stays "<status> <body>" for
* backward compatibility; `.status` and `.payload` (parsed JSON body, if any)
* let callers branch on a specific failure — e.g. a `code: "dwh_unreachable"`.
*/
export class ApiError extends Error {
constructor(readonly status: number, readonly bodyText: string, readonly payload: unknown) {
super(`${status} ${bodyText}`);
this.name = "ApiError";
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
// Only declare a JSON content-type when we actually send a body. Body-less
// POSTs (resume, close) would otherwise make Fastify reject the empty body
// with FST_ERR_CTP_EMPTY_JSON_BODY (400).
const headers: Record<string, string> = {
...(init?.headers as Record<string, string> | undefined),
};
if (init?.body != null && !("content-type" in headers) && !("Content-Type" in headers)) {
headers["content-type"] = "application/json";
}
const res = await fetch(joinBackendPath(BASE, path), { ...init, headers });
if (!res.ok) {
const bodyText = await res.text().catch(() => "");
let payload: unknown;
try { payload = bodyText ? JSON.parse(bodyText) : undefined; } catch { payload = undefined; }
throw new ApiError(res.status, bodyText, payload);
}
if (res.status === 204) return undefined as T;
// Accepted fire-and-forget endpoints may legitimately return 202 with no
// representation. Keep apiFetch useful for both 202 and 204 contracts.
const body = await res.text();
return body ? (JSON.parse(body) as T) : (undefined as T);
}
export { BASE };