Error Handling
Overview
The API has a deliberate split between two kinds of errors: ones you expect (bad input, permission denied) and ones you don't (database failures, third-party outages). Expected errors are returned as responses — fast, quiet, no alerting. Unexpected errors are thrown as exceptions — caught by the wrapper, reported to Sentry, visible in monitoring.
This distinction matters because treating everything as an exception floods Sentry with noise, and treating everything as a quiet return means real failures go unnoticed.
The Two Patterns
Return — for expected client errors. The route handler builds a response and returns it directly. withErrorHandling passes it through. No Sentry, no console.error. The caller did something wrong, not the system.
if (!isStaff(auth.claims)) {
return createErrorResponse('Forbidden', { type: ErrorType.FORBIDDEN })
}
Throw — for unexpected failures. Something the system didn't anticipate. withErrorHandling catches it and serializes a structured error response. Sentry only fires when the resulting status is >= 500 — throwing a 4xx AppError from inside a service is silent in monitoring, the same as returning one. Anything that becomes 500/502/503 (unmapped errors, ErrorType.INTERNAL_ERROR, EXTERNAL_SERVICE, DATABASE_ERROR) gets captured.
throw new AppError('External service unavailable', ErrorType.EXTERNAL_SERVICE)
The decision rule: Could this happen during normal usage by a well-behaved client? If yes, return. If no, throw.
| Situation | Pattern | Reasoning |
|---|---|---|
| Auth failure | Return | Client sent bad/expired token |
| Validation failure | Return | Client sent bad input |
| Permission denied | Return | Client lacks access — expected |
| Entity not found | Throw | Could indicate data inconsistency |
| Business rule violation | Throw | Worth tracking frequency |
| External service error | Throw | Always track third-party failures |
| Database error | Throw | Mapped by withTransaction's error handler |
AppError
AppError (at packages/shared-types/src/common/app-error.ts) extends Error with structured metadata:
type— anErrorTypeenum value that determines the HTTP statusstatus— derived from the type, or explicitly overriddendetails— optional structured info (e.g., per-field validation errors)originalError— optional wrapped cause for debugging
ensureAppError(error, fallbackMessage, fallbackType) coerces any thrown value (string, unknown object) into an AppError — used inside catch blocks where the error type isn't guaranteed.
Wire shape (canonical) and validation details
Every error serializes through one formatter, so the shape is uniform across all routes:
{ "success": false, "error": "validation_error", "message": "…", "status": 400, "details": [ … ] }
error is the ErrorType value; details is the optional structured channel. Validation errors put field errors in details as an array of { path, message } (api-validator.ts createValidationErrorResponse):
{ "success": false, "error": "validation_error", "message": "Invalid request data", "status": 400,
"details": [ { "path": "email", "message": "Invalid email" }, { "path": "phone", "message": "Required" } ] }
Consumers map field errors from error.details — not error.errors (there is no errors key; a doc elsewhere claimed one incorrectly). A frontend form-error handler reads path → field.
problem+json is deferred, not adopted
RFC 9457 application/problem+json is the 2026 standard, but we intentionally keep the shape above for now — see ADR-019. The shape is deliberately 9457-adjacent (error→type, message→title/detail, status, details→extension member), so a future switch is a one-formatter change plus a coordinated consumer-parser update — not a route-by-route rewrite. Do not introduce problem+json on individual routes; error format is a global contract and changes only as an all-consumers cutover.
ErrorType Reference
| ErrorType | Status | Typical use |
|---|---|---|
UNAUTHORIZED |
401 | Auth failures |
FORBIDDEN |
403 | Permission denied |
VALIDATION_ERROR |
400 | Bad input |
NOT_FOUND |
404 | Entity doesn't exist |
CONFLICT |
409 | Duplicate, overlap |
BAD_REQUEST |
400 | General bad request |
BUSINESS_RULE_VIOLATION |
422 | Domain rule violated |
INTERNAL_ERROR |
500 | Unexpected failure |
PAYMENT_REQUIRED |
402 | Payment needed |
RESOURCE_DELETED |
404 | Soft-deleted entity |
EXTERNAL_SERVICE |
500 | Third-party failure |
DATABASE_ERROR |
500 | DB failure |
METHOD_NOT_ALLOWED |
405 | Wrong HTTP method |
CONFIGURATION_ERROR |
500 | Missing config |
Non-Fatal Error Reporting
Not all errors happen in request handlers. For errors caught in background jobs, side effects, or other non-request contexts, use captureError() from src/lib/error-reporting.ts. This reports to Sentry with service/operation tags without throwing or returning a response. Never just console.error in a catch block — always report to Sentry.
Code Map
packages/shared-types/src/
common/app-error.ts AppError class, ensureAppError
api/envelopes.ts ErrorType enum, ERROR_STATUS_CODES
src/lib/api/response/
factory.ts createErrorResponse
middleware.ts withErrorHandling (catch + Sentry on 5xx only)
src/lib/error-reporting.ts captureError for non-fatal catch blocks
See Also
- [[api/overview|API]] — The request pipeline and how errors fit in
- [[api/response-envelopes]] — Error envelope shape
- [[monitoring]] — Sentry error reporting infrastructure