06 — Error Handling
One exception hierarchy in core/src/errors/, one stable code catalog, one wire shape (RFC 9457 problem details, ADR-0009). Later work adds leaves; nothing existing changes.
1. Hierarchy
KavoException (abstract; implements the KavoExceptionShape contract)
├─ QueryValidationException carries issues[] → errors[] extension
├─ NotFoundException
├─ ConflictException
├─ AlreadyDeletedException soft delete of a deleted row → 409
├─ NotDeletedException restore/purge of a live row → 409
├─ OperationDisabledException
├─ OperationNotRegisteredException registry miss, never "disabled"
├─ PatchNoChangesException patchOne body carries no field changes
├─ BulkOperationException carries items[] (reserved)
├─ PersistenceException
├─ TransactionException carries retryable: boolean
├─ ConfigurationException mostly bootstrap; also a request-time refusal
│ when a handler returns an unusable shape
└─ PaginationNotAdvancingException cursor page produced its own token → 500
(ADR-0021 §5); data-/adapter-dependentEvery leaf binds exactly one catalog code; status, title, and the English message template come from the catalog, so an exception cannot disagree with it. Downstream layers program against the KavoExceptionShape contract; @kavo/nest's filter uses the base class only as its catch token.
2. Error-code catalog
Codes are API surface — renaming one is a breaking change (semver policy). Source of truth: ERROR_CATALOG in core/src/errors/error-catalog.ts.
| Code | HTTP | Fires when | Payload extensions |
|---|---|---|---|
KAVO_QUERY_INVALID | 400 | Any query grammar/allowlist/limit violation (aggregate) | errors[] of the sub-codes below |
KAVO_QUERY_INVALID_FIELD | 400 | Field not on the filter/sort/select allowlist | issue-level |
KAVO_QUERY_INVALID_OPERATOR | 400 | Unknown or misspelled wire operator | issue-level |
KAVO_QUERY_INVALID_VALUE | 400 | Coercion failure, malformed bounds, bad pagination value | issue-level |
KAVO_QUERY_LIMIT_EXCEEDED | 400 | filter.limits.maxDepth / filter.limits.maxInValues / filter.limits.maxLikePatternLength exceeded | issue-level |
KAVO_QUERY_UNSUPPORTED_PARAM | 400 | withDeleted/onlyDeleted on a hard-delete entity; include when no include resolver is wired | issue-level |
KAVO_QUERY_CONFLICTING_PARAMS | 400 | withDeleted=true and onlyDeleted=true set together | issue-level |
KAVO_ARRAY_MUTATION_INVALID_SHAPE | 400 | replace<Relation> body is not an array of {id} refs, or null; a resource-strategy add/remove<Relation> body is not a single id/{id} ref (ADR-0029's replace / resource strategies) | — |
KAVO_ASSOCIATION_INVALID_SHAPE | 400 | A create/update/patch relation value is a bare scalar id instead of an {id} reference object (ADR-0014, issue #291) — composite-key targets keep their ~-delimited scalar shorthand (ADR-0039) | — |
KAVO_JSON_PATCH_INVALID_DOCUMENT | 400 | patchOne array body is not a well-formed RFC 6902 document within Kavo's subset (ADR-0029's jsonPatch strategy) | — |
KAVO_PATCH_NO_CHANGES | 400 | patchOne body carries no field changes — empty, or only the id/soft-delete marker, after immutable-key stripping; updateOne is unaffected | — |
KAVO_NOT_FOUND | 404 | Target row missing on findOne/update/patch/delete; also a jsonPatch/resource add naming an id with no matching row | — |
KAVO_FORBIDDEN | 403 | A resolved policy function (operation, entity, or global scope) evaluated to false for the request (ADR-0037); also raisable by a custom operation's handler | — |
KAVO_JSON_PATCH_TARGET_NOT_FOUND | 404 | jsonPatch remove op names a relation member id that is not currently associated (ADR-0029) | — |
KAVO_ARRAY_MUTATION_MEMBER_NOT_FOUND | 404 | resource-strategy remove<Relation> names a member id that is not currently associated (ADR-0029's resource amendment) | — |
KAVO_CONFLICT | 409 | Unique violation, or an FK violation blocking a delete (row still referenced by children), mapped by the adapter | — |
KAVO_UNRESOLVED_RELATION | 422 | A write whose payload references a related row that does not exist (dangling FK on insert/update), mapped by the adapter | — |
KAVO_ALREADY_DELETED | 409 | Soft-deleting an already-deleted row | — |
KAVO_NOT_DELETED | 409 | Restoring or purging a row that is not deleted | — |
KAVO_PRECONDITION_FAILED | 412 | If-Match names no tag matching the target's current ETag (ADR-0020) | — |
KAVO_PRECONDITION_UNSUPPORTED | 412 | If-Match the engine cannot evaluate — untargeted operation, cache.etag off, findOne disabled (ADR-0020 §4) | — |
KAVO_OPERATION_DISABLED | 405 | Programmatic call to a disabled registry entry (no route exists over HTTP) | — |
KAVO_OPERATION_NOT_REGISTERED | 405 | Programmatic call naming an operation the registry has no entry for at all | — |
KAVO_BULK_FAILED | 422 | Atomic bulk failure (reserved — bulk is not built) | items[] per-index issues |
KAVO_PERSISTENCE_FAILED | 500 | Unrecognized adapter/driver error | cause kept internally |
KAVO_TRANSACTION_FAILED | 500 | Deadlock/serialization failure | retryable flag |
KAVO_CONFIG_INVALID | 500 | Bootstrap config error (fails startup), and a request-time refusal when a handler returns a shape the envelope or the projection cannot use | — |
KAVO_PAGINATION_NOT_ADVANCING | 500 | A cursor page produced the token it was given, so a client following meta.nextCursor would loop forever (ADR-0021 §5). Data-/adapter-dependent, not a bootstrap fault — hence its own code, not KAVO_CONFIG_INVALID | — |
KAVO_HTTP_ERROR | * | Framework-level HttpException reaching the filter without ever going through KavoEngine.execute (§6) | errors[] when the app's own ValidationPipe sets a fieldErrors body (issue #437) |
KAVO_UNEXPECTED_ERROR | 500 | Any other error reaching the filter without ever going through KavoEngine.execute (§6) | cause kept internally |
3. Error context & message strategy
Every exception carries ErrorContext (entityName, operation, correlationId); the engine's DefaultErrorHandler fills whatever the throw site didn't know. Human-readable detail strings are rendered from messageKey (= the code) + messageParams via the catalog's {param} templates, so a consumer can localize by re-rendering the same key and params; core ships the English defaults.
4. Mapping strategy
Adapter errors are translated by the adapter's own table — each adapter has one, keyed on whatever its driver reports (@kavo/typeorm doc 09 §5, @kavo/prisma doc 14 §5, @kavo/mongoose doc 15 §6, @kavo/mikroorm doc 17 §6) — inside the adapter; whatever reaches the engine unrecognized becomes PersistenceException with the original as cause — never swallowed. Whether cause details leak into responses is governed by errors.exposeInternals (default false).
5. Problem-details serialization
toProblemDetails(exception, { exposeInternals }) produces the wire document: type (https://kavo.dev/errors/<kebab-code>), title and status from the catalog, detail, instance (urn:kavo:request:<correlationId>), code, plus errors[] (from exception.issues, whenever a KavoExceptionShape carries one — query issues, each with its own sub-code; or, since issue #437, per-field body validation issues with no sub-code of their own) and items[] (bulk, reserved). The @kavo/nest filter maps it 1:1 with Content-Type: application/problem+json; a different wire shape means swapping this serializer, never the hierarchy. Core never depends on NestJS exceptions — the filter is the boundary.
6. Errors that never reach KavoEngine.execute
KavoExceptionFilter is registered globally (APP_FILTER), so it is the one error boundary for the whole Nest app, not only @Kavo-generated routes — a global ValidationPipe, an unmatched route, or a bug in application code outside a Kavo handler must still answer with problem-details (ADR-0009), never Nest's default { statusCode, message, error } shape. @Catch() (no token) is what makes that possible; the filter narrows to HTTP contexts itself (host.getType() !== "http" rethrows) since a global filter also runs for ws/rpc contexts a REST-only framework binding has nothing to map.
toKavoExceptionShape (@kavo/nest/src/unhandled-exception.ts) adapts whatever isn't a KavoException into the same KavoExceptionShape contract toProblemDetails already serializes:
- A Nest
HttpException→KAVO_HTTP_ERROR, with the response'sstatustaken from the exception's owngetStatus(), not the catalog's (nominal 500) entry — the one place a shape's status legitimately disagrees with its code's catalog row, because Nest already picked the correct one. Itsdetailis the exception's own message (Nest's built-ins, and aValidationPipe'smessage: string[], are already meant for a client to see, so this happens regardless ofexposeInternals). By default thatmessageis Nest's own flattening ofclass-validator'sValidationError[], which loses the field a failure came from. An app whoseValidationPipeinstead uses anexceptionFactoryshaped likekavoValidationExceptionFactoryused to be (issue #437;@kavo/nestno longer bundles one as of issue #467 — schema- driven validation, ADR-0055, is Kavo's own answer to write-body validation now, so class-validator wiring, including this factory, is entirely the app's own choice; seeexamples/nest-typeorm/src/common/validation- exception-factory.ts) gets oneerrors[]entry per field instead — deduped when multiple constraints on the same property share a message — surfaced throughKavoExceptionShape.issuesthe same wayQueryValidationException/SchemaValidationExceptiondo. This is entirely app-owned:@kavo/nestnever installs the pipe itself, so an app that keeps Nest's defaultexceptionFactorysees no change. - Anything else →
KAVO_UNEXPECTED_ERROR, fixed at 500, with the original value ascause— leaked intodetailonly whenexposeInternalsis on, same asPersistenceException.
This mapping lives in @kavo/nest, not in the exception hierarchy: these are framework-level errors Kavo did not raise and does not own the shape of, so no new KavoException leaf exists for them.