// One place that turns whatever the API threw into something an operator can // act on. Before this existed, every list screen surfaced the raw status text // ("Internal Server Error", "Too Many Requests", "Bad Request") — which names // the failure but never the fix — and, worse, rendered its empty state *next // to* the error, so "the load failed" and "there is nothing here" looked // identical. An operator can't tell an empty audit log from a broken one. import { ArcadiaError } from "@crema/arcadia-core-client" export type LoadError = { /** Plain-language headline. Never a raw HTTP status. */ title: string /** What to do about it. Empty when there's genuinely nothing to suggest. */ detail: string status?: number /** Set for 429s — seconds until it's worth retrying. Drives auto-retry. */ retryAfterSec?: number /** True when retrying might plausibly work (5xx, 429, network). */ retryable: boolean /** Field-level validation messages, flattened from Ecto's error tree. */ fields?: string[] } /** Flatten Ecto's nested `{tenant: {slug: ["has already been taken"]}}`. */ function flattenFieldErrors(details: unknown): string[] { const lines: string[] = [] const walk = (obj: unknown, prefix: string) => { if (Array.isArray(obj)) { lines.push(prefix ? `${prefix}: ${obj.join(", ")}` : obj.join(", ")) } else if (obj && typeof obj === "object") { for (const [k, v] of Object.entries(obj)) { walk(v, prefix ? `${prefix}.${k}` : k) } } } walk(details, "") return lines } export function describeError(err: unknown, context = "load"): LoadError { // Network / CORS / server down — fetch rejects before any status exists. if (err instanceof TypeError || (err instanceof Error && /fetch/i.test(err.message))) { return { title: "Can't reach arcadia", detail: "The API didn't respond. Check the service is running and that this host is allowed to call it, then retry.", retryable: true, } } if (!(err instanceof ArcadiaError)) { return { title: `Couldn't ${context}`, detail: err instanceof Error && err.message ? err.message : "An unexpected error occurred.", retryable: true, } } const fields = err.details ? flattenFieldErrors(err.details) : undefined switch (true) { case err.status === 401: return { title: "Your session has expired", detail: "Sign in again to continue.", status: 401, retryable: false, } case err.status === 403: return { title: "You don't have access to this", detail: "Your account lacks the role this screen needs. A platform administrator can grant it.", status: 403, retryable: false, } case err.status === 404: return { title: "Not found", detail: "It may have been deleted, or the endpoint isn't available on this deployment.", status: 404, retryable: false, } case err.status === 422: return { title: "That didn't validate", detail: fields?.length ? "" : err.message, status: 422, retryable: false, fields, } case err.status === 429: return { title: "Too many requests", detail: "arcadia is rate-limiting this console. It'll retry automatically.", status: 429, retryAfterSec: 30, retryable: true, } case err.status >= 500: return { title: "arcadia hit a server error", detail: `The request failed on the server${ err.requestId ? ` (request ${err.requestId})` : "" }. Retry, and if it persists check the service logs.`, status: err.status, retryable: true, } default: return { title: `Couldn't ${context}`, // Prefer the server's own message over the bare status line. detail: fields?.length ? "" : err.message, status: err.status, retryable: err.status >= 500, fields, } } } /** One-line form, for toasts and inside dialogs. */ export function errorMessage(err: unknown, context = "save"): string { const d = describeError(err, context) const parts = [d.title] if (d.fields?.length) parts.push(d.fields.join("; ")) else if (d.detail) parts.push(d.detail) return parts.join(" — ") }