Skip to main content

Error handling

Status: Scaffolded 2026-09-24

Every error the API returns has one shape, is produced by one class, and is rendered by one middleware. Clients normalize it to one object. There is no second path.

The envelope​

{ "error": { "code": "validation_error", "message": "Invalid request.", "details": [ ... ] } }

code is a stable snake_case identifier clients switch on. message is safe to show a member. details is optional and only present when it helps the client (field errors); it is never a stack, an internal id, or a database error string.

AppError​

src/lib/errors.ts defines AppError(status, code, message, details?) and short constructors: badRequest(code, message, details?), unauthorized(), forbidden(), notFound(), conflict(code, message), unavailable(code, message), and notImplemented, the handler every milestone-2 route stub uses. Throw or next() an AppError from anywhere below the router and the error handler renders toBody() with the status.

Anything that is not an AppError (a Mongoose cast error, a thrown string, a bug) becomes 500 { error: { code: 'internal_error', message: 'Something went wrong.' } }. The original error and its stack go to the log with the request id, never to the client, in any environment. NODE_ENV=development gets the same body; the detail is in the terminal.

Validation at the boundary​

Each route declares zod schemas for body, query, and params, applied by the validate middleware before the handler runs. A failed parse returns 400 validation_error with one entry per issue:

{ "error": { "code": "validation_error", "message": "Invalid request.",
"details": [{ "path": "body.handle", "message": "must be 3 to 24 characters of a-z, 0-9, _" }] } }

Handlers therefore never see an unvalidated field, and the principal never comes from a body field: it is req.session.userId, set by the auth middleware.

401, 403, or 404​

SituationStatusCode
No session, expired session, bad token401unauthenticated
Valid session, wrong role, resource is public knowledge403forbidden
Valid session, resource exists but the caller may not see it404not_found
Resource does not exist404not_found

The third row is the privacy rule. A friends-only post, a private place list, someone else's conversation, another member's sessions: a 403 would confirm the thing exists and often who owns it. So the API answers 404, identical to the not-found case, whenever the caller's right to know the resource exists is itself in question. 403 is reserved for cases where existence is public and only the action is restricted: a member calling an organizer-only attendee list for a public event, or a non-admin calling /api/admin/*.

501 not_implemented marks scaffold stubs and is removed with the route, see API endpoints.

Client normalization​

Both clients reduce every failure to { message, status, code }:

  • The envelope maps directly.
  • A non-JSON response (nginx 502, a timeout page) becomes status from the response and code: 'http_error'.
  • A network failure or abort becomes status: 0, code: 'network_error'.

Screens switch on code for behavior (unauthenticated signs out, validation_error highlights fields, rate_limited shows a wait) and show message for everything else. The mobile API client and the web src/lib/api.ts implement the same table.

Rate limits​

express-rate-limit on /api/auth/* and the companion route returns 429 { error: { code: 'rate_limited' } } with Retry-After. Limits are per client address behind TRUST_PROXY; the address is not logged.