Skip to content

The error model

Handle stable framework error codes across HTML and JSON responses.

Every error Adonia raises extends AdoniaException and carries a stable E_ADONIA_* code. Codes are public contract: match on error.code, never on the message. They do not change within a major version.

The codes

Code Status Class Raised when
E_ADONIA_INVALID_CONFIG 500 InvalidConfigException config/adonia.ts or a panel option has an invalid shape (message is path-qualified)
E_ADONIA_INVALID_FIELD_CAPABILITY 403 InvalidFieldCapabilityException a field callback URL is missing its app-key signature, or its signed route, form mode, or edit record id was changed
E_ADONIA_PANEL_CONFLICT 500 PanelConflictException two panels claim the same (domain, path) mount, or a panel id is registered twice
E_ADONIA_UNKNOWN_RESOURCE 404 UnknownResourceException the :resource segment names no registered resource
E_ADONIA_UNKNOWN_FIELD 404 UnknownFieldException field.options/field.upload names a field the request cannot see
E_ADONIA_UNKNOWN_PAGE 404 UnknownPageException pages.show names a slug no registered page claims
E_ADONIA_UNKNOWN_ACTION 404 UnknownActionException actions.* names an action the resource does not expose to this request
E_ADONIA_UNAUTHORIZED 403 UnauthorizedException a panel access() verdict of false, or a denied ability
E_ADONIA_INVALID_CURSOR 422 InvalidCursorException a ?cursor= token fails to decode or validate, names an unsortable key, or continues an ordering other than the request’s (resources may opt out with static onInvalidCursor = 'reset')
E_ADONIA_UNSAFE_JSON 422 UnsafeJsonException a json/key-value document is malformed, cyclic, nested past 64 levels, or carries a prototype-polluting key (__proto__, constructor, prototype)
E_ADONIA_UPLOAD_REJECTED 422 UploadRejectedException field.upload has no usable multipart part — none sent, more than the field accepts, or one the body parser already flagged
E_ADONIA_UPLOAD_TYPE 422 UnsupportedUploadTypeException the stored bytes’ magic-byte signature matches no accepted media type
E_ADONIA_UPLOAD_SIZE 422 UploadTooLargeException the bytes on disk exceed uploads.maxSizeMb or the field’s maxSize()
E_ADONIA_UPLOAD_DIMENSIONS 422 InvalidImageDimensionsException an image violates the field’s dimensions({...}) rules, or carries no measurable header while such rules are declared
E_ADONIA_UNSCOPED_RESOURCE 500 UnscopedResourceException a resource on a tenant-scoped panel declares no tenantScope (opting out must be explicit)
E_ADONIA_TENANT_NOT_FOUND 404 TenantNotFoundException the panel’s tenant() resolver returns null or rejects
E_ADONIA_STALE_STATE 409 StaleStateException optimistic-lock conflict on update; the client reloads and re-applies
E_ADONIA_CODEGEN 500 CodegenException indexAdoniaResources() cannot produce its artifacts
E_ADONIA_PLUGIN 500 PluginException a plugin fails outside the §16.4 isolation boundary

The upload family shares one base — UnsupportedUploadTypeException, UploadTooLargeException and InvalidImageDimensionsException all extend UploadRejectedException — so a client that only wants “the upload was refused” catches the base and one that wants a specific message switches on the code. All four are 422 rather than 400: the request is well-formed HTTP addressing a real field, and it is the CONTENT that fails the field’s rules.

AdoniaException itself carries E_ADONIA at 500. It is the base, not a code any thrower uses, and it is what an error reaching the handler without a subclass code reports.

Two renderings, chosen by Accept

AdoniaException defines handle(error, ctx), which v7’s ExceptionHandler prefers over its own rendering. The branch therefore lives on the error, and a host app that replaces its exception handler keeps it.

  • JSON endpoints (request.accepts(['html','json']) === 'json', or no Inertia on the context) → { code, message } at the exception’s status. message is error.detail: the message without the [E_ADONIA_*] prefix that error.message carries, since the code already rides beside it.
  • Browsers and Inertia XHR → the adonia/error page (below).

Both responses are Cache-Control: no-store. They depend on who asked (abilities, tenant) and on state expected to change, so a shared cache holding one would serve a stale 403 or a resolved 404 to somebody else.

The adonia/error page

One page serves 403, 404, 500 and every other status. Props are AdoniaErrorPageProps, built by errorPageProps in packages/core/src/errors.ts:

import type { AdoniaErrorPageProps } from '@adonia/core'

const props: AdoniaErrorPageProps = {
  status: 404,                              // number
  code: 'E_ADONIA_UNKNOWN_RESOURCE',        // string | null — null for a foreign error
  message: null,                            // string | null — error.detail in development, ALWAYS null in production
  title: 'Not found',                       // string — keyed by status, not by code
  description: 'That page does not exist.', // string — generic, always safe to show
  backUrl: '/admin',                        // string | null — the panel mount path
}

title/description are keyed by status, not by code: the reader is a panel user, for whom E_ADONIA_UNKNOWN_FIELD and E_ADONIA_UNKNOWN_RESOURCE are both “that does not exist”. A status with no copy of its own falls back by class (4xx vs 5xx), so a new code renders sensibly without a table edit.

message is the one leak-prone member, so the server — not the page — decides it. In production the page receives null and has no other source for the text; a leak is impossible by construction rather than by discipline. code is shown in production: it is documented contract, it is what a support ticket should quote, and it reveals nothing the docs do not.

Overriding it

The React half is ErrorPage from @adonia/ui/pages, scaffolded into the host app as inertia/pages/adonia/error.tsx. Two levels of override:

// inertia/adonia.ts — replace the body, keep the panel chrome.
import { adonia } from '@adonia/ui'

export default adonia({
  registry: (r) => r.page('error', MyErrorBody),   // props: ErrorPageProps
})
# Replace the whole page, chrome included.
node ace adonia:eject error

The registry override is the usual one: a branded 404 belongs inside the sidebar the user was already looking at, not on a bare white page.

Foreign errors inside a panel route

A TypeError in a resource query(), a Lucid E_ROW_NOT_FOUND, anything the app throws from a transformer — none of them are AdoniaExceptions, and without help they escape to the app’s global handler, dropping the visitor out of the panel on a route the panel owns.

PanelErrorMiddleware is a boundary applied to each panel route group (after the panel slot and the §9.1 share hook, before the access gate and the app’s own panel.middleware([...])). It renders the same chromed page. It is scoped to panel groups on purpose: Adonia has no business deciding how the host app renders errors on its own routes.

It re-throws — leaving the error to the app’s handler, exactly as before — when:

  • the error is an AdoniaException (it renders itself);
  • the error defines its own handle (v7’s self-handling protocol: hijacking it would turn a validation redirect-back or an auth challenge into a 500 page);
  • the client wants JSON (there is no Adonia code to put in the body for a foreign error, so the host handler’s shape is the honest answer);
  • there is no Inertia or no panel on the context (no chrome to render into).

An error carrying its own 4xx/5xx status keeps it; anything else becomes 500. A 5xx is written to ctx.logger before the page is sent, because the visitor only ever sees generic copy and operators still need the stack.

Tests

  • packages/core/tests/errors.spec.ts — every code’s class/status/detail, a rendered-page test and a JSON-endpoint test per code, the production no-leak assertion per code, the copy fallbacks, and the boundary’s re-throw/render matrix.
  • packages/ui/tests/error_page.test.tsx — chrome, copy, the dev-only detail, and the r.page('error', …) override.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close