Errors
Every error that leaves the API has the same shape, whatever caused it:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"requestId": "5c397446-0c18-4cfe-99be-d08c78419bec",
"details": { "price": "Must be a number", "role": "Unknown field" }
}
}| Key | Always | Meaning |
|---|---|---|
code | yes | Stable, machine readable. Switch on this in clients. |
message | yes | Human readable and safe to show |
requestId | yes | Same as the X-Request-Id response header. Search logs and devtools by it. |
details | sometimes | One message per field or query parameter |
Codes#
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Invalid list query, a JSON body nested too deep or with too many values (jsonLimits), or badRequest() from your code |
| 400 | INVALID_JSON | The body is not valid JSON |
| 400 | INVALID_TOKEN | Password reset token unknown, expired or used |
| 401 | UNAUTHORIZED | No valid session where one is needed, or wrong login |
| 403 | FORBIDDEN | Signed in but not allowed |
| 404 | NOT_FOUND | Unknown route, malformed id, missing record, or a record the user may not see |
| 409 | CONFLICT | Duplicate value on a .unique() field |
| 413 | PAYLOAD_TOO_LARGE | Body larger than bodyLimit (1 MB) |
| 422 | VALIDATION_ERROR | Body failed validation; see details |
| 429 | RATE_LIMITED | Too many requests (see Retry-After), or a sign-in for a locked account |
| 500 | INTERNAL_ERROR | Anything unexpected |
Throwing errors#
Import the helpers from #calm and throw. Express 5 forwards errors from async handlers, so there is no try/catch or next(err):
import { badRequest, unauthorized, forbidden, notFound, conflict, validationError, tooManyRequests, CalmError } from '#calm';
throw badRequest('items must be an array'); // 400 BAD_REQUEST
throw badRequest('Invalid date range', { to: 'Must be after from' }); // with details
throw unauthorized(); // 401 "Authentication required"
throw forbidden('Only the organizer can cancel this event'); // 403
throw notFound('Invoice not found'); // 404
throw conflict('This product has orders', { product: 'In use' }); // 409
throw validationError({ email: 'Already invited' }); // 422 "Validation failed"
throw tooManyRequests(); // 429
throw new CalmError(402, 'PAYMENT_REQUIRED', 'Upgrade your plan to add more projects');| Helper | Signature | Default message |
|---|---|---|
badRequest | (message?, details?) | Bad request |
unauthorized | (message?) | Authentication required |
forbidden | (message?) | You do not have permission to do this |
notFound | (message?) | Resource not found |
conflict | (message?, details?) | Resource already exists |
validationError | (details, message?) | Validation failed |
tooManyRequests | (message?) | Too many requests, slow down |
new CalmError | (status, code, message, details?) |
Messages of CalmErrors are sent to clients as they are, so write them for your users.
What other errors become#
Errors that are not CalmErrors are translated in calm/errors.js:
| Thrown | Response |
|---|---|
| Malformed JSON body | 400 INVALID_JSON |
Body over bodyLimit | 413 PAYLOAD_TOO_LARGE |
Other 400 errors from Express middleware | 400 BAD_REQUEST with their message |
Mongoose CastError on _id | 404 NOT_FOUND |
Mongoose CastError on another path | 422 VALIDATION_ERROR with { <path>: "Invalid value for <path>" } |
Mongoose ValidationError | 422 VALIDATION_ERROR with one message per path |
MongoDB duplicate key (E11000) | 409 CONFLICT, "email already exists", details { email: "Already taken" } |
| Anything else | 500 INTERNAL_ERROR, "Something went wrong" |
Nothing internal leaks#
For a 500, the client only ever gets Something went wrong. The real message and stack trace go to the log (with the request id) and to devtools:
r.get('/boom', { policy: 'public' }, () => {
throw new Error('db password is hunter2');
});{ "error": { "code": "INTERNAL_ERROR", "message": "Something went wrong", "requestId": "0b7e6f3c-5f8e-4d7b-9a43-2c1f8d7e6a10" } }In development mode only, 500 responses also include the original error, which saves a trip to the terminal:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong",
"requestId": "0b7e6f3c-5f8e-4d7b-9a43-2c1f8d7e6a10",
"debug": { "message": "db password is hunter2", "stack": "Error: db password is hunter2\n at ..." }
}
}The devtools Requests panel shows the same error with a clickable stack trace.
Request ids#
Every response has an X-Request-Id header. A client can send its own (letters, digits, ., _, -, up to 128 characters) to correlate logs across services; otherwise a UUID is generated. Every log line written during the request carries it, including your own ctx.log.info(...) calls.
Handling errors in clients#
const res = await fetch('/api/posts', { method: 'POST', headers, body: JSON.stringify(post) });
if (!res.ok) {
const { error } = await res.json();
if (error.code === 'VALIDATION_ERROR') showFieldErrors(error.details);
else if (error.code === 'UNAUTHORIZED') redirectToLogin();
else toast(`${error.message} (ref ${error.requestId})`);
}204 responses have no body; do not call res.json() on them.