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/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}`; 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.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(); }); 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..4a9b781 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -4,13 +4,25 @@ 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"; 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; @@ -26,24 +38,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(""); @@ -74,7 +69,12 @@ export default function UsagePage() { const queryAnnouncement = useUsageAnnouncement(queryResult); - const applyPreset = (key: PresetKey) => { + const queryRows = useMemo( + () => deriveUsageRows(queryResult.kind === "ok" ? queryResult.result : null), + [queryResult], + ); + + const applyPreset = useCallback((key: PresetKey) => { setActivePreset(key); if (key === "custom") { setStartDate(""); @@ -123,7 +123,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 +154,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(() => {