Skip to content

Errors

An error thrown anywhere in a request becomes a response. This page covers the errors you throw, the shape every error response has, onError hooks that answer errors your own way, and reportError, which receives the failures no response can carry.

A handler or a hook refuses a request by throwing. HttpError carries a status, and httpError() builds one with an error code of your own:

route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/orders/:id/ship"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/orders/:id/ship",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}) => Order

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
) => {
const
const order: Order | undefined
order
=
const orders: {
find(id: string): Order | undefined;
}
orders
.find(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
params: {
id: string;
}
params
.
id: string
id
);
if (!
const order: Order | undefined
order
) throw new HttpError(404);
// { "status": 404, "message": "Not Found", "error": "NOT_FOUND" }
if (
const order: Order
order
.
shipped: boolean
shipped
) throw httpError(409, "ALREADY_SHIPPED", "Order already shipped");
// { "status": 409, "message": "Order already shipped", "error": "ALREADY_SHIPPED" }
return
const order: Order
order
;
},
});

The status is always yours to choose. httpError(status, error, message) is the usual way to throw. With new HttpError(status, body), the second argument decides the body:

body The response body
none the envelope for the status
a string the envelope, with the string as its message
anything else the value itself, as JSON

Anything thrown that is not an HttpError, such as a TypeError or a driver’s error, answers 500 with the envelope and nothing of the error itself, and goes to reportError.

Every error the framework produces, and every HttpError without a body of its own, has one shape:

{ "status": 404, "message": "Not Found", "error": "NOT_FOUND" }
  • status repeats the HTTP status, for a client that kept only the payload.
  • error is the machine-readable code in UPPER_SNAKE_CASE. Branch on it.
  • message is for people and may change. Never match on it.

By default the message is the status’s reason phrase, and the code is that phrase in upper snake case: Unprocessable Content is UNPROCESSABLE_CONTENT.

A validation failure adds issues, one per rejected value, with a path that starts at the request part; see Validation errors.

errorBody(status, error, message) builds the same envelope as a plain object, to add fields of your own or to answer with a Response of your own:

const closed = hook.beforeParse(() => {
if (
const maintenance: {
on: boolean;
until: string;
}
maintenance
.
on: boolean
on
) {
throw new HttpError(503, { ...errorBody(503, "MAINTENANCE"),
until: string
until
:
const maintenance: {
on: boolean;
until: string;
}
maintenance
.
until: string
until
});
}
});

Thrown, it still reaches the onError hooks; a returned Response does not.

The framework raises its own failures as HttpErrors in the same envelope: MALFORMED_JSON or MALFORMED_FORM for a body that does not parse, NOT_FOUND, METHOD_NOT_ALLOWED, BODY_TOO_LARGE, VALIDATION_FAILED, and INTERNAL_SERVER_ERROR for anything unexpected. Framework error codes lists every one, those of the packages included. All of them reach the application’s onError hooks, the 404 and 405 too, so one hook can decide the format of every error.

An onError hook runs when a stage throws. It sees the error as ctx.error, and answers with a Response, or returns nothing to pass the error on:

const domainErrors = hook.onError((ctx) => {
if (ctx.
error: unknown
error
instanceof OrderNotFound) {
return Response.json(errorBody(404, "ORDER_NOT_FOUND"), {
status?: number | undefined
status
: 404 });
}
});

This is how a domain error becomes a response without the domain knowing about HTTP: the service throws OrderNotFound, and the hook decides it is a 404.

onError hooks mount on a route, a group or the application, and run from the route outwards, the opposite of the other slots (see Groups and mounting). The first Response wins. When none answers, an HttpError gets its own status and body, and anything else a 500.

  • It returns a Response or nothing. Returning an object is a compile error.
  • Validated parts and fields added by hooks are optional. The error may have come before validation or before the hook that adds a field ran, so narrow before reading them.
  • A hook that throws is skipped. Its error goes to reportError with source: "onError", and the next hook, or the default mapping, answers the original error.
  • Its response goes the rest of the way out. It passes the beforeResponse hooks that have not run yet, gets ctx.out.headers, and is seen by afterResponse.
  • Only the application’s hooks see 404, 405 and preflights. No route is behind them, so a group’s onError never runs for them.

An onError hook on the application replaces the format for every failure: an HttpError you threw, a validation or body failure, an unmatched path or method, a rate limit’s refusal, and an unexpected error.

import type { ErrorBody } from "@tetsujs/core";
import { hook, HttpError, reportFailure } from "@tetsujs/core";
export const inOurFormat = hook.onError((ctx) => {
const {
const error: unknown
error
} = ctx;
if (
const error: unknown
error
instanceof HttpError) {
const {
const status: number
status
,
error: string
error
:
const code: string
code
, ...
const rest: {
message: string;
}
rest
} =
const error: HttpError
error
.
body?: unknown
body
as ErrorBody;
return Response.json({
code: string
code
, ...
const rest: {
message: string;
}
rest
}, {
status?: number | undefined
status
});
}
reportFailure(ctx, "unhandled",
const error: unknown
error
);
return Response.json(
{
code: string
code
: "INTERNAL_SERVER_ERROR",
message: string
message
: "Internal Server Error" },
{
status?: number | undefined
status
: 500 },
);
});

reportError only hears of a failure no onError hook answered. So a hook that answers unexpected errors reports them itself, with reportFailure. Without that line, a lost database connection answers in your format and never reaches the error tracker.

The generated document does not read that hook. Tell @tetsujs/openapi the same format with its errors option, and test the two against each other with assertDescribed.

Some failures have no response to become: an afterResponse observer that throws after the response has gone, a WebSocket handler, a stream whose generator breaks mid-body, an error no onError hook answered. These are the only things the framework logs, and by default they go to console.error. Pass reportError to createApp to receive them yourself:

const
const logger: pino.Logger<never, boolean>
logger
= pino();
const
const app: App<RoutesOf<object[]>>
app
= createApp({
hooks: { beforeParse: [requestId()] },
reportError: ({
source: FailureSource
source
,
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
, ctx }) =>
const logger: pino.Logger<never, boolean>
logger
.
error: pino.LogFn
<{
err: unknown;
source: FailureSource;
requestId: string | undefined;
}, "tetsu">(obj: {
err: unknown;
source: FailureSource;
requestId: string | undefined;
} & pino.LogFnFields, msg?: "tetsu" | undefined) => void (+2 overloads)
error
({
err: unknown
err
:
error: unknown

What was thrown, exactly as it was thrown: not formatted, not truncated, so a logger's redaction sees the fields it knows.

error
,
source: FailureSource
source
,
requestId: string | undefined
requestId
: ctx?.
requestId?: string | undefined
requestId
}, "tetsu"),
routes: object[]

The topology: a group, a controller, or an array of either.

routes
,
});

A report carries:

  • error: what was thrown, untouched, so the logger’s redaction still applies.
  • ctx: the request’s context, with the fields your hooks add typed as optional. Absent where there was no request: a WebSocket event, a shutdown.
  • source: what failed.
source What failed
unhandled an error no onError hook answered, and not an HttpError; the request got a 500
response a handler broke its response contract: an undeclared status, a body its schema rejects
onError an onError hook threw; the next one, or the default mapping, answered instead
errorResponse the error path kept failing, and the request got a plain 500
afterResponse an afterResponse observer threw; the response had already gone
websocket a WebSocket handler threw, a message schema threw instead of reporting issues, or an endpoint’s until function threw
stream a streamed body’s generator threw, or its onEnd did (@tetsujs/sse)
shutdown a closer threw while the process was stopping (@tetsujs/lifecycle)

An HttpError no hook answered is not reported: it is an answer, not a failure.

The receiver is called in place and never awaited, so the error path never waits on a log shipper. If it throws or its promise rejects, that error is printed to console.error with the original report.

reportFailure(ctx, source, error) sends a report the way the framework does, for code that meets a failure it cannot answer: a hook package, a background task started from a handler, or the onError hook above. source can be any string. With a context from testCtx(), which has no application behind it, the report is printed.