---
title: "The error model"
description: "Handle stable framework error codes across HTML and JSON responses."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The error model

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`:

```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:

<!-- @sample-preamble
import type { ComponentType } from 'react'
import type { ErrorPageProps } from '@adonia/ui'
declare const MyErrorBody: ComponentType<ErrorPageProps>
-->

```ts
// 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
})
```

```sh
# 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 `AdoniaException`s, 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.

Source: https://adonia.pages.dev/error-model/index.mdx
