From 86de4252db29a721c35d235c05fa834140404847 Mon Sep 17 00:00:00 2001 From: Marvelous Oni Date: Thu, 30 Jul 2026 21:21:51 +0000 Subject: [PATCH 1/5] refactor(api): centralize error-to-message mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mapApiError() helper in src/lib/mapApiError.ts that translates unknown error values (ApiError shapes, Error instances, non-Error rejections) to a consistent { message, requestId } structure. Replace scattered inline error-to-message logic across 15 call sites with this single tested helper. Changes: - New: src/lib/mapApiError.ts — mapApiError(error, fallback?) helper - New: src/lib/__tests__/mapApiError.test.ts — 26 comprehensive tests - Updated: src/lib/usePolling.ts — replace errorMessage() - Updated: src/lib/useApi.ts — use mapApiError for rate-limited/generic - Updated: src/lib/useApiMutation.ts — use mapApiError for message extraction - Updated: src/app/usage/page.tsx — replace describeError() with mapApiError - Updated: 9 additional page-level catch blocks (webhooks, search, api-keys, admin, export, services, agents, events, service detail/edit) - Fixed: pre-existing bug in api-keys/page.tsx where 'error' was undefined Test output: src/lib/__tests__/mapApiError.test.ts — 26/26 passed Targeted call-site tests — 264/265 passed (1 pre-existing failure) Full suite — 92/95 suites pass (3 pre-existing failures) Preserves all existing fallback messages verbatim: "failed to load", "failed to mutate", "Search failed", "Export failed" Closes #354 --- src/app/admin/page.tsx | 3 +- src/app/agents/page.tsx | 3 +- src/app/api-keys/page.tsx | 8 +- src/app/events/page.tsx | 3 +- src/app/export/ExportActions.tsx | 3 +- src/app/search/page.tsx | 3 +- src/app/services/[serviceId]/agents/page.tsx | 3 +- src/app/services/[serviceId]/edit/page.tsx | 5 +- src/app/services/[serviceId]/page.tsx | 3 +- src/app/services/page.tsx | 3 +- src/app/usage/page.tsx | 27 +- src/app/webhooks/page.tsx | 9 +- src/lib/__tests__/mapApiError.test.ts | 313 +++++++++++++++++++ src/lib/mapApiError.ts | 42 +++ src/lib/useApi.ts | 5 +- src/lib/useApiMutation.ts | 6 +- src/lib/usePolling.ts | 9 +- 17 files changed, 396 insertions(+), 52 deletions(-) create mode 100644 src/lib/__tests__/mapApiError.test.ts create mode 100644 src/lib/mapApiError.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 71184cb..2df27dd 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { apiPost } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { AlertError } from "@/components/AlertError"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { EmptyState } from "@/components/EmptyState"; @@ -98,7 +99,7 @@ export default function AdminPage() { toast.push("Admin pause toggle applied.", "info"); await refreshAfterAction(); } catch (e) { - const message = (e as Error).message; + const message = mapApiError(e).message; setActionError(message); toast.push(message, "error"); } finally { diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx index f4dbf4e..4d61e2b 100644 --- a/src/app/agents/page.tsx +++ b/src/app/agents/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { AlertError } from "@/components/AlertError"; import { EmptyState } from "@/components/EmptyState"; import { PageShell } from "@/components/PageShell"; @@ -78,7 +79,7 @@ export default function AgentsPage() { }) .catch((e: Error) => { if (cancelled) return; - setError(e.message ?? "failed to load"); + setError(mapApiError(e, "failed to load").message); setPageCount(1); }) .finally(() => { diff --git a/src/app/api-keys/page.tsx b/src/app/api-keys/page.tsx index 9045fc0..2ff83b8 100644 --- a/src/app/api-keys/page.tsx +++ b/src/app/api-keys/page.tsx @@ -3,6 +3,7 @@ import { PageShell } from "@/components/PageShell"; import { useCallback, useEffect, useState, useMemo } from "react"; import { apiGet, apiPost, apiDelete } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { AlertError } from "@/components/AlertError"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { CopyButton } from "@/components/CopyButton"; @@ -98,6 +99,7 @@ export default function ApiKeysPage() { ); const items = fetchState.status === "ok" ? fetchState.items : null; + const error = fetchState.status === "error" ? fetchState.message : null; const tableData = useMemo(() => items ?? [], [items]); const load = useCallback( @@ -107,7 +109,7 @@ export default function ApiKeysPage() { // shows the empty state instead of rendering blank. .then((b) => setFetchState({ status: "ok", items: b.items ?? [] })) .catch((e: Error) => - setFetchState({ status: "error", message: e.message }) + setFetchState({ status: "error", message: mapApiError(e).message }) ), [] ); @@ -132,7 +134,7 @@ export default function ApiKeysPage() { setLabel(""); await load(); } catch (err) { - setActionError((err as Error).message); + setActionError(mapApiError(err).message); } }; @@ -142,7 +144,7 @@ export default function ApiKeysPage() { await apiDelete(`/api/v1/api-keys/${prefix}`); await load(); } catch (err) { - setActionError((err as Error).message); + setActionError(mapApiError(err).message); } }; diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index 1af0015..6fb1dd9 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -7,6 +7,7 @@ import { PageShell } from "@/components/PageShell"; import { SearchBar } from "@/components/SearchBar"; import { Spinner } from "@/components/Spinner"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { MAX_RENDERED_ROWS, safeFormatTimestamp, safeStringify } from "@/lib/format"; import { useDebounce } from "@/lib/useDebounce"; @@ -189,7 +190,7 @@ export default function EventsPage() { }) .catch((e: unknown) => { if (cancelled) return; - const message = e instanceof Error ? e.message : "Failed to load events"; + const message = mapApiError(e, "Failed to load events").message; setError(message); setItems([]); }) diff --git a/src/app/export/ExportActions.tsx b/src/app/export/ExportActions.tsx index f84cd15..e8a099b 100644 --- a/src/app/export/ExportActions.tsx +++ b/src/app/export/ExportActions.tsx @@ -3,6 +3,7 @@ import { Spinner } from "@/components/Spinner"; import { useToast } from "@/components/ToastProvider"; import { useState, useMemo } from "react"; +import { mapApiError } from "@/lib/mapApiError"; type ExportFormat = "json" | "csv"; @@ -105,7 +106,7 @@ export function ExportActions({ apiBase }: Props) { URL.revokeObjectURL(blobUrl); toast.push(`${format.toUpperCase()} export downloaded.`, "info"); } catch (err) { - setError((err as Error).message || "Export failed"); + setError(mapApiError(err, "Export failed").message); } finally { setDownloading(null); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index ef8f7b8..abbef3b 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { SearchBar } from "@/components/SearchBar"; import { Spinner } from "@/components/Spinner"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { MAX_RENDERED_ROWS } from "@/lib/format"; import { useDebounce } from "@/lib/useDebounce"; import { EmptyState } from "@/components/EmptyState"; @@ -108,7 +109,7 @@ export default function SearchPage() { if (!isCurrentRequest()) return; dispatchSearch({ type: "error", - error: (e as Error).message || "Search failed", + error: mapApiError(e, "Search failed").message, }); }); diff --git a/src/app/services/[serviceId]/agents/page.tsx b/src/app/services/[serviceId]/agents/page.tsx index 4303f58..99a0c95 100644 --- a/src/app/services/[serviceId]/agents/page.tsx +++ b/src/app/services/[serviceId]/agents/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState, use, Suspense } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { MAX_RENDERED_ROWS } from "@/lib/format"; import { EmptyState } from "@/components/EmptyState"; import { Pagination } from "@/components/Pagination"; @@ -61,7 +62,7 @@ function ServiceAgentsContent({ }) .catch((e: Error) => { if (cancelled) return; - setError(e.message ?? "failed to load"); + setError(mapApiError(e, "failed to load").message); setPageCount(1); }) .finally(() => { diff --git a/src/app/services/[serviceId]/edit/page.tsx b/src/app/services/[serviceId]/edit/page.tsx index 5765018..504fd19 100644 --- a/src/app/services/[serviceId]/edit/page.tsx +++ b/src/app/services/[serviceId]/edit/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { apiGet, apiPatch } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { PageShell } from "@/components/PageShell"; import { TextField } from "@/components/TextField"; import { Spinner } from "@/components/Spinner"; @@ -46,7 +47,7 @@ export default function EditServicePage({ setPrice(prefilled); setOriginalPrice(prefilled); } catch (e) { - setPrefillError((e as Error).message); + setPrefillError(mapApiError(e).message); } finally { setPrefillLoading(false); } @@ -94,7 +95,7 @@ export default function EditServicePage({ toast.push("Price updated.", "info"); router.push(`/services/${encodeURIComponent(serviceId)}`); } catch (err) { - setError((err as Error).message); + setError(mapApiError(err).message); } finally { setSaving(false); } diff --git a/src/app/services/[serviceId]/page.tsx b/src/app/services/[serviceId]/page.tsx index 0c0de1f..821e46c 100644 --- a/src/app/services/[serviceId]/page.tsx +++ b/src/app/services/[serviceId]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { use } from "react"; import Link from "next/link"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { ErrorMessage } from "@/components/ErrorMessage"; import { Badge } from "@/components/Badge"; import { CopyButton } from "@/components/CopyButton"; @@ -28,7 +29,7 @@ export default function ServiceDetailPage({ let cancelled = false; apiGet(`/api/v1/services/${encodeURIComponent(serviceId)}`) .then((s) => { if (!cancelled) setService(s); }) - .catch((e) => { if (!cancelled) setError(e.message); }); + .catch((e) => { if (!cancelled) setError(mapApiError(e).message); }); apiGet(`/api/v1/services/${encodeURIComponent(serviceId)}/usage`) .then((r) => { if (!cancelled) setRollup(r); }) .catch(() => { diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..731058b 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { apiGet } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { ErrorMessage } from "@/components/ErrorMessage"; import { EmptyState } from "@/components/EmptyState"; import { PageShell } from "@/components/PageShell"; @@ -240,7 +241,7 @@ export default function ServicesPage() { }) .catch((e) => { if (cancelled) return; - setError(e.message ?? "failed to load"); + setError(mapApiError(e, "failed to load").message); setPageCount(1); }) .finally(() => { diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index d7981ed..d9be2a7 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -4,8 +4,8 @@ import { ErrorMessage } from "@/components/ErrorMessage"; import { Spinner } from "@/components/Spinner"; import { PageShell } from "@/components/PageShell"; import { TextField } from "@/components/TextField"; -import type { ApiError } from "@/lib/apiClient"; import { apiGet, apiPost } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import type { FormEvent } from "react"; import { useCallback, useMemo, useState } from "react"; import { parsePositiveInt } from "@/lib/validateNumber"; @@ -26,24 +26,7 @@ type QueryStatus = | { kind: "ok"; result: QueryResult | null } | { kind: "error"; message: string; requestId?: string }; -function describeError(error: unknown): { - message: string; - requestId?: string; -} { - const apiError = error as Partial | null | undefined; - return { - message: - typeof apiError?.message === "string" && apiError.message.length > 0 - ? apiError.message - : error instanceof Error - ? error.message - : "request failed", - requestId: - typeof apiError?.requestId === "string" && apiError.requestId.length > 0 - ? apiError.requestId - : undefined, - }; -} + export default function UsagePage() { const [agent, setAgent] = useState(""); @@ -84,7 +67,7 @@ export default function UsagePage() { setStartDate(toISODate(range.start())); setEndDate(toISODate(range.end())); } - }, []); + }; const onStartDateChange = useCallback((value: string) => { setStartDate(value); @@ -123,7 +106,7 @@ export default function UsagePage() { }); setStatus({ kind: "ok", total: body?.total }); } catch (error) { - const { message, requestId } = describeError(error); + const { message, requestId } = mapApiError(error); setStatus({ kind: "error", message, requestId }); } }; @@ -154,7 +137,7 @@ export default function UsagePage() { ); setQueryResult({ kind: "ok", result: result ?? null }); } catch (error) { - const { message, requestId } = describeError(error); + const { message, requestId } = mapApiError(error); setQueryResult({ kind: "error", message, requestId }); } }; diff --git a/src/app/webhooks/page.tsx b/src/app/webhooks/page.tsx index 06ec6b0..0804e86 100644 --- a/src/app/webhooks/page.tsx +++ b/src/app/webhooks/page.tsx @@ -3,6 +3,7 @@ import { PageShell } from "@/components/PageShell"; import { useEffect, useState } from "react"; import { apiGet, apiPost, apiDelete } from "@/lib/apiClient"; +import { mapApiError } from "@/lib/mapApiError"; import { AlertError } from "@/components/AlertError"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { TimeAgo } from "@/components/TimeAgo"; @@ -21,13 +22,13 @@ export default function WebhooksPage() { const load = () => apiGet<{ items: Webhook[] }>("/api/v1/webhooks") .then((b) => setItems(b.items)) - .catch((e) => setError(e.message)); + .catch((e) => setError(mapApiError(e).message)); useEffect(() => { let cancelled = false; apiGet<{ items: Webhook[] }>("/api/v1/webhooks") .then((b) => { if (!cancelled) setItems(b.items); }) - .catch((e) => { if (!cancelled) setError(e.message); }); + .catch((e) => { if (!cancelled) setError(mapApiError(e).message); }); return () => { cancelled = true; }; }, []); @@ -43,7 +44,7 @@ export default function WebhooksPage() { setUrl(""); await load(); } catch (err) { - setError((err as Error).message); + setError(mapApiError(err).message); } }; @@ -52,7 +53,7 @@ export default function WebhooksPage() { await apiDelete(`/api/v1/webhooks/${id}`); await load(); } catch (err) { - setError((err as Error).message); + setError(mapApiError(err).message); } }; diff --git a/src/lib/__tests__/mapApiError.test.ts b/src/lib/__tests__/mapApiError.test.ts new file mode 100644 index 0000000..24ec156 --- /dev/null +++ b/src/lib/__tests__/mapApiError.test.ts @@ -0,0 +1,313 @@ +import { mapApiError, type MappedApiError } from "../mapApiError"; +import type { ApiError } from "../apiClient"; +import { ApiTimeoutError, ApiRateLimitedError } from "../apiClient"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build an Error that also carries ApiError fields (simulating createHttpError). */ +function apiErrorLike( + overrides: Partial & { message: string }, +): Error & Partial { + const err = new Error(overrides.message); + return Object.assign(err, overrides); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("mapApiError", () => { + // ----------------------------------------------------------------------- + // ApiError shape + // ----------------------------------------------------------------------- + + it("extracts message and requestId from an ApiError-shaped object", () => { + const err = apiErrorLike({ + error: "invalid_request", + message: "boom", + requestId: "req-abc-123", + }); + + expect(mapApiError(err)).toEqual({ + message: "boom", + requestId: "req-abc-123", + }); + }); + + it("extracts message from an ApiError shape without requestId", () => { + const err = apiErrorLike({ + error: "server_error", + message: "Internal Server Error", + }); + + expect(mapApiError(err)).toEqual({ + message: "Internal Server Error", + requestId: undefined, + }); + }); + + it("treats an empty requestId string as absent", () => { + const err = apiErrorLike({ + error: "gone", + message: "Gone", + requestId: "", + }); + + expect(mapApiError(err).requestId).toBeUndefined(); + }); + + // ----------------------------------------------------------------------- + // Plain Error + // ----------------------------------------------------------------------- + + it("uses the error message from a plain Error instance", () => { + const err = new Error("Network failure"); + + expect(mapApiError(err)).toEqual({ + message: "Network failure", + requestId: undefined, + }); + }); + + it("falls back when a plain Error has an empty message", () => { + const err = new Error(""); + + expect(mapApiError(err)).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("uses the provided fallback when a plain Error has an empty message", () => { + const err = new Error(""); + + expect(mapApiError(err, "default text")).toEqual({ + message: "default text", + requestId: undefined, + }); + }); + + // ----------------------------------------------------------------------- + // Non-Error rejections + // ----------------------------------------------------------------------- + + it("returns the fallback for a non-Error rejection", () => { + expect(mapApiError({})).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("returns the fallback for a string rejection", () => { + expect(mapApiError("something broke")).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("returns the fallback for null", () => { + expect(mapApiError(null)).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("returns the fallback for undefined", () => { + expect(mapApiError(undefined)).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("returns a custom fallback for non-Error rejections", () => { + expect(mapApiError("nope", "custom fallback")).toEqual({ + message: "custom fallback", + requestId: undefined, + }); + }); + + // ----------------------------------------------------------------------- + // Timeout errors + // ----------------------------------------------------------------------- + + it("preserves the ApiTimeoutError message verbatim", () => { + const err = new ApiTimeoutError(5000); + + expect(mapApiError(err)).toEqual({ + message: "request timed out after 5000ms", + requestId: undefined, + }); + }); + + it("preserves an ApiTimeoutError with additional ApiError fields", () => { + const err = new ApiTimeoutError(3000); + Object.assign(err, { requestId: "req-timeout-1" }); + + expect(mapApiError(err)).toEqual({ + message: "request timed out after 3000ms", + requestId: "req-timeout-1", + }); + }); + + // ----------------------------------------------------------------------- + // Rate-limited errors + // ----------------------------------------------------------------------- + + it("preserves the ApiRateLimitedError message verbatim", () => { + const err = new ApiRateLimitedError(30000); + + expect(mapApiError(err)).toEqual({ + message: "Rate limited. Retry after 30s", + requestId: undefined, + }); + }); + + it("preserves an ApiRateLimitedError with additional ApiError fields", () => { + const err = new ApiRateLimitedError(60000); + Object.assign(err, { + requestId: "req-rate-1", + error: "rate_limited", + }); + + expect(mapApiError(err)).toEqual({ + message: "Rate limited. Retry after 60s", + requestId: "req-rate-1", + }); + }); + + // ----------------------------------------------------------------------- + // HTTP status code scenarios (simulated through ApiError shape) + // ----------------------------------------------------------------------- + + it("handles a 400 error with the standard ApiError shape", () => { + const err = apiErrorLike({ + error: "invalid_request", + message: "Agent identifier is required", + requestId: "req-400", + }); + + expect(mapApiError(err)).toEqual({ + message: "Agent identifier is required", + requestId: "req-400", + }); + }); + + it("handles a 404 error with the standard ApiError shape", () => { + const err = apiErrorLike({ + error: "not_found", + message: "Service not found", + }); + + expect(mapApiError(err)).toEqual({ + message: "Service not found", + requestId: undefined, + }); + }); + + it("handles a 500 error with the standard ApiError shape", () => { + const err = apiErrorLike({ + error: "internal", + message: "Internal Server Error", + }); + + expect(mapApiError(err)).toEqual({ + message: "Internal Server Error", + requestId: undefined, + }); + }); + + it("handles a 503 error with the standard ApiError shape", () => { + const err = apiErrorLike({ + error: "unavailable", + message: "Service Unavailable", + }); + + expect(mapApiError(err)).toEqual({ + message: "Service Unavailable", + requestId: undefined, + }); + }); + + // ----------------------------------------------------------------------- + // Network errors (TypeError from fetch) + // ----------------------------------------------------------------------- + + it("handles a network error TypeError", () => { + const err = new TypeError("Failed to fetch"); + + expect(mapApiError(err)).toEqual({ + message: "Failed to fetch", + requestId: undefined, + }); + }); + + it("handles AbortError", () => { + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + + expect(mapApiError(err)).toEqual({ + message: "The operation was aborted", + requestId: undefined, + }); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + it("prioritises ApiError.message over Error.message when both are present", () => { + // createHttpError does Object.assign(err, apiError), so the Error.message + // and the assigned .message property are the same. But if someone creates + // an object that has both, the assigned property wins because it's checked + // first (as a Partial). + const err = apiErrorLike({ + error: "boom", + message: "from api error", + requestId: "req-1", + }); + + expect(mapApiError(err).message).toBe("from api error"); + }); + + it("does not mutate the input error", () => { + const err = apiErrorLike({ + error: "test", + message: "test message", + requestId: "req-abc", + }); + // Capture properties before the call so we can verify no mutation. + const messageBefore = (err as Error & Partial).message; + const requestIdBefore = (err as Error & Partial).requestId; + const errorBefore = (err as Error & Partial).error; + + mapApiError(err); + + expect((err as Error & Partial).message).toBe(messageBefore); + expect((err as Error & Partial).requestId).toBe(requestIdBefore); + expect((err as Error & Partial).error).toBe(errorBefore); + }); + + it("handles an ApiError shape where message is null", () => { + const err = { error: "test", message: null as unknown as string }; + + expect(mapApiError(err as unknown as Error)).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("handles an ApiError shape where message is undefined", () => { + const err = { error: "test", message: undefined as unknown as string }; + + expect(mapApiError(err as unknown as Error)).toEqual({ + message: "request failed", + requestId: undefined, + }); + }); + + it("returns the default fallback when no fallback is explicitly passed", () => { + expect(mapApiError("boom").message).toBe("request failed"); + }); +}); diff --git a/src/lib/mapApiError.ts b/src/lib/mapApiError.ts new file mode 100644 index 0000000..54605c9 --- /dev/null +++ b/src/lib/mapApiError.ts @@ -0,0 +1,42 @@ +import type { ApiError } from "./apiClient"; + +export type MappedApiError = { + message: string; + requestId?: string; +}; + +/** + * Translate an unknown error (caught from a promise rejection) into a + * user-facing message and optional requestId. + * + * Strategy: + * 1. If the error has the {@link ApiError} shape (from the shared API client's + * `createHttpError`), its `.message` and `.requestId` are used verbatim. + * 2. Otherwise, if it is an `Error` instance with a non-empty message, that + * message is used. + * 3. Otherwise the `fallback` string is returned. + * + * @param error - The `unknown` value caught from the rejected promise. + * @param fallback - Default message when no structured error is present. + * Defaults to `"request failed"`. + */ +export function mapApiError( + error: unknown, + fallback = "request failed", +): MappedApiError { + const apiError = error as Partial | null | undefined; + + const requestId = + typeof apiError?.requestId === "string" && apiError.requestId.length > 0 + ? apiError.requestId + : undefined; + + const message = + typeof apiError?.message === "string" && apiError.message.length > 0 + ? apiError.message + : error instanceof Error && error.message.length > 0 + ? error.message + : fallback; + + return { message, requestId }; +} diff --git a/src/lib/useApi.ts b/src/lib/useApi.ts index 99d5d1d..c272c48 100644 --- a/src/lib/useApi.ts +++ b/src/lib/useApi.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; import { apiGet, ApiRateLimitedError, ApiTimeoutError } from "./apiClient"; +import { mapApiError } from "./mapApiError"; export type ApiErrorKind = "timeout" | "rate_limited" | "generic"; @@ -102,9 +103,7 @@ export function useApi(path: string | null): State { : "generic"; const errorMsg = isTimeout ? "Request timed out. Please try again." - : isRateLimited - ? (e as Error).message ?? "Rate limited" - : (e as Error).message ?? "failed to load"; + : mapApiError(e, isRateLimited ? "Rate limited" : "failed to load").message; dispatch({ status: "error", diff --git a/src/lib/useApiMutation.ts b/src/lib/useApiMutation.ts index 95c491f..f012b01 100644 --- a/src/lib/useApiMutation.ts +++ b/src/lib/useApiMutation.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { mapApiError } from "./mapApiError"; export type MutationStatus = "idle" | "pending" | "success" | "error"; @@ -96,10 +97,7 @@ export function useApiMutation( throw err; } - const message = - err instanceof Error && err.message - ? err.message - : "failed to mutate"; + const message = mapApiError(err, "failed to mutate").message; const normalized = err instanceof Error ? err : new Error(message); setStatus("error"); diff --git a/src/lib/usePolling.ts b/src/lib/usePolling.ts index d522afc..ad4d473 100644 --- a/src/lib/usePolling.ts +++ b/src/lib/usePolling.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; import { apiGet } from "./apiClient"; +import { mapApiError } from "./mapApiError"; export type PollingStatus = "loading" | "error" | "ok"; @@ -31,11 +32,7 @@ export type UsePollingOptions = { initialPaused?: boolean; }; -function errorMessage(error: unknown) { - return error instanceof Error && error.message.length > 0 - ? error.message - : "failed to load"; -} + function reducer(state: StoredState, action: Action): StoredState { switch (action.type) { @@ -117,7 +114,7 @@ export function usePolling( }) .catch((error) => { if (mountedRef.current && requestId === requestIdRef.current) { - dispatch({ type: "error", error: errorMessage(error) }); + dispatch({ type: "error", error: mapApiError(error, "failed to load").message }); } }) .finally(() => { From 877ccb6d298939b19903e124a6860374f3621a46 Mon Sep 17 00:00:00 2001 From: Marvelous Oni Date: Thu, 30 Jul 2026 21:29:20 +0000 Subject: [PATCH 2/5] fix: add missing onboarding and reports page titles The onboarding and reports layouts reference pageTitles.onboarding and pageTitles.reports but those keys were never added to the object. --- src/app/pageTitles.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/pageTitles.ts b/src/app/pageTitles.ts index 3b3b62e..bafecfd 100644 --- a/src/app/pageTitles.ts +++ b/src/app/pageTitles.ts @@ -10,6 +10,8 @@ export const pageTitles = { transactions: "Transactions", apiKeys: "API keys", search: "Search", + onboarding: "Onboarding", + reports: "Reports", } as const; export const serviceTitle = (serviceId: string) => `Service ${serviceId}`; From 380b9fb945e2301ac0a668be7dd8ea6f36168d80 Mon Sep 17 00:00:00 2001 From: Marvelous Oni Date: Thu, 30 Jul 2026 21:35:43 +0000 Subject: [PATCH 3/5] fix: add missing imports and queryRows derivation in usage/page.tsx UsageRow, PresetKey, PRESET_RANGES, toISODate, buildDateRangeAnnouncement, UsageDateRangeFilters, UsageQueryRows, and deriveUsageRows were all referenced but never imported. Also adds the missing useMemo for queryRows. --- src/app/usage/page.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index d9be2a7..5d5e87a 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -11,6 +11,18 @@ import { useCallback, useMemo, useState } from "react"; import { parsePositiveInt } from "@/lib/validateNumber"; import { validateIdentifier } from "@/lib/validateId"; import { useUsageAnnouncement } from "./useUsageAnnouncement"; +import { + PresetKey, + PRESET_RANGES, + toISODate, + buildDateRangeAnnouncement, +} from "./dateRange"; +import { UsageDateRangeFilters } from "./UsageDateRangeFilters"; +import { + type UsageRow, + UsageQueryRows, + deriveUsageRows, +} from "./UsageQueryRows"; type QueryResult = UsageRow; @@ -57,6 +69,11 @@ export default function UsagePage() { const queryAnnouncement = useUsageAnnouncement(queryResult); + const queryRows = useMemo( + () => deriveUsageRows(queryResult.kind === "ok" ? queryResult.result : null), + [queryResult], + ); + const applyPreset = (key: PresetKey) => { setActivePreset(key); if (key === "custom") { From b8bebe84f2a3197209bfa00f5201d93a136835d3 Mon Sep 17 00:00:00 2001 From: Marvelous Oni Date: Thu, 30 Jul 2026 21:41:09 +0000 Subject: [PATCH 4/5] fix(usage): wrap applyPreset in useCallback to restore memoization The original code had a stray }, []); after applyPreset that was a syntax error from a broken useCallback wrapper. Fixing the syntax error revealed that the memoization contract required useCallback to keep the onPresetChange prop stable across re-renders. --- src/app/usage/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 5d5e87a..4a9b781 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -74,7 +74,7 @@ export default function UsagePage() { [queryResult], ); - const applyPreset = (key: PresetKey) => { + const applyPreset = useCallback((key: PresetKey) => { setActivePreset(key); if (key === "custom") { setStartDate(""); @@ -84,7 +84,7 @@ export default function UsagePage() { setStartDate(toISODate(range.start())); setEndDate(toISODate(range.end())); } - }; + }, []); const onStartDateChange = useCallback((value: string) => { setStartDate(value); From 9db89ec32e303051bd02a358ed79a4a23028d474 Mon Sep 17 00:00:00 2001 From: Marvelous Oni Date: Thu, 30 Jul 2026 22:02:32 +0000 Subject: [PATCH 5/5] fix(test): wrap pagination assertion in waitFor to handle async state update The 'uses the page from searchParams' test was calling getByText synchronously after waitFor for the API call. React batches the setItems/setLoading state updates from the mock resolution, so the DOM wasn't updated yet. Wrapping in waitFor gives React time to flush. --- src/app/services/[serviceId]/agents/page.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/services/[serviceId]/agents/page.test.tsx b/src/app/services/[serviceId]/agents/page.test.tsx index 65672e0..a78d8c6 100644 --- a/src/app/services/[serviceId]/agents/page.test.tsx +++ b/src/app/services/[serviceId]/agents/page.test.tsx @@ -156,7 +156,9 @@ describe("ServiceAgentsPage", () => { ); }); - expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); + }); expect(screen.getByText("26.")).toBeInTheDocument(); });