Errors

Every error that leaves the API has the same shape, whatever caused it:

JSON
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "requestId": "5c397446-0c18-4cfe-99be-d08c78419bec",
    "details": { "price": "Must be a number", "role": "Unknown field" }
  }
}
KeyAlwaysMeaning
codeyesStable, machine readable. Switch on this in clients.
messageyesHuman readable and safe to show
requestIdyesSame as the X-Request-Id response header. Search logs and devtools by it.
detailssometimesOne message per field or query parameter

Codes#

StatusCodeWhen
400BAD_REQUESTInvalid list query, a JSON body nested too deep or with too many values (jsonLimits), or badRequest() from your code
400INVALID_JSONThe body is not valid JSON
400INVALID_TOKENPassword reset token unknown, expired or used
401UNAUTHORIZEDNo valid session where one is needed, or wrong login
403FORBIDDENSigned in but not allowed
404NOT_FOUNDUnknown route, malformed id, missing record, or a record the user may not see
409CONFLICTDuplicate value on a .unique() field
413PAYLOAD_TOO_LARGEBody larger than bodyLimit (1 MB)
422VALIDATION_ERRORBody failed validation; see details
429RATE_LIMITEDToo many requests (see Retry-After), or a sign-in for a locked account
500INTERNAL_ERRORAnything 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):

JavaScript
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');
HelperSignatureDefault 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:

ThrownResponse
Malformed JSON body400 INVALID_JSON
Body over bodyLimit413 PAYLOAD_TOO_LARGE
Other 400 errors from Express middleware400 BAD_REQUEST with their message
Mongoose CastError on _id404 NOT_FOUND
Mongoose CastError on another path422 VALIDATION_ERROR with { <path>: "Invalid value for <path>" }
Mongoose ValidationError422 VALIDATION_ERROR with one message per path
MongoDB duplicate key (E11000)409 CONFLICT, "email already exists", details { email: "Already taken" }
Anything else500 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:

JavaScript
r.get('/boom', { policy: 'public' }, () => {
  throw new Error('db password is hunter2');
});
JSON
{ "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:

JSON
{
  "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#

JavaScript
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.