Errors
The error hierarchy, its status codes, and what reaches the operator.
Throw one of these from an action, an adapter, or a custom route and FlowPanel
turns it into the right HTTP status with a message that is safe to show. Throw
anything else and the caller gets a generic 500 — the detail is logged, never
sent.
That split is the point of the hierarchy: safeMessage is the part you have
decided an operator may read.
FlowpanelError
The base class. Every other error extends it.
export class FlowpanelError { constructor(code: FlowpanelErrorCode, safeMessage: string); }| Property | Meaning |
|---|---|
code | Stable machine-readable tag, e.g. "validation_failed". |
safeMessage | Shown to the operator verbatim. Keep internals out of it. |
status | HTTP status from FLOWPANEL_ERROR_STATUS[code]. |
Construct it directly for a case the subclasses do not cover:
throw new FlowpanelError("conflict", "This order is already being processed");The subclasses
| Class | code | Status | Default message | Raised by FlowPanel? |
|---|---|---|---|---|
FlowpanelValidationError | validation_failed | 422 | Validation failed | yes |
FlowpanelAuthError | unauthenticated | 401 | Authentication required | yours to throw |
FlowpanelAccessError | forbidden | 403 | Forbidden | yes |
FlowpanelFieldAccessError | field_forbidden | 403 | Field "x" cannot be changed. | yes |
FlowpanelOperationDisabledError | operation_disabled | 403 | This operation is disabled. | yes |
FlowpanelUnknownFieldError | unknown_field | 400 | Unknown field: "x". | yes |
FlowpanelNotFoundError | not_found | 404 | Not found | yes |
FlowpanelConflictError | conflict | 409 | Conflict | no — yours to throw |
FlowpanelRateLimitError | rate_limited | 429 | Rate limit exceeded | yes |
Each takes an optional message overriding the default.
The last column matters when you write a hooks.onError or a log filter.
FlowPanel raises FlowpanelAccessError for rejected admin/resource access and
an unbound tenant scope, FlowpanelFieldAccessError for prohibited field
reads/writes, FlowpanelOperationDisabledError for disabled mutations,
FlowpanelUnknownFieldError for fields outside the resource contract,
FlowpanelRateLimitError from the request prologue, and the not-found and
validation errors from resource routes. FlowpanelAuthError and
FlowpanelConflictError are available for application code. (The render path
still catches FlowpanelAuthError if you throw one from a page — see below.)
FlowpanelValidationError
export class FlowpanelValidationError { constructor(fieldErrors: Record<string, string>, message?: string); }The one subclass with extra state: fieldErrors, keyed by field name — and it
is the first constructor argument, with the message second:
constructor(fieldErrors: Record<string, string>, message = "Validation failed");Forms render the entries against the matching inputs instead of showing one banner.
import { FlowpanelValidationError } from "@flowpanel/kit";
throw new FlowpanelValidationError({
email: "That address is already registered",
});
// with a banner message as well:
throw new FlowpanelValidationError({ email: "Already registered" }, "Could not save");Config-time errors are not in this hierarchy
defineAdmin throws plain Errors, deliberately: they happen while your
config module evaluates, long before there is a request to map a status onto.
They carry no code, no safeMessage and no status, and no hooks.onError
sees them — the process fails to boot instead.
What they cover: duplicate resource names, dashboard paths and queue keys;
duplicate action keys within one list; a resource or queue named after a
reserved route segment (dashboards, drawer, queues); a column a resource
points at that the adapter does not report; an omitted columns on a ref the
adapter cannot introspect; an unresolvable cross-resource reference; and a
rowClick: "drawer" with no drawer.
Each names the config path at fault and, where it can, the near match. See Troubleshooting for the full table.
Did you mean
A name that is not registered but is close to one that is gets the suggestion
appended to the error: an exact match once casing and _/- are ignored, else
the closest name within an edit distance of two.
resource "users" points at column "emial" via columns[0], but the adapter
reports no such column. Did you mean "email"? Known columns: "id", "email".It applies to columns, filters, expose, defaultSort.field, rowKey,
create.fields / update.fields names, and to the resource a
detail.tabs[] / drawer.tabs[] entry or a reference points at.
Warnings that do not stop the boot
Some config problems are real but do not justify refusing to start. defineAdmin
collects them on the resolved config as warnings: readonly string[], prints each
one once per process in development, and flowpanel doctor lists them. (doctor
evaluates flowpanel.config.ts to read them; if that fails — a missing
DATABASE_URL, say — it prints one line saying the warnings were not checked
instead of passing silently.)
The one warning today is a create form that cannot succeed:
resource users: create is enabled but required column `email` has no field;
creation will always failIt fires for a column the adapter reports as non-nullable, not generated, not
defaulted (ColumnMeta.hasDefault, which both shipped adapters populate) and not
the primary key, when nothing a create write carries would fill it: not
create.fields, not columns when create.fields is absent, and not
create.defaultValues. An adapter that does not report hasDefault makes a
defaulted column look required; declare it in create.fields or
create.defaultValues to say otherwise.
On the page render path
FlowpanelAuthError and FlowpanelAccessError thrown while rendering a page are
caught by the shell, not surfaced as a crash:
auth.signInUrlis set → redirect there.auth.forbiddenUrlis set and the visitor is signed in → redirect there.- Neither → an inline "Sign in required" or "Access denied" panel.
Other errors propagate to your Next.js error boundary — except for a failed adapter read, which has a surface of its own.
A query that failed
When the adapter's list or get throws while a list page, a detail page or a
related tab renders, FlowPanel renders its own card in place of the data instead
of letting the page crash into the host's error boundary. The card names the
resource and the operation through labels.errors.queryTitle, adds
labels.errors.queryHint, and carries the request id the failure was logged
under:
users: list failed
Check the column list and the database schema. Details are in the server log.
Request 5f3c…The cause always goes to console.error with that same id, and outside
production the card shows the message too. Nothing else about it changes with the
environment. A FlowpanelError — an access denial, a not-found — keeps
travelling, so the auth boundary above still sees it, and so does a redirect()
thrown from your own code.
Dashboard and detail-tab widgets already have their own boundary: a widget whose
query or render throws becomes one failed card, with its copy from
labels.errors.widgetTitle, widgetHint and retry.
Returning versus throwing
Inside an action's run you have both options:
run: async (row, input, ctx) => {
if (row.status === "refunded") {
return { ok: false, error: "Already refunded" }; // 200 with a failure body
}
throw new FlowpanelConflictError("Order is locked"); // 409
}Return { ok: false } for an outcome the operator caused and can fix — the UI
shows the message next to the action. Throw when the request itself was not
valid, and you want the status code to say so.
Errors the routes produce themselves
Some generated-UI responses never pass through the hierarchy at all — the
route answers directly with a string error. These compatibility routes are
distinct from the v1 JSON CRUD API and protected controllers, whose
FlowpanelResultError contains code, message, optional fieldErrors, and
requestId.
400 — malformed request body
A POST that declares Content-Type: application/json but carries a body that
does not parse is rejected before anything else runs:
{ "ok": false, "error": "invalid JSON body" }That covers the row-, bulk-, dashboard- and drawer-action routes and the inline-cell update. A non-JSON content type is parsed as form data instead and never reaches this branch.
404 — unknown resource, action or dashboard
The message depends on NODE_ENV, on purpose. In production it is terse,
so the response confirms nothing about what does exist:
{ "ok": false, "error": "resource not found" }Outside production it names what was requested and what is registered, which turns "why is my action 404-ing" into a one-look answer:
{
"ok": false,
"error": "action not found: \"refnud\". Registered actions: \"refund\", \"cancel\"."
}kind is one of resource, action or dashboard.
403 / 422 from the guard pipeline
403 Cross-origin write requests are not allowed. when the default browser
origin guard blocks a mutation; 403 This admin is read-only. when
readOnly: true blocks a write;
422 too many ids (max 1000) when a bulk action exceeds its cap;
422 validation failed with an issues array when an action form fails
validation. See Actions → Validation.