Appearance
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
| Field | Description |
|---|---|
message | Human-readable error description |
extensions.code | Machine-readable error code |
extensions.reason | Additional detail (only shown in debug/trace log level) |
HTTP Status Codes
| Status | Description |
|---|---|
400 | Bad Request — invalid payload, missing required fields |
401 | Unauthorized — token expired, invalid, or missing |
403 | Forbidden — authenticated but no permission |
404 | Not Found — resource does not exist |
409 | Conflict — unique constraint violation |
429 | Too Many Requests — rate limit exceeded |
500 | Internal Server Error — unexpected server error |
503 | Service Unavailable — server under pressure |
Application Error Codes
Authentication Errors
| Code | Status | Description |
|---|---|---|
TOKEN_EXPIRED | 401 | JWT access or refresh token has expired |
TOKEN_REVOKED | 401 | Token has been explicitly revoked |
INVALID_TOKEN | 401 | Token is malformed or signature invalid |
INVALID_CREDENTIALS | 401 | Wrong email or password |
INVALID_OTP | 401 | Wrong TOTP code |
USER_SUSPENDED | 403 | Account is suspended |
Authorization Errors
| Code | Status | Description |
|---|---|---|
FORBIDDEN | 403 | Action not permitted |
MISSING_ACCOUNTABILITY | 403 | Request requires authentication |
Resource Errors
| Code | Status | Description |
|---|---|---|
NOT_FOUND | 404 | Resource not found |
COLLECTION_NOT_FOUND | 404 | Collection does not exist |
FIELD_NOT_FOUND | 404 | Field not found in collection |
RECORD_NOT_UNIQUE | 409 | Duplicate value for unique field |
Input Errors
| Code | Status | Description |
|---|---|---|
INVALID_PAYLOAD | 400 | Request body fails validation |
INVALID_QUERY | 400 | Query parameter is invalid |
UNSUPPORTED_MEDIA_TYPE | 400 | Content-Type not supported |
REQUESTS_EXCEEDED | 429 | Rate limit exceeded |
Server Errors
| Code | Status | Description |
|---|---|---|
INTERNAL_SERVER_ERROR | 500 | Unexpected error (details hidden in production) |
SERVICE_UNAVAILABLE | 503 | Server 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 Error | Status | Code | Message |
|---|---|---|---|
| NOT NULL violation | 400 | INVALID_PAYLOAD | a required field is missing |
| UNIQUE constraint | 409 | RECORD_NOT_UNIQUE | Value has to be unique |
| FOREIGN KEY violation | 400 | INVALID_PAYLOAD | referenced record does not exist |
| CHECK constraint | 400 | INVALID_PAYLOAD | value fails validation constraint |
| Invalid input syntax | 400 | INVALID_PAYLOAD | value 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 requiredWrong credentials:
POST /auth/login { "email": "x@x.com", "password": "wrong" }
→ 401 INVALID_CREDENTIALS: Wrong email or passwordExpired token:
GET /items/articles (with expired JWT)
→ 401 TOKEN_EXPIRED: Token expiredNo 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