Skip to content

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-dependent

Every 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.

CodeHTTPFires whenPayload extensions
KAVO_QUERY_INVALID400Any query grammar/allowlist/limit violation (aggregate)errors[] of the sub-codes below
KAVO_QUERY_INVALID_FIELD400Field not on the filter/sort/select allowlistissue-level
KAVO_QUERY_INVALID_OPERATOR400Unknown or misspelled wire operatorissue-level
KAVO_QUERY_INVALID_VALUE400Coercion failure, malformed bounds, bad pagination valueissue-level
KAVO_QUERY_LIMIT_EXCEEDED400filter.limits.maxDepth / filter.limits.maxInValues / filter.limits.maxLikePatternLength exceededissue-level
KAVO_QUERY_UNSUPPORTED_PARAM400withDeleted/onlyDeleted on a hard-delete entity; include when no include resolver is wiredissue-level
KAVO_QUERY_CONFLICTING_PARAMS400withDeleted=true and onlyDeleted=true set togetherissue-level
KAVO_ARRAY_MUTATION_INVALID_SHAPE400replace<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_SHAPE400A 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_DOCUMENT400patchOne array body is not a well-formed RFC 6902 document within Kavo's subset (ADR-0029's jsonPatch strategy)
KAVO_PATCH_NO_CHANGES400patchOne body carries no field changes — empty, or only the id/soft-delete marker, after immutable-key stripping; updateOne is unaffected
KAVO_NOT_FOUND404Target row missing on findOne/update/patch/delete; also a jsonPatch/resource add naming an id with no matching row
KAVO_FORBIDDEN403A 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_FOUND404jsonPatch remove op names a relation member id that is not currently associated (ADR-0029)
KAVO_ARRAY_MUTATION_MEMBER_NOT_FOUND404resource-strategy remove<Relation> names a member id that is not currently associated (ADR-0029's resource amendment)
KAVO_CONFLICT409Unique violation, or an FK violation blocking a delete (row still referenced by children), mapped by the adapter
KAVO_UNRESOLVED_RELATION422A write whose payload references a related row that does not exist (dangling FK on insert/update), mapped by the adapter
KAVO_ALREADY_DELETED409Soft-deleting an already-deleted row
KAVO_NOT_DELETED409Restoring or purging a row that is not deleted
KAVO_PRECONDITION_FAILED412If-Match names no tag matching the target's current ETag (ADR-0020)
KAVO_PRECONDITION_UNSUPPORTED412If-Match the engine cannot evaluate — untargeted operation, cache.etag off, findOne disabled (ADR-0020 §4)
KAVO_OPERATION_DISABLED405Programmatic call to a disabled registry entry (no route exists over HTTP)
KAVO_OPERATION_NOT_REGISTERED405Programmatic call naming an operation the registry has no entry for at all
KAVO_BULK_FAILED422Atomic bulk failure (reserved — bulk is not built)items[] per-index issues
KAVO_PERSISTENCE_FAILED500Unrecognized adapter/driver errorcause kept internally
KAVO_TRANSACTION_FAILED500Deadlock/serialization failureretryable flag
KAVO_CONFIG_INVALID500Bootstrap 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_ADVANCING500A 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_ERROR500Any 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 HttpExceptionKAVO_HTTP_ERROR, with the response'sstatus taken from the exception's own getStatus(), 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. Its detail is the exception's own message (Nest's built-ins, and a ValidationPipe's message: string[], are already meant for a client to see, so this happens regardless of exposeInternals). By default that message is Nest's own flattening of class-validator's ValidationError[], which loses the field a failure came from. An app whose ValidationPipe instead uses an exceptionFactory shaped like kavoValidationExceptionFactory used to be (issue #437; @kavo/nest no 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; see examples/nest-typeorm/src/common/validation- exception-factory.ts) gets one errors[] entry per field instead — deduped when multiple constraints on the same property share a message — surfaced through KavoExceptionShape.issues the same way QueryValidationException/SchemaValidationException do. This is entirely app-owned: @kavo/nest never installs the pipe itself, so an app that keeps Nest's default exceptionFactory sees no change.
  • Anything else → KAVO_UNEXPECTED_ERROR, fixed at 500, with the original value as cause — leaked into detail only when exposeInternals is on, same as PersistenceException.

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.