Ir al contenido
Español

Error handling & fallbacks

Esta página aún no está disponible en tu idioma.

Three layers turn a thrown error into a response, innermost first: a scoped handleErrors, then the server-wide withErrorHandler, then the framework default (HttpError → its status + { error, … } JSON, anything else → a generic 500). A fallback route answers requests that matched no route at all.

Any handler (or middleware) may throw an HttpError; reject is the shorthand:

import { HttpError, Status, reject } from 'actor-ts/http';
get(async (req) => {
const user = await findUser(req.params.id);
if (!user) reject(Status.NotFound, 'no such user', { id: req.params.id });
return completeJson(Status.OK, user);
});

The default mapping turns that into 404 { "error": "no such user", "id": … }. An HttpError can also carry response headers (e.g. WWW-Authenticate):

throw new HttpError(Status.Unauthorized, 'login required', undefined, {
'www-authenticate': 'Bearer realm="api"',
});

handleErrors(handler, child) catches anything thrown inside child (handlers and inner middleware) and sees the original error — before any default mapping. Return a response to handle it, or null to decline and let an outer handler (or the default) take over.

import { complete, completeJson, handleErrors, path, Status } from 'actor-ts/http';
const routes = handleErrors(
(err) => err instanceof ValidationError
? completeJson(Status.BadRequest, { error: err.message, fields: err.fields })
: null, // decline → escalate
path('api', apiRoutes),
);

Handlers nest outside-in like withMiddleware: the innermost handleErrors gets first refusal. Placed around an auth middleware, it also catches that middleware’s throws (e.g. a 401).

fallback(handler) answers any request that matched no route — any method, including unmatched OPTIONS/HEAD. It is wired to each backend’s not-found hook, so it must sit at the root of the route tree (a fallback under path() throws at compile time), and there is at most one per server.

import { completeJson, concat, fallback, path, Status } from 'actor-ts/http';
const routes = concat(
path('api', apiRoutes),
fallback((req) => completeJson(Status.NotFound, { error: 'no such route', path: req.path })),
);

The last resort for errors that escape every handleErrors, plus backend-internal errors (body-parse failures). Set it on the server builder:

const binding = await system.http(8080)
.withErrorHandler((err, req) => {
system.log.error(`[http] ${req.method} ${req.path}`, err);
return completeJson(Status.InternalServerError, { error: 'internal error' });
})
.bind(routes);

The generic 500 at step 3 carries { "error": "Internal Server Error" } and nothing else — deliberately. The thrown text routinely holds file paths, SQL fragments, connection strings or a stack, and a 500 is by definition a case nobody wrote a message for.

That redaction only works because the detail survives on the server. The framework logs every throw that escapes handleErrors at error, and passes the error value through, so a sink that formats stacks still gets one:

[http] POST /api/orders → 500 after 12 ms [x-request-id=01J8Z…]

A thrown HttpError is not in that log — it is a response the handler chose (a 404, a 401), so it stays on the per-request debug line rather than crying wolf. The same applies to a throw inside a fallback route, which is mapped to a 500 without being re-thrown: without the log line it would leave no trace anywhere.

The [x-request-id=…] suffix appears when the request carried that header and its value is a well-formed id — see Request id. Read it yourself with requestIdOf(request) rather than by hand: it applies the same shape check, which is what keeps a client-controlled string from forging a log record through an embedded newline.

import { requestIdOf } from 'actor-ts/http';
const binding = await system.http(8080)
.withErrorHandler((err, req) => {
system.log.error(`[http] ${req.method} ${req.path} (${requestIdOf(req) ?? '-'})`, err);
return completeJson(Status.InternalServerError, { error: 'internal error' });
})
.bind(routes);

If you renamed the header via requestId({ headerName }), the framework’s own line still reads the default one — log your id from withErrorHandler.

OrderLayerSees
1innermost → outermost handleErrorsthe original thrown value; may decline with null
2withErrorHandlerwhatever escaped step 1, plus backend-internal errors
3framework defaultHttpError → status + { error, …extra }; else generic 500

If a handler at step 1 or 2 throws, the next layer takes the new error.