Zum Inhalt springen
Deutsch

Fehlerbehandlung & Fallbacks

Drei Schichten verwandeln einen geworfenen Fehler in eine Response, von innen nach außen: ein gescoptes handleErrors, dann das serverweite withErrorHandler, dann der Framework-Default (HttpError → sein Status + { error, … }-JSON, alles andere → ein generischer 500). Eine fallback-Route beantwortet Requests, die auf gar keine Route passten.

Jeder Handler (oder jede Middleware) darf ein HttpError werfen; reject ist die Kurzform:

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);
});

Das Default-Mapping macht daraus 404 { "error": "no such user", "id": … }. Ein HttpError kann auch Response-Header tragen (z. B. WWW-Authenticate):

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

handleErrors(handler, child) fängt alles, was in child geworfen wird (Handler und innere Middleware), und sieht den originalen Fehler — vor jedem Default-Mapping. Gib eine Response zurück, um ihn zu behandeln, oder null, um abzulehnen und einen äußeren Handler (oder den Default) übernehmen zu lassen.

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, // ablehnen → eskalieren
path('api', apiRoutes),
);

Handler verschachteln von außen nach innen wie withMiddleware: das innerste handleErrors hat das erste Zugriffsrecht. Um eine Auth-Middleware gelegt, fängt es auch deren Würfe (z. B. ein 401).

fallback(handler) beantwortet jeden Request, der auf keine Route passte — jede Methode, inklusive unpassender OPTIONS/HEAD. Es ist an den Not-Found-Hook jedes Backends verdrahtet und muss daher an der Wurzel des Routenbaums stehen (ein fallback unter path() wirft zur Compile-Zeit); pro Server gibt es höchstens eines.

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 })),
);

Die letzte Instanz für Fehler, die jedem handleErrors entkommen, plus backend-interne Fehler (Fehler beim Body-Parsen). Am Server-Builder setzen:

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);

Der generische 500 aus Schritt 3 trägt { "error": "Internal Server Error" } und sonst nichts — mit Absicht. Der geworfene Text enthält regelmäßig Dateipfade, SQL-Fragmente, Connection-Strings oder einen Stack, und ein 500 ist per Definition ein Fall, für den niemand eine Meldung geschrieben hat.

Diese Redaktion funktioniert nur, weil das Detail serverseitig überlebt. Das Framework loggt jeden Wurf, der handleErrors entkommt, auf error und reicht den Fehlerwert durch, damit eine Senke, die Stacks formatiert, weiterhin einen bekommt:

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

Ein geworfenes HttpError steht nicht in diesem Log — es ist eine Response, die der Handler gewählt hat (ein 404, ein 401), und bleibt daher auf der debug-Zeile pro Request, statt falschen Alarm zu schlagen. Dasselbe gilt für einen Wurf in einer fallback-Route, der zu einem 500 gemappt und nicht erneut geworfen wird: ohne die Logzeile bliebe davon nirgends eine Spur.

Der Zusatz [x-request-id=…] erscheint, wenn der Request diesen Header trug und sein Wert eine wohlgeformte Id ist — siehe Request-Id. Lies sie mit requestIdOf(request) statt von Hand: die Funktion wendet dieselbe Formprüfung an, und genau die verhindert, dass ein client-kontrollierter String über einen eingebetteten Zeilenumbruch einen Log-Eintrag fälscht.

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);

Hast du den Header über requestId({ headerName }) umbenannt, liest die Zeile des Frameworks weiterhin den Default — logge deine Id dann aus withErrorHandler.

ReihenfolgeSchichtSieht
1innerstes → äußerstes handleErrorsden originalen Wurf; darf mit null ablehnen
2withErrorHandlerwas Schritt 1 entkam, plus backend-interne Fehler
3Framework-DefaultHttpError → Status + { error, …extra }; sonst generischer 500

Wirft ein Handler in Schritt 1 oder 2 selbst, übernimmt die nächste Schicht den neuen Fehler.