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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
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.
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.
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.
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.
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
constlogger:pino.Logger<never, boolean>
logger=pino();
const
constapp: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.
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.