/** * Error classification. * * Server `result_code`s are downstream-opaque (only `"ok"` is a stable * contract — see `docs/ARCHITECTURE.md`), so the client cannot key UX off * them. Instead every caught error is collapsed into a small, stable set * of client-side kinds, derived from the transport-level signal that IS * reliable: HTTP status (`AuthError`), the Connect `Code`, a fetch * `TypeError`, or the live `navigator.onLine` flag. */ import { ConnectError, Code } from "@connectrpc/connect"; import { AuthError } from "../../api/auth"; /** A stable, user-facing classification of any caught error. */ export type ErrorKind = | "offline" | "network" | "auth" | "forbidden" | "conflict" | "notFound" | "rateLimit" | "server" | "unknown"; function fromHttpStatus(status: number): ErrorKind { if (status === 401) return "auth"; if (status === 403) return "forbidden"; if (status === 404) return "notFound"; if (status === 409) return "conflict"; if (status === 429) return "rateLimit"; if (status >= 500) return "server"; return "unknown"; } function fromConnectCode(code: Code): ErrorKind { switch (code) { case Code.Unauthenticated: return "auth"; case Code.PermissionDenied: return "forbidden"; case Code.NotFound: return "notFound"; case Code.AlreadyExists: case Code.Aborted: case Code.FailedPrecondition: return "conflict"; case Code.ResourceExhausted: return "rateLimit"; case Code.Unavailable: return "network"; case Code.Internal: case Code.DataLoss: case Code.Unknown: return "server"; default: return "unknown"; } } function hasNumericStatus(err: unknown): err is { status: number } { return ( typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number" ); } /** Collapse any caught value into a stable {@link ErrorKind}. */ export function classifyError(err: unknown): ErrorKind { if (typeof navigator !== "undefined" && navigator.onLine === false) { return "offline"; } if (err instanceof AuthError) return fromHttpStatus(err.status); if (err instanceof ConnectError) return fromConnectCode(err.code); if (err instanceof TypeError && /fetch|network|load failed/i.test(err.message)) { return "network"; } if (hasNumericStatus(err)) return fromHttpStatus(err.status); return "unknown"; } const RETRYABLE: ReadonlySet = new Set([ "offline", "network", "server", ]); /** Whether retrying the same operation could plausibly succeed. */ export function isRetryable(kind: ErrorKind): boolean { return RETRYABLE.has(kind); }