From d3bb091f737b35faca42dca1d3e38ad295aadb77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Jul 2026 00:07:04 -0400 Subject: [PATCH] m4: global API-error surface (no more silent failures) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api client now catches network failures and non-JSON bodies robustly, and routes unexpected errors (offline / 5xx) to a global toast — 4xx stay with the caller so forms keep their inline messages. Toast actions are now optional (undo toasts keep their button; error toasts are message-only), and ToastHost moved from AppShell to the app root so toasts show everywhere, including the login screen. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/App.vue | 5 ++++ frontend/src/api/client.ts | 43 ++++++++++++++++++++++----- frontend/src/components/AppShell.vue | 3 -- frontend/src/components/ToastHost.vue | 3 +- frontend/src/stores/notes.ts | 5 ++-- frontend/src/stores/ui.ts | 16 ++++++---- 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7c2aa3f..a690645 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,8 @@ + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6e1038c..e2785f6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,25 +1,52 @@ +import { useUiStore } from "../stores/ui"; + export interface ApiError { error: string; status: number; } +// Surface infra / unexpected failures (network, 5xx) globally. 4xx are left to +// the caller — forms and views show their own inline messages for those. +function notifyError(err: ApiError) { + try { + useUiStore().showToast(err.error); + } catch { + /* pinia not active yet (very early boot) — skip the toast */ + } +} + async function request(method: string, path: string, body?: unknown): Promise { - const resp = await fetch(path, { - method, - credentials: "include", // send/receive the signed session cookie - headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + let resp: Response; + try { + resp = await fetch(path, { + method, + credentials: "include", // send/receive the signed session cookie + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch { + const err: ApiError = { error: "Can't reach the server. Check your connection.", status: 0 }; + notifyError(err); + throw err; + } const text = await resp.text(); - const data: unknown = text ? JSON.parse(text) : {}; + let data: unknown = {}; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = {}; // non-JSON body (e.g. a proxy error page) — fall through to status handling + } + } if (!resp.ok) { const message = typeof data === "object" && data !== null && "error" in data ? String((data as { error: unknown }).error) - : "Request failed."; + : "Something went wrong. Please try again."; const err: ApiError = { error: message, status: resp.status }; + if (resp.status >= 500) notifyError(err); throw err; } diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 7986021..950f77a 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -8,7 +8,6 @@ import { useUiStore } from "../stores/ui"; import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; import LabelsModal from "./LabelsModal.vue"; -import ToastHost from "./ToastHost.vue"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; const route = useRoute(); @@ -297,8 +296,6 @@ async function signOut() { - -
{{ ui.toast.message }}