Skip to content

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 — an ErrorType enum value that determines the HTTP status
  • status — derived from the type, or explicitly overridden
  • details — 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.detailsnot 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 (errortype, messagetitle/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