Skip to main content

Error handling

What happens when a route handler or a middleware 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": "Something went wrong. Please try again later."}. Every framework 500 uses this one text, translatable through the http.serverError key.

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 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>"}
  • message is required: it is the English fallback when the request's language has no i18nKey translation (or i18n is off), and the text that appears in logs.
  • code, i18nKey and errors are optional and independent. Without code there is no error field; without i18nKey the message is never translated.
  • The English message is 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, and err.issues for 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.
  • 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, with optional headers (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; `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:

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.

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:

MiddlewareErrorStatuserror
AuthUnauthorizedError401AUTH001
Role, no userUnauthorizedError401AUTH001
Role, no matching roleForbiddenError403NO_ACCESS
RateLimiterHttpError (with Retry-After)429TOO_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.

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 {"message": "Validation failed", "errors": {"name": ["..."]}} — the 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 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.

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 {"message": "Validation failed", "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.