Error handling
What happens when a route handler 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": "Platform error. Please check later or contact support"}.
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 (message, body?). The response body is { message } unless you pass an explicit body, which replaces it:
throw new HttpError(422, "Unprocessable", { errors: { csv: "row 17 malformed" } });
// → 422 {"errors": {"csv": "row 17 malformed"}}
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 } | 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 (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, like any handler would do.
message: req.appInfo.i18n?.t("errors.taskExists") ?? "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.
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 {"errors": {"name": "..."}}— 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 {"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.