diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e4a2a889..4a0fa5dd 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { useConnectionStore } from "@/stores/connection-store"; + const API_BASE = "/api"; export interface FeatureNotInstalledError { @@ -74,9 +76,17 @@ async function throwWithMessage(res: Response): Promise { } export async function apiGet(path: string): Promise { - const res = await fetch(`${API_BASE}${path}`, { - headers: formatHeaders(), - }); + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + headers: formatHeaders(), + }); + } catch (error) { + if (error instanceof TypeError) { + useConnectionStore.getState().setDisconnected(); + } + throw error; + } if (!res.ok) await throwWithMessage(res); return res.json(); } @@ -84,30 +94,54 @@ export async function apiGet(path: string): Promise { export async function apiPost(path: string, body?: unknown): Promise { const headers = body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders(); - const res = await fetch(`${API_BASE}${path}`, { - method: "POST", - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (error) { + if (error instanceof TypeError) { + useConnectionStore.getState().setDisconnected(); + } + throw error; + } if (!res.ok) await throwWithMessage(res); return res.json(); } export async function apiPut(path: string, body?: unknown): Promise { - const res = await fetch(`${API_BASE}${path}`, { - method: "PUT", - headers: formatHeaders({ "Content-Type": "application/json" }), - body: body ? JSON.stringify(body) : undefined, - }); + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + method: "PUT", + headers: formatHeaders({ "Content-Type": "application/json" }), + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + if (error instanceof TypeError) { + useConnectionStore.getState().setDisconnected(); + } + throw error; + } if (!res.ok) await throwWithMessage(res); return res.json(); } export async function apiDelete(path: string): Promise { - const res = await fetch(`${API_BASE}${path}`, { - method: "DELETE", - headers: formatHeaders(), - }); + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + method: "DELETE", + headers: formatHeaders(), + }); + } catch (error) { + if (error instanceof TypeError) { + useConnectionStore.getState().setDisconnected(); + } + throw error; + } if (!res.ok) await throwWithMessage(res); return res.json(); } diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts index 63edeced..5090b2c9 100644 --- a/tests/unit/web/stores.test.ts +++ b/tests/unit/web/stores.test.ts @@ -741,6 +741,40 @@ describe("API lib", () => { }); }); + // -- api network error → connection store --------------------------------- + + describe("api network error → connection store", () => { + it("triggers disconnected state on TypeError from fetch", async () => { + const { useConnectionStore } = await import("@/stores/connection-store"); + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + await expect(apiGet("/v1/test")).rejects.toThrow("Failed to fetch"); + + expect(useConnectionStore.getState().status).toBe("disconnected"); + }); + + it("does NOT trigger disconnected on HTTP errors", async () => { + const { useConnectionStore } = await import("@/stores/connection-store"); + useConnectionStore.setState({ + status: "connected", + failedSince: null, + lastHealthCheck: null, + }); + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Not found" }), { status: 404 }), + ); + await expect(apiGet("/v1/test")).rejects.toThrow(); + + expect(useConnectionStore.getState().status).toBe("connected"); + }); + }); + // -- Cross-cutting: token is read fresh on every call -------------------- describe("token freshness", () => {