Error handling
What happens when a route handler or a middleware throws? The framework resolves the error through an ordered error-handler registry:
- Your registered handlers — checked first, in registration order.
- Built-ins — the
HttpErrormapper, the Mongoose validation safety net, then the Mongoose cast safety net. - Fallback — nothing matched: the error is logged at
errorlevel and the client gets500 {"message": "Something went wrong. Please try again later."}. Every framework 500 uses this one text, translatable through thehttp.serverErrorkey.
The first entry whose error class matches (instanceof) and whose handler returns a response wins. That gives you two tools: throw typed HTTP errors from your own code, and register handlers for error types you don't own.
Throwing HTTP errors from your code
Deep inside business logic you often know the right HTTP answer — the document doesn't exist, the user isn't allowed — but you don't have res there, and threading it through every function is noise. Throw instead:
import { NotFoundError, ForbiddenError } from "@adaptivestone/framework/services/http/httpErrors.js";
class Posts extends AbstractController {
get routes() {
return {
get: { "/:id": { handler: this.getOne } },
};
}
async getOne(req, res) {
const post = await this.app.getModel("Post").findById(req.params.id);
if (!post) {
throw new NotFoundError("Post not found");
// → 404 {"message": "Post not found"}
}
if (String(post.ownerId) !== req.appInfo.user.id) {
throw new ForbiddenError("Not your post");
// → 403 {"message": "Not your post"}
}
return res.status(200).json({ data: post.getPublic() });
}
}
It works from any depth — a service function three calls down can throw the same way.
Available classes (all from services/http/httpErrors.js):
| Class | Status | Default message |
|---|---|---|
BadRequestError | 400 | Bad request |
UnauthorizedError | 401 | Unauthorized |
ForbiddenError | 403 | Forbidden |
NotFoundError | 404 | Not found |
ConflictError | 409 | Conflict |
HttpError | any | — (base class) |
Every constructor accepts a message string, or a details object in place of it (see Error codes and translated messages). Every framework error response follows one contract, so clients can rely on it:
{ error?: string; message: string; errors?: { [field: string]: string[] } }
error is a machine-readable code, message the human text, and errors the field errors: always an array per field, exactly as request validation answers. Field errors go in errors:
throw new HttpError(422, {
message: "Unprocessable",
errors: { csv: "row 17 malformed" },
});
// → 422 {"message": "Unprocessable", "errors": {"csv": ["row 17 malformed"]}}
errors takes a string or an array per field, or validation issues ([{ message, path, params }]). A message that is an i18n key (for example accounts.errors.csvRow) is translated the same way validation messages are; free text is sent as-is. An empty errors object is left out.
A fully custom response body
When a response genuinely needs a different shape, say so explicitly with body. It is the response: sent as-is, with no error, message or errors added. message is still required and only goes to the log:
throw new ConflictError({
message: "Task already exists",
body: { existingId: task.id },
});
// → 409 {"existingId": "..."}
body opts this response out of the contract, so clients get no guaranteed message or errors from it. It cannot be combined with code, i18nKey or errors: TypeScript rejects the combination, and at runtime body wins and a one-time ASF_HTTP_ERROR_BODY_MIXED warning names the ignored fields. Prefer the contract whenever it fits.
:::warning Deprecated: body as a separate argument
The older form new HttpError(422, "Unprocessable", body) (and new NotFoundError(message, body)) still works but is deprecated and will be removed in v6. It logs a one-time DeprecationWarning per error class (ASF_DEP_HTTP_ERROR_BODY). Use the details object instead: { message, errors } for field errors, or { message, body } for a custom body. A plain message string such as new NotFoundError("Post not found") is not deprecated.
:::
Error codes and translated messages
Instead of the message, pass a details object. code is a machine-readable code the client can branch on; i18nKey names the translation of the message and is never sent to the client:
throw new ConflictError({
code: "ALREADY_CONFIRMED",
i18nKey: "accounts.errors.alreadyConfirmed",
message: "This account is already confirmed.",
});
// → 409 {"error": "ALREADY_CONFIRMED", "message": "<translation, or the English message>"}
messageis required: it is the English fallback when the request's language has noi18nKeytranslation (or i18n is off), and the text that appears in logs.code,i18nKeyanderrorsare optional and independent. Withoutcodethere is noerrorfield; withouti18nKeythe message is never translated.- The English
messageis used as-is, never interpreted as i18next syntax, so it can safely include request data. - The details are readable on the error too:
err.code,err.i18nKey, anderr.issuesfor the field errors.
Response headers
Add headers to the details object when the answer needs them, for example how long to wait before retrying:
throw new HttpError(503, {
code: "MAINTENANCE",
message: "Back in a few minutes.",
headers: { "Retry-After": "120" },
});
// → 503 Retry-After: 120 {"error": "MAINTENANCE", "message": "Back in a few minutes."}
headers works with a custom body too.
To keep throw sites short, wrap your own convention in a subclass:
import { BadRequestError } from "@adaptivestone/framework/services/http/httpErrors.js";
const EN = { LOGIN_CODE_EXPIRED: "This code has expired. Request a new one." };
export class AccountsError extends BadRequestError {
constructor(code) {
super({ code, i18nKey: `accounts.errors.${code}`, message: EN[code] });
}
}
throw new AccountsError("LOGIN_CODE_EXPIRED");
For a status you use often, subclass once and throw everywhere:
import { HttpError } from "@adaptivestone/framework/services/http/httpErrors.js";
export class PaymentRequiredError extends HttpError {
constructor(message = "Subscription expired") {
super(402, message);
}
}
Thrown HttpErrors are logged at verbose level — they're deliberate control flow, not defects, so they don't pollute your error logs.
Mapping errors you don't own
Libraries throw their own error types — the Mongo driver, payment SDKs, queue clients. Register a handler for the class, typically from the bootHttp hook (the Server constructor option that runs with the live app):
import { MongoServerError } from "mongodb";
const server = new Server({
...folderConfig,
bootHttp: async (app) => {
if (!app.httpServer) {
throw new Error("bootHttp ran without a live httpServer");
}
app.httpServer.registerErrorHandler(MongoServerError, (err) =>
err.code === 11000
? { status: 409, body: { message: "Already exists" } }
: null, // null = "not mine after all" → try the next entry
);
},
});
:::tip Register them in tests too
The framework builds its own server for tests and does not read your Server
options, so handlers registered here are absent under test — the 409 above comes
back as a 500. Pass the same bootHttp to
configureTestServer in
your test setup.
:::
The handler contract:
- Signature:
(err, req) => { status, body, headers? } | null— async is fine, the result is awaited. erris typed as an instance of the class you registered —err.codeautocompletes, no casts.reqis the same request the route handler had —req.appInfo.request,req.appInfo.i18n, etc.- Return
{ status, body }to produce the response, with optionalheaders(the framework sends it — handlers never touchres, so double-send protection and logging stay in one place). - Return
null/undefinedto pass the error to the next entry. registerErrorHandlerreturns an unregister function — handy in tests.- Third argument
{ logLevel }controls how the handled error is logged (defaultwarn):
app.httpServer.registerErrorHandler(
StripeCardError,
(err) => ({ status: 402, body: { message: err.declineReason } }),
{ logLevel: "verbose" },
);
If a handler itself throws, the framework logs it (with the stack) and falls back to the 500 — a broken error handler can never crash the request pipeline.
Handler-side types, if you want to extract the function:
import type { ErrorHandlerFn, ErrorHandlerResult } from "@adaptivestone/framework/services/http/builtinErrorHandlers.js";
Reading the request in a handler
req is the full framework request, so a handler can build responses from everything the route knew — path params, validated body and query, locale, client IP. A fuller example: a task-creation route hits a unique index, and the handler turns the raw driver error into an answer that names what collided and where:
// Route: POST /project/:projectId/tasks?notify=email
// request: object({ title: string().required() })
// query: object({ notify: string() })
// Model: title has a unique index per project → E11000 on duplicates.
app.httpServer?.registerErrorHandler(MongoServerError, (err, req) => {
if (err.code !== 11000) {
return null; // other driver errors → next entry (→ 500 fallback)
}
// Which unique field collided — E11000 carries it in `keyValue`.
const [field] = Object.keys(err.keyValue ?? {});
return {
status: 409,
body: {
// Translated for the request's locale; `defaultValue` is what the
// client gets when your locale files do not define the key.
message:
req.appInfo.i18n?.t("errors.taskExists", {
defaultValue: "Task already exists",
}) ?? "Task already exists",
field, // "title"
projectId: req.params.projectId, // raw path param (string)
attempted: req.appInfo.request?.title, // validated body value
notify: req.appInfo.query?.notify ?? null, // validated query value
},
};
});
What's available on req:
| Source | What it is | Caveat |
|---|---|---|
req.params | Path params (:projectId) | Raw strings, never validated — declare a route params: schema and read req.appInfo.params instead |
req.appInfo.params | Validated, coerced path params | Only set when the route declares a params: schema |
req.appInfo.request | Validated, cast request body | Only set when the route declares a request: schema — guard with ?. in handlers registered for many routes |
req.appInfo.query | Validated, cast query string | Same — needs a query: schema |
req.appInfo.i18n | t() + detected language | Present on framework routes; typed optional |
req.appInfo.ip | Client IP (proxy-aware) | From the global IP detector |
req.appInfo.user | Authenticated user document | Only on routes running the auth middleware (GetUserByToken) |
req.method, req.path, req.headers, … | Anything Express exposes | — |
One design boundary to keep in mind: the handler decides the response; the framework does the sending and the logging. If you find a handler reaching for res or a logger, that logic probably belongs in the route handler's own try/catch instead.
Errors from middleware
Since 5.5, an error thrown in a middleware goes through the same registry as an error from a route handler. Before, any middleware error became a 500. The built-in middleware throw coded errors:
| Middleware | Error | Status | error |
|---|---|---|---|
Auth | UnauthorizedError | 401 | AUTH001 |
Role, no user | UnauthorizedError | 401 | AUTH001 |
Role, no matching role | ForbiddenError | 403 | NO_ACCESS |
RateLimiter | HttpError (with Retry-After) | 429 | TOO_MANY_REQUESTS |
So one handler can reshape the framework's answers along with your own. For example, every 401, including the one from Auth:
app.httpServer.registerErrorHandler(UnauthorizedError, (err, req) => ({
status: 401,
body: { error: err.code ?? "UNAUTHORIZED", message: err.message, loginUrl: "/login" },
}));
Your own middleware can reject the same way; see Middleware › Rejecting a request.
Matching order
Entries are matched by instanceof in registration order — not by class specificity. Your handlers always run before the built-ins, so you can intercept or override anything, including the built-ins themselves.
An early handler for a base class shadows later handlers for its subclasses. If you register a handler for HttpError and later one for NotFoundError, the HttpError one wins for every NotFoundError thrown — it was registered first and NotFoundError instanceof HttpError is true. Register the specific classes first, or branch inside one handler.
Built-in: the Mongoose validation safety net
The recommended practice is to mirror model constraints in your route schema — a maxLength: 50 in the model should have a .max(50) in the route's request: schema, so bad input fails fast with a clean, translated 400 (see Validation).
But when a constraint slips through, doc.save() throws a Mongoose ValidationError, and a built-in registry entry catches it:
- If every failing model path is a field the client actually sent (a key of the validated
request:/query:input), the client gets400 {"message": "Validation failed", "errors": {"name": ["..."]}}— the same shape as route validation errors — and the framework logs awarn: your route schema is missing a constraint worth mirroring. - If any failing path is internal or renamed (the client sent
name, the model field isuserName), it stays an honest 500. Model field names are never leaked to the client, and a server-side data bug is never blamed on the client.
Each message is rebuilt from the validation kind and the schema constraint — maxlength → "Must be at most 255 characters", a Number cast failure → "Must be a number", enum → "Must be one of: …" — and never includes the value the client submitted. Mongoose's own default messages interpolate that value (a phone number, a password pasted into the wrong field), which would otherwise leak it into the response and the log. For the same reason a custom message set on the model (maxLength: [50, 'Name too long']) is not passed through — it's rebuilt generically, since a custom string can't be told apart from a templated default that embedded the value. The warn log line for a handled error is sanitized the same way; a failure that stays a 500 logs the original error in full. These fallback messages are plain English and not translated; put user-facing, i18n wording on the route schema.
Note this covers Mongoose validation errors only. A duplicate-key violation (E11000) is a MongoServerError from the driver, not a ValidationError — map it yourself as shown above if you want a 409. A standalone cast failure is a CastError, a sibling of ValidationError rather than a subclass, so it has its own built-in — described next.
The safety net is a fallback, not the contract. Route schemas are the API's source of truth: they produce field-accurate, i18n-translated errors under the names the client knows. The safety net exists so a missed constraint degrades to a useful 400 instead of a mystery 500.
Built-in: the Mongoose cast safety net
The classic version of this is a path param handed straight to a model:
async getPerson(req, res) {
// `req.params.id` is a raw, unvalidated string
const person = await this.app.getModel("Person").findById(req.params.id);
return res.json({ data: person });
}
GET /person/abc cannot be cast to an ObjectId, so Mongoose throws a CastError. A CastError is not a ValidationError — they are siblings — so the validation safety net above structurally cannot see it. A built-in entry handles it separately:
- If the rejected value is one the client actually supplied — matched by value against the path params and the validated
request:/query:input — the client gets400 {"message": "Validation failed", "errors": {"id": ["Must be a valid id"]}}, keyed by the public input name, logged atwarn. - If the value was computed server-side, nothing matches and it stays an honest 500 at
errorlevel. A bug in your own code is never blamed on the caller.
The message is rebuilt from the cast kind (Must be a valid id, Must be a number, Must be a valid date), so neither the rejected value nor the internal model path (_id) reaches the response — and the warn log line is sanitized the same way.
This is a floor, not a design. Declaring a params: schema rejects the same request earlier, with your own wording, i18n, and coercion — and it documents the constraint in your OpenAPI output. Reach for the floor only where you haven't got round to a schema yet.
What still becomes a 500
- Any error no registry entry claims (including
nullreturns all the way down). - A registry handler that throws while handling.
- Mongoose validation failures on internal/renamed fields (see above).
- Mongoose cast failures on server-computed values (see above).
All of these are logged at error level with the original error, so the details are in your logs — the client only ever sees the generic message.