Browse documentation

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); }
PropertyMeaning
codeStable machine-readable tag, e.g. "validation_failed".
safeMessageShown to the operator verbatim. Keep internals out of it.
statusHTTP 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

ClasscodeStatusDefault messageRaised by FlowPanel?
FlowpanelValidationErrorvalidation_failed422Validation failedyes
FlowpanelAuthErrorunauthenticated401Authentication requiredyours to throw
FlowpanelAccessErrorforbidden403Forbiddenyes
FlowpanelFieldAccessErrorfield_forbidden403Field "x" cannot be changed.yes
FlowpanelOperationDisabledErroroperation_disabled403This operation is disabled.yes
FlowpanelUnknownFieldErrorunknown_field400Unknown field: "x".yes
FlowpanelNotFoundErrornot_found404Not foundyes
FlowpanelConflictErrorconflict409Conflictno — yours to throw
FlowpanelRateLimitErrorrate_limited429Rate limit exceededyes

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 fail

It 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.signInUrl is set → redirect there.
  • auth.forbiddenUrl is 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.