Skip to content

Error Handling & Response Format

Overview

All errors are handled by the global error handler in src/middleware/error-handler.ts. It converts exceptions (both application errors and database errors) into a consistent JSON format.

Error Response Format

All errors return an errors array:

json
{
  "errors": [
    {
      "message": "You don't have permission to access this.",
      "extensions": {
        "code": "FORBIDDEN"
      }
    }
  ]
}

Multiple errors can be returned in a single response:

json
{
  "errors": [
    {
      "message": "\"email\" is required",
      "extensions": { "code": "INVALID_PAYLOAD" }
    },
    {
      "message": "\"password\" is required",
      "extensions": { "code": "INVALID_PAYLOAD" }
    }
  ]
}

Error Object Fields

FieldDescription
messageHuman-readable error description
extensions.codeMachine-readable error code
extensions.reasonAdditional detail (only shown in debug/trace log level)

HTTP Status Codes

StatusDescription
400Bad Request — invalid payload, missing required fields
401Unauthorized — token expired, invalid, or missing
403Forbidden — authenticated but no permission
404Not Found — resource does not exist
409Conflict — unique constraint violation
429Too Many Requests — rate limit exceeded
500Internal Server Error — unexpected server error
503Service Unavailable — server under pressure

Application Error Codes

Authentication Errors

CodeStatusDescription
TOKEN_EXPIRED401JWT access or refresh token has expired
TOKEN_REVOKED401Token has been explicitly revoked
INVALID_TOKEN401Token is malformed or signature invalid
INVALID_CREDENTIALS401Wrong email or password
INVALID_OTP401Wrong TOTP code
USER_SUSPENDED403Account is suspended

Authorization Errors

CodeStatusDescription
FORBIDDEN403Action not permitted
MISSING_ACCOUNTABILITY403Request requires authentication

Resource Errors

CodeStatusDescription
NOT_FOUND404Resource not found
COLLECTION_NOT_FOUND404Collection does not exist
FIELD_NOT_FOUND404Field not found in collection
RECORD_NOT_UNIQUE409Duplicate value for unique field

Input Errors

CodeStatusDescription
INVALID_PAYLOAD400Request body fails validation
INVALID_QUERY400Query parameter is invalid
UNSUPPORTED_MEDIA_TYPE400Content-Type not supported
REQUESTS_EXCEEDED429Rate limit exceeded

Server Errors

CodeStatusDescription
INTERNAL_SERVER_ERROR500Unexpected error (details hidden in production)
SERVICE_UNAVAILABLE503Server is under too much load

Database Errors

Database errors (Knex, pg, mysql2, better-sqlite3) are automatically classified and returned with safe messages. Raw database messages are never exposed to the client in production.

DB ErrorStatusCodeMessage
NOT NULL violation400INVALID_PAYLOADa required field is missing
UNIQUE constraint409RECORD_NOT_UNIQUEValue has to be unique
FOREIGN KEY violation400INVALID_PAYLOADreferenced record does not exist
CHECK constraint400INVALID_PAYLOADvalue fails validation constraint
Invalid input syntax400INVALID_PAYLOADvalue does not match the expected field type

In debug or trace log mode, the raw database error message is included in extensions.reason.


Error Handler Behavior

The request.error emitter filter allows extensions to intercept and modify error responses before they are sent.


Error in Debug Mode

When LOG_LEVEL=debug or LOG_LEVEL=trace, additional context is included:

json
{
  "errors": [
    {
      "message": "Invalid payload: value does not match the expected field type",
      "extensions": {
        "code": "INVALID_PAYLOAD",
        "reason": "invalid input syntax for type uuid: \"not-a-uuid\""
      }
    }
  ]
}

Never enable debug in production — it exposes internal database error messages.


Multiple Error Status Codes

When multiple errors have different status codes, the response status is set to 500:

json
HTTP/1.1 500
{
  "errors": [
    { "message": "...", "extensions": { "code": "FORBIDDEN" } },
    { "message": "...", "extensions": { "code": "NOT_FOUND" } }
  ]
}

Extension Error Filtering

Extensions can modify error responses via the request.error filter hook:

typescript
emitter.onFilter('request.error', async (errors, ctx, { accountability }) => {
  // Remove sensitive error details for non-admin users
  if (!accountability?.admin) {
    return errors.map(e => ({
      ...e,
      extensions: { code: e.extensions.code }
    }));
  }
  return errors;
});

Catching Errors in Client Code

typescript
const response = await fetch('/items/articles', {
  headers: { Authorization: `Bearer ${token}` }
});

if (!response.ok) {
  const { errors } = await response.json();
  const firstError = errors[0];
  console.error(`${firstError.extensions.code}: ${firstError.message}`);
  // e.g. "FORBIDDEN: You don't have permission to access this."
}

Common Error Scenarios

Missing required field:

POST /auth/login {}
→ 400 INVALID_PAYLOAD: "email" and "password" are required

Wrong credentials:

POST /auth/login { "email": "x@x.com", "password": "wrong" }
→ 401 INVALID_CREDENTIALS: Wrong email or password

Expired token:

GET /items/articles (with expired JWT)
→ 401 TOKEN_EXPIRED: Token expired

No permission:

DELETE /users/some-uuid (as non-admin)
→ 403 FORBIDDEN: You don't have permission to access this.

Not found:

GET /items/articles/nonexistent-uuid
→ 404 NOT_FOUND: Item "nonexistent-uuid" doesn't exist.

Duplicate key:

POST /users { "email": "existing@example.com", ... }
→ 409 RECORD_NOT_UNIQUE: Value has to be unique

ODP Internal API Documentation