HTTP
Exception filters
Turn errors thrown anywhere in the request pipeline into responses of your choice with exception filters, and know what happens when no filter handles them.
On this page 6 sections
Errors thrown by middleware, guards, validation or a handler become responses. By default that
is Knead's RFC 9457 problem response (see errors). An exception filter lets you
answer specific errors differently: map a domain error to a 404, render an HTML error page,
or report errors to a tracker.
#The ExceptionFilter interface
A filter is a class or object with a catch(error, ctx) method that returns a Response, or
undefined to decline the error. The method may be async. ExceptionFilter<E> types the error
parameter; the FilterRef type covers a filter class and a filter object. Unlike middleware and
guards, a plain function is not a filter.
@Catch(...errorClasses) restricts a filter class to errors that are instanceof one of the
listed classes, so subclasses match too. A filter without @Catch(), including every filter
object, is tried for every error.
export class AccountNotFoundError extends Error {
constructor(readonly accountId: string) {
super(`Account ${accountId} does not exist`);
}
}
@Catch(AccountNotFoundError)
export class AccountNotFoundFilter implements ExceptionFilter<AccountNotFoundError> {
catch(error: AccountNotFoundError, ctx: Ctx): Response {
const problem = new NotFoundException(error.message).toProblem(requestUrl(ctx.req).pathname);
return problemResponse(problem);
}
}A NotFoundException builds the problem body here (toProblem(instance)), and problemResponse()
sends it with content-type: application/problem+json; charset=utf-8 and
x-content-type-options: nosniff. A request for an unknown account gets:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Account acc_2 does not exist",
"instance": "/accounts/acc_2"
}Filter classes are constructed through dependency injection, like middleware and guards: a
provider visible from the module is used when there is one, otherwise Knead constructs one
instance per module at listen() and runs its onModuleInit there and its onModuleDestroy at
close().
#Scopes and matching
Filters are attached at three levels:
| Scope | How |
|---|---|
| Route | The filters option of the route decorator, then @UseFilters() on the method. |
| Controller | The filters option of @Controller(), then @UseFilters() on the class. |
| Global | APP_FILTER multi-providers (in module order), then app.useGlobalFilters(...) (in call order). |
When an error reaches the filters, Knead tries them in exactly that order, route filters first, and the first filter that handles the error wins:
- A filter whose
@Catch()classes do not match the error is skipped. - A filter that returns a
Responseends the search, and that response is sent. - A filter that returns
undefineddeclines, and the next filter is tried. - When no filter handles the error, the default problem response is sent.
const statementUnavailable: ExceptionFilter = {
catch: () => new Response(null, { status: 503, headers: { 'retry-after': '30' } }),
};
@Controller('/accounts')
@UseFilters(AccountNotFoundFilter)
export class AccountsController {
@Get('/:id')
find(ctx: Ctx<{ params: { id: string } }>) {
if (ctx.params.id !== 'acc_1') throw new AccountNotFoundError(ctx.params.id);
return { id: 'acc_1', balance: 120 };
}
@Get('/:id/statement', { filters: [statementUnavailable] })
statement(ctx: Ctx<{ params: { id: string } }>): never {
throw new AccountNotFoundError(ctx.params.id);
}
}GET /accounts/acc_1/statement throws an AccountNotFoundError, but its route filter comes
first and answers 503 with Retry-After: 30, so the controller's AccountNotFoundFilter is
never consulted.
The filters of the matched route handle errors from every step of its pipeline, including global
middleware. A request that matches no route has only global filters, which see its
NotFoundException or MethodNotAllowedException. app.useGlobalFilters() throws an
HttpConfigError once listen() has been called.
#Declining an error
A filter that returns undefined lets the error continue, which suits global filters that observe
errors without answering them:
@Injectable()
export class ErrorTracker {
readonly reported: unknown[] = [];
capture(error: unknown): void {
this.reported.push(error);
}
}
export class ReportUnexpected implements ExceptionFilter {
readonly #tracker = inject(ErrorTracker);
catch(error: unknown): undefined {
if (!(error instanceof HttpException) || error.status >= 500) this.#tracker.capture(error);
return undefined;
}
}
@Module({
controllers: [AccountsController],
providers: [ErrorTracker, { provide: APP_FILTER, multi: true, useValue: ReportUnexpected }],
})
export class AppModule {}ReportUnexpected reports 5xx and unknown errors, then declines, so the client still gets the
default response (a masked 500 in production). Since filters run route first, errors that a
route or controller filter already answered never reach it.
#When a filter fails
A filter that throws, or that returns something that is neither a Response nor undefined,
does not get a second chance, and neither do the filters after it. Knead logs the problem and
sends the default problem response for the original error:
- A throwing filter is logged at error level as
Exception filter <ClassName> threw, with a correlation id, through the same redacting error log as other server errors. - A filter returning another value is logged as
Exception filter <ClassName> did not return a Response.
#The default response
When no filter handles an error, defaultErrorResponse(error, ctx, logger, development) (also
exported, for filters and transports that want the same mapping) builds it:
- An
HttpExceptionbelow 500 becomes a problem response with itstype,title,status,detail, extensions and headers. - An
HttpExceptionof 500 or above is logged under a correlation id. Outside development the body has onlytype,title,status,instanceandcorrelationId: the detail and extensions are dropped, while headers such asRetry-Afterare kept. - Any other error is logged under a correlation id and answered with a
500Internal Server Error. Only in development does the body include the error'sdetailandstack, with credentials redacted.
Development mode is KneadFactory.create(root, { development }), which defaults to
NODE_ENV === 'development' (see the server guide).
The errors guide covers the problem format and the exception classes in full.
#Testing filters
TestingModuleBuilder.overrideFilter(Class) replaces a filter class in a test module. See
testing.