Skip to main content

Error handling

What happens when a route handler throws? The framework resolves the error through an ordered error-handler registry:

  1. Your registered handlers — checked first, in registration order.
  2. Built-ins — the HttpError mapper, the Mongoose validation safety net, then the Mongoose cast safety net.
  3. Fallback — nothing matched: the error is logged at error level and the client gets 500 {"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):

ClassStatusDefault message
BadRequestError400Bad request
UnauthorizedError401Unauthorized
ForbiddenError403Forbidden
NotFoundError404Not found
ConflictError409Conflict
HttpErrorany— (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.
  • err is typed as an instance of the class you registered — err.code autocompletes, no casts.
  • req is 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 touch res, so double-send protection and logging stay in one place).
  • Return null/undefined to pass the error to the next entry.
  • registerErrorHandler returns an unregister function — handy in tests.
  • Third argument { logLevel } controls how the handled error is logged (default warn):
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:

SourceWhat it isCaveat
req.paramsPath params (:projectId)Raw strings, never validated — declare a route params: schema and read req.appInfo.params instead
req.appInfo.paramsValidated, coerced path paramsOnly set when the route declares a params: schema
req.appInfo.requestValidated, cast request bodyOnly set when the route declares a request: schema — guard with ?. in handlers registered for many routes
req.appInfo.queryValidated, cast query stringSame — needs a query: schema
req.appInfo.i18nt() + detected languagePresent on framework routes; typed optional
req.appInfo.ipClient IP (proxy-aware)From the global IP detector
req.appInfo.userAuthenticated user documentOnly 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.

warning

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 gets 400 {"errors": {"name": "..."}} — same shape as route validation errors — and the framework logs a warn: your route schema is missing a constraint worth mirroring.
  • If any failing path is internal or renamed (the client sent name, the model field is userName), 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 constraintmaxlength"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.

tip

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 gets 400 {"errors": {"id": "Must be a valid id"}}, keyed by the public input name, logged at warn.
  • If the value was computed server-side, nothing matches and it stays an honest 500 at error level. 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.

tip

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 null returns 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.