Exception Filters
TL;DR — Un Filter implémente
ExceptionFilter.catch(exception, host). Il intercepte les erreurs après que tout le pipeline (Guard / Interceptor / Pipe / Handler) a throw. C'est la seule couche qui doit décider du format final de la réponse d'erreur. Stratégie typique : unAllExceptionsFilterglobal qui mapsHttpException→ response standard et tout le reste → 500 envelope unifiée.
🧠 Mental model
Handler ─┐
Pipe ────┤── throw ───► Interceptor (catchError) ───► [ExceptionFilter.catch] ───► JSON response
Guard ───┤ │
Mid. ────┘ └─► log / metrics / sentryAnalogie — Le Filter c'est le standardiste qui transforme tout incident en réponse polie au client. Le code applicatif throw librement (throw new NotFoundError('user')), le Filter mappe vers HTTP propre (404 { error: 'user_not_found' }).
ArgumentsHost permet d'adapter par transport : host.switchToHttp().getResponse() vs host.switchToWs().getClient() vs host.switchToRpc().getContext().
Le modèle de résolution — qui catch quoi, dans quel ordre
C'est LE point que 9 devs sur 10 se trompent. Nest maintient, pour chaque scope, une liste ordonnée de filters. Quand une exception remonte, Nest parcourt la liste et s'arrête au premier filter dont le @Catch(...) matche l'exception (un @Catch() sans argument matche tout). Deux règles non-évidentes :
- Précédence par scope — Nest tente d'abord les filters method-scoped (
@UseFilterssur le handler), puis controller-scoped, puis global (APP_FILTER/app.useGlobalFilters). Le plus proche du handler gagne. Un@UseFiltersau niveau route remplace (n'additionne pas) le controller pour le matching — mais le global reste un fallback si aucun scope local ne matche. - Ordre des
APP_FILTER— au sein du même scope global, les providersAPP_FILTERsont essayés dans l'ordre inverse de déclaration (le dernier déclaré est tenté en premier). C'est contre-intuitif : pour un catch-all@Catch()global qui doit être le dernier recours, on le déclare en premier dans le tableauproviders, et les filters spécifiques après.
@Module({
providers: [
// Tenté en DERNIER (filet de sécurité) → déclaré en PREMIER
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_FILTER, useClass: PrismaErrorFilter },
// Tenté en PREMIER → déclaré en DERNIER
{ provide: APP_FILTER, useClass: BankingErrorFilter },
],
})
export class AppModule {}Piège classique : on met
AllExceptionsFilteren dernier dans le tableau « par habitude » → il intercepte tout en premier → les filters spécifiques ne sont jamais atteints. Si tesDomainErrorrepartent eninternal_error, c'est ça. (Voir aussi le bloc end-to-end plus bas qui montre l'ordre correct.)
| Scope | Décorateur / wiring | DI possible ? | Cas d'usage |
|---|---|---|---|
| Method | @UseFilters(Filter) sur le handler | Si instancié par Nest (classe, pas new) | Override ultra-local, rare |
| Controller | @UseFilters(Filter) sur la classe | Oui (classe) | Erreurs propres à un domaine |
Global (useGlobalFilters) | app.useGlobalFilters(new F()) | Non — instance manuelle, pas de DI | Bootstrap simple, pas de deps |
Global (APP_FILTER) | provider dans un module | Oui — DI complète | Le défaut en prod : Logger, Sentry client, config injectés |
Toujours préférer APP_FILTER à useGlobalFilters(new ...) dès que le filter a une dépendance (Logger structuré, client Sentry, ConfigService). Avec new F() tu perds la DI et tu finis par instancier des clients à la main dans le filter — anti-pattern.
🛠️ Code minimal
// 1) Built-in HttpException — déjà mappé par Nest
throw new NotFoundException('user_not_found');
throw new BadRequestException({ code: 'invalid_payload', fields: ['email'] });
throw new ConflictException();
throw new UnauthorizedException();
throw new ForbiddenException();
throw new UnprocessableEntityException();
throw new InternalServerErrorException();// 2) Exception métier custom (transport-agnostic)
export class DomainError extends Error {
constructor(public readonly code: string, message?: string, public readonly meta?: any) {
super(message ?? code);
}
}
export class UserNotFoundError extends DomainError {
constructor(id: string) { super('user_not_found', `User ${id} not found`, { id }); }
}
export class TenantMismatchError extends DomainError {
constructor() { super('tenant_mismatch'); }
}// 3) Filter global qui mappe DomainError + HttpException + fallback
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly log = new Logger('Err');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const res = ctx.getResponse<Response>();
let status = 500;
let body: any = { error: 'internal_error' };
if (exception instanceof HttpException) {
status = exception.getStatus();
const r = exception.getResponse();
body = typeof r === 'string' ? { error: r } : r;
} else if (exception instanceof DomainError) {
const map: Record<string, number> = {
user_not_found: 404,
tenant_mismatch: 403,
email_already_used: 409,
};
status = map[exception.code] ?? 400;
body = { error: exception.code, message: exception.message, meta: exception.meta };
} else {
// log full stack uniquement pour les inconnus
this.log.error(exception instanceof Error ? exception.stack : String(exception));
}
res.status(status).json({
...body,
requestId: req.id,
timestamp: new Date().toISOString(),
path: req.originalUrl,
});
}
}
// Wiring
@Module({ providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }] })
export class AppModule {}// 4) Filter scoped (route ou controller)
@Catch(UserNotFoundError)
export class UserNotFoundFilter implements ExceptionFilter {
catch(e: UserNotFoundError, host: ArgumentsHost) {
host.switchToHttp().getResponse().status(404).json({ error: e.code });
}
}
@Controller('users')
@UseFilters(UserNotFoundFilter) // controller-scope
export class UsersController {
@Get(':id') @UseFilters(UserNotFoundFilter) // route-scope (override)
get(@Param('id') id: string) { return this.users.findOrThrow(id); }
}⚠️ Express vs Fastify —
res.status(s).json(o)est l'API Express. Sous Fastify c'estres.status(s).send(o). Tant que tu restes mono-adapter,res.json()suffit. Mais un filter global réutilisable (lib interne, multi-app) doit rester agnostique viaHttpAdapterHost:
import { Catch, ArgumentsHost, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class PortableAllExceptionsFilter implements ExceptionFilter {
private readonly log = new Logger('Err');
// L'adapter est injecté par DI → fonctionne en Express comme en Fastify
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.httpAdapterHost; // résolu au runtime, pas au boot
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
const body = { error: status === 500 ? 'internal_error' : 'request_error', timestamp: new Date().toISOString() };
// setHeader/reply/end abstraits → pas de couplage Express/Fastify
httpAdapter.reply(ctx.getResponse(), { ...body, path: httpAdapter.getRequestUrl(req) }, status);
}
}HttpAdapterHost est résolu lazy (au runtime du catch, pas au constructeur) car l'adapter n'existe pas encore au moment où le filter global est instancié pendant le bootstrap.
🎯 Patterns courants
- Error envelope unifiée — toujours
{ error: 'snake_case_code', message, meta, requestId, timestamp, path }. Le client switch surerror, pas surmessage(i18n). - Domain → HTTP mapping centralisé — table
code → statusdans un seul endroit. Pas dethrow new BadRequestExceptiondans le service (couple HTTP et domain). - Multiple
@Catch()classes —@Catch(UserNotFoundError, ProductNotFoundError)accepte une liste. - Global filter + per-controller override —
@UseFilters()au niveau controller/route remplace le global pour ce scope. - WS / RPC filters —
BaseWsExceptionFilter,BaseRpcExceptionFilterpour ne pas casser le protocole non-HTTP. - Sentry / observability — dans le filter pour le path "inconnu" (non
HttpException), envoyer à Sentry avecrequestIdet user context. Ne pas envoyer les 4xx attendus (bruit). - RFC 9457 Problem Details — format standardisé
{ type, title, status, detail, instance }. Adopté par certaines APIs publiques pour des erreurs lisibles par humains et machines. - i18n des messages — le filter peut traduire
error.codevia i18next/nestjs-i18n selonAccept-Language. Garder le code stable, traduiremessage. - Fallback HTML pour les routes server-rendered — si le client
Accept: text/html, rendre une page d'erreur ; sinon JSON. Détection viareq.headers.accept. - Filter chain — appliquer un filter spécifique
@Catch(ZodError)AVANT un@Catch()global. Nest essaie les filters dans l'ordre de spécificité.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
| Version | Notes |
|---|---|
| Nest 7 | HttpException accepte string | object en arg. |
| Nest 8 | HttpException peut wrapper une cause (Error nativement). |
| Nest 9 | HttpException constructor signature stabilisée. Filter peut être request-scoped. |
| Nest 10 | BaseExceptionFilter.catch reçoit host: ArgumentsHost (avant : host: any). |
| Nest 11 | Support natif de error.cause ES2022 lors de l'auto-mapping. Améliorations sur le filter pour HttpAdapterHost (utile Fastify). |
HttpException.cause (Nest 8+) : throw new InternalServerErrorException('oops', { cause: dbError }) — préserve la stack pour le logger sans la leak au client.
⚠️ Pitfalls
- Throw d'
Errorbrut en@Catch()typé —@Catch(HttpException)ne catch pas lesError. Avoir un@Catch()(sans args) global comme safety net. - Leak d'info en 500 — renvoyer
exception.stackouerror.messagebrut au client peut leak des paths internes, des noms de tables, des secrets. Toujours mapper. - Double réponse — un Interceptor qui catch puis re-throw, et un Filter qui répond ⇒ ok. Mais un Interceptor qui répond via
res.json()directement + Filter ⇒ERR_HTTP_HEADERS_SENT. - Filter qui throw — un filter qui throw lui-même ⇒ Nest balance un 500 brut. Toujours
try/catchà l'intérieur ducatch(). getResponse()Fastify vs Express —res.status(s).json(o)est Express. En Fastify :res.status(s).send(o). UtiliserHttpAdapterHostpour rester agnostique.- Validation errors illisibles — par défaut
ValidationPiperenvoie{ message: ['x must be ...'], error: 'Bad Request', statusCode: 400 }. Customiser viaexceptionFactorypour produire un format{ error: 'validation_failed', fields: { email: ['must_be_email'] } }. - 404 sur route inexistante — pas un
HttpExceptionau sens applicatif, mais Nest balanceNotFoundException. Ok. Mais si tu as un proxy ou un static handler avant Nest, le 404 peut venir d'avant et bypass le filter. - Performance — un filter qui sérialise/log chaque erreur en JSON-stringifying des gros payloads ⇒ pression GC. Limiter le log à un sous-set de champs.
- Conditional sensitive fields — en
NODE_ENV=development, on peut leak la stack pour debug. En prod, JAMAIS. Penser à une flagprocess.env.NODE_ENV !== 'production'. - Filter qui exécute du code lourd —
@Catch()est sur le chemin chaud d'erreur. Pas d'appel DB synchrone, pas de cryptographie. Mettre en file si lourd (Sentry batch). - Erreur dans
onApplicationBootstrap— pas catché par le Filter HTTP (l'app n'a pas encore démarré). Logger explicitement dansmain.tsavec un try/catch top-level. instanceofqui ment (le piège le plus vicieux) — un@Catch(DomainError)repose surinstanceof DomainError. Si deux copies du même package coexistent dansnode_modules(monorepo mal hoisté, lib publiée + version locale liée,npm link), tu as deux classesDomainErrordistinctes : l'erreur throw par le service estinstanceofla copie A, ton filter teste la copie B → le@Catchne matche jamais et tout repart en500. Pire avectarget: es5qui casse la chaîne prototypeextends Error. Symptôme : « mon filter domain marche en local mais pas en CI/prod ». Diagnostic : logexception.constructor.nameetObject.getPrototypeOf; mitigation :target≥es2017, une seule source de vérité pour les erreurs (package partagé hoisté), et pour les cas extrêmes un fallback discriminant sur une propriété ('code' in exception) plutôt que purement nominal.@Catch()ne voit pas les erreurs des Interceptors après le handler — si un Interceptor mappe l'Observableet renvoie une valeur, ou catch+swallow l'erreur, le Filter n'est jamais invoqué. L'ordre de sortie est l'inverse de l'entrée : l'Interceptor enveloppe le Filter. Une erreur émise dans le pipe RxJS après le handler remonte bien au Filter, mais une erreur avalée par uncatchError(() => of(fallback))ne le fera jamais — c'est voulu, mais c'est une source de confusion.
🧪 Testing
// Unitaire — mock ArgumentsHost
const mockResponse = () => {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
it('mappe UserNotFoundError → 404', () => {
const res = mockResponse();
const host: any = {
switchToHttp: () => ({
getRequest: () => ({ id: 'r1', originalUrl: '/users/42' }),
getResponse: () => res,
}),
};
new AllExceptionsFilter().catch(new UserNotFoundError('42'), host);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'user_not_found' }));
});// e2e — provoque l'erreur via une route
await request(app.getHttpServer())
.get('/users/00000000-0000-0000-0000-000000000000')
.expect(404)
.expect(({ body }) => {
expect(body.error).toBe('user_not_found');
expect(body.requestId).toBeDefined();
});Pour les filters complexes (multi-transport), tester séparément le path HTTP, WS, RPC en injectant le bon host.
// Vérifier qu'une exception inconnue ne leak pas
it('5xx ne leak ni stack ni détails internes', () => {
const res = mockResponse();
const host: any = { switchToHttp: () => ({ getRequest: () => ({ id: 'r' }), getResponse: () => res }) };
new AllExceptionsFilter().catch(new Error('DB password: secret123'), host);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'internal_error' }));
const body = res.json.mock.calls[0][0];
expect(JSON.stringify(body)).not.toContain('secret123');
});🎬 Cas d'usage concrets
Scénario 1 — Banque : mapping d'erreurs domain → HTTP
Qui — Une banque privée FR (180 ETP IT) qui expose des APIs internes à ses applications front et mobiles. ≈ 65 endpoints, équipe back composée de 4 squads.
Problème métier — Sans filter centralisé, chaque service throw des BadRequestException, ForbiddenException, etc. directement — ce qui couple HTTP au domain. Pire, les codes d'erreur varient ("insufficient_funds" ici, "InsufficientBalance" là). Le mobile a 80 cas d'erreur différents à gérer, dont la moitié inattendus.
Comment ce concept aide — Exceptions domain pures (InsufficientFundsError, AccountFrozenError, DailyLimitExceededError), et un filter global qui mappe chaque type vers (status, code, message). Le service ne sait rien de HTTP, le filter centralise. Format unifié { error: 'snake_case', requestId, timestamp }.
export class InsufficientFundsError extends DomainError {
constructor(public requested: number, public available: number) {
super('insufficient_funds', `Requested ${requested}, available ${available}`);
}
}
export class AccountFrozenError extends DomainError {
constructor(public reason: string) {
super('account_frozen', `Account frozen: ${reason}`);
}
}
@Catch()
export class BankingErrorFilter implements ExceptionFilter {
private readonly map: Record<string, number> = {
insufficient_funds: 422,
account_frozen: 423,
daily_limit_exceeded: 429,
kyc_pending: 403,
iban_invalid: 400,
};
catch(exception: unknown, host: ArgumentsHost) {
if (exception instanceof DomainError) {
const status = this.map[exception.code] ?? 500;
host.switchToHttp().getResponse().status(status).json({
error: exception.code,
message: exception.message,
requestId: host.switchToHttp().getRequest().correlationId,
});
}
// fallback...
}
}Gains chiffrés — Codes d'erreur côté mobile passés de 80 disparates à 18 normalisés, MTTR sur incidents de paiement divisé par 3 (filter log structuré envoyé à Sentry), audit ACPR validé (mapping documenté, aucun leak de stack).
Scénario 2 — SIRH : erreurs fiches de paye
Qui — Un SIRH FR (50 ETP, 800 clients PME). Génération de fiches de paye en batch (mensuel) avec règles complexes (URSSAF, prévoyance, RTT).
Problème métier — Les erreurs de paye sont variées : variable de paye manquante, taux URSSAF obsolète, IBAN salarié invalide, montant négatif. Sans filter dédié, chaque erreur remontait en 500 générique, le RH ne savait pas quoi corriger.
Comment ce concept aide — Hiérarchie d'exceptions métier (PayrollError parent, MissingPayrollVariableError, UrssafRateOutdatedError, etc.). Un filter dédié @Catch(PayrollError) produit un format { error, salaryId, missingFields[], suggestion }. Couplé avec un AllExceptionsFilter global en fallback.
export abstract class PayrollError extends Error {
abstract code: string;
abstract status: number;
constructor(message: string, public meta?: Record<string, any>) { super(message); }
}
export class MissingPayrollVariableError extends PayrollError {
code = 'missing_payroll_variable';
status = 422;
constructor(public salaryId: string, public missing: string[]) {
super(`Missing variables for salary ${salaryId}`, { salaryId, missing });
}
}
@Catch(PayrollError)
export class PayrollErrorFilter implements ExceptionFilter {
catch(exc: PayrollError, host: ArgumentsHost) {
host.switchToHttp().getResponse().status(exc.status).json({
error: exc.code,
message: exc.message,
...exc.meta,
suggestion: this.getSuggestion(exc.code),
});
}
private getSuggestion(code: string): string | null {
const tips: Record<string, string> = {
missing_payroll_variable: 'Complete missing variables in employee profile',
urssaf_rate_outdated: 'Sync URSSAF rates from /admin/rates',
};
return tips[code] ?? null;
}
}Gains chiffrés — Tickets support sur erreurs de paye divisés par 5 (le client RH a le code + la suggestion d'action), batch de paye qui terminent en succès en première passe passés de 76% à 96%.
Scénario 3 — Industrie : erreurs workflow de production
Qui — Une ETI industrielle FR (450 personnes) qui édite un MES (Manufacturing Execution System) pour ses 7 sites. Workflows de production : ordres de fabrication, recettes, conformités qualité.
Problème métier — Erreurs workflow critiques : recette non validée, capteur hors tolérance, lot bloqué qualité. Bloquer la chaîne sans message clair = perte de production (~25k€/h sur certains sites). Les opérateurs en bord de ligne ont besoin d'un message en français, actionnable.
Comment ce concept aide — WorkflowError hiérarchie, filter dédié qui produit un format orienté opérateur : { severity, action, contact, traceUrl }. Le filter log aussi vers Datadog avec corrélation OPC-UA pour debug technique. Séparation message UX vs trace technique.
export abstract class WorkflowError extends Error {
abstract code: string;
abstract status: number;
abstract severity: 'info' | 'warning' | 'critical';
constructor(message: string, public meta?: Record<string, any>) {
super(message);
this.name = this.constructor.name;
}
}
export class RecipeNotValidatedError extends WorkflowError {
code = 'recipe_not_validated';
severity = 'critical' as const;
status = 409;
constructor(public recipeId: string, public missingApproval: string) {
super(`Recipe ${recipeId} not validated (missing: ${missingApproval})`);
}
}
@Catch(WorkflowError)
export class WorkflowErrorFilter implements ExceptionFilter {
constructor(private readonly logger: Logger, private readonly tracing: TracingService) {}
catch(exc: WorkflowError, host: ArgumentsHost) {
const req = host.switchToHttp().getRequest();
const traceId = this.tracing.currentTraceId();
this.logger.error({
code: exc.code, severity: exc.severity,
siteId: req.siteId, operatorId: req.user?.id,
traceId, opcuaCorrelation: req.opcuaCorrelation,
});
host.switchToHttp().getResponse().status(exc.status).json({
error: exc.code,
severity: exc.severity,
messageOperator: this.translateForOperator(exc),
action: this.suggestedAction(exc.code),
supportContact: this.getContactForSite(req.siteId),
traceUrl: `https://datadog.fr/trace/${traceId}`,
});
}
}Gains chiffrés — Temps de résolution sur incident chaîne passé de 22 min à 4 min en moyenne (l'opérateur a l'action directe), perte de production sur erreurs workflow divisée par 6.
🛠️ Exemple end-to-end
Use case — On bâtit la stack d'exception filters pour une banque privée. Hiérarchie d'erreurs domain, filter dédié BankingErrorFilter qui mappe les codes domain vers HTTP, filter PrismaExceptionFilter pour les erreurs DB, fallback AllExceptionsFilter qui sécurise les 500 (pas de leak de stack ni de secrets), envoi Sentry pour les 5xx uniquement.
// src/banking/errors.ts
export abstract class DomainError extends Error {
abstract readonly code: string;
constructor(message: string, public readonly meta?: Record<string, any>) {
super(message);
this.name = this.constructor.name;
}
}
export class InsufficientFundsError extends DomainError {
readonly code = 'insufficient_funds';
constructor(public requested: number, public available: number, public currency: string) {
super(`Requested ${requested} ${currency}, available ${available} ${currency}`, { requested, available, currency });
}
}
export class AccountFrozenError extends DomainError {
readonly code = 'account_frozen';
constructor(public accountId: string, public reason: string) {
super(`Account ${accountId} frozen: ${reason}`, { accountId, reason });
}
}
export class DailyLimitExceededError extends DomainError {
readonly code = 'daily_limit_exceeded';
constructor(public attempted: number, public limit: number) {
super(`Daily limit ${limit} exceeded with ${attempted}`, { attempted, limit });
}
}
export class IbanInvalidError extends DomainError {
readonly code = 'iban_invalid';
constructor(public iban: string, public reason: string) {
super(`IBAN ${iban} invalid: ${reason}`, { iban, reason });
}
}
export class KycPendingError extends DomainError {
readonly code = 'kyc_pending';
constructor(public userId: string) {
super(`KYC pending for user ${userId}`, { userId });
}
}Les exceptions sont transport-agnostic — pas de référence à HTTP. Elles vivent dans le domain.
// src/filters/banking-error.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Response } from 'express';
import { DomainError } from '../banking/errors';
@Catch(DomainError)
export class BankingErrorFilter implements ExceptionFilter {
private readonly log = new Logger('BankingErrorFilter');
private readonly statusMap: Record<string, number> = {
insufficient_funds: 422,
account_frozen: 423,
daily_limit_exceeded: 429,
iban_invalid: 400,
kyc_pending: 403,
};
catch(exception: DomainError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const req = host.switchToHttp().getRequest();
const status = this.statusMap[exception.code] ?? 400;
this.log.warn({
msg: 'domain_error',
code: exception.code,
meta: exception.meta,
userId: req.user?.id,
correlationId: req.correlationId,
});
res.status(status).json({
error: exception.code,
message: exception.message,
meta: exception.meta,
requestId: req.correlationId,
timestamp: new Date().toISOString(),
path: req.originalUrl,
});
}
}// src/filters/prisma-error.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Response } from 'express';
import { Prisma } from '@prisma/client';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaErrorFilter implements ExceptionFilter {
private readonly log = new Logger('PrismaErrorFilter');
private readonly map: Record<string, { status: number; error: string }> = {
P2002: { status: 409, error: 'unique_constraint_violation' },
P2003: { status: 400, error: 'foreign_key_violation' },
P2025: { status: 404, error: 'record_not_found' },
P2034: { status: 409, error: 'transaction_conflict' },
};
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const req = host.switchToHttp().getRequest();
const mapped = this.map[exception.code] ?? { status: 500, error: 'database_error' };
this.log.error({
msg: 'prisma_error',
code: exception.code,
target: (exception.meta as any)?.target,
correlationId: req.correlationId,
});
res.status(mapped.status).json({
error: mapped.error,
prismaCode: exception.code,
meta: exception.meta,
requestId: req.correlationId,
timestamp: new Date().toISOString(),
});
}
}// src/filters/all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { Response } from 'express';
import { ErrorReporter } from '../observability/error-reporter';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly log = new Logger('AllExceptionsFilter');
// ErrorReporter injecté → testable, échantillonné, pas de Sentry en dur
constructor(private readonly reporter: ErrorReporter) {}
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const req = host.switchToHttp().getRequest();
let status = 500;
let body: any = { error: 'internal_error', message: 'An unexpected error occurred' };
if (exception instanceof HttpException) {
status = exception.getStatus();
const r = exception.getResponse();
body = typeof r === 'string' ? { error: r } : r;
} else {
// Erreur inconnue : log full + Sentry, mais ne LEAK rien au client
const errorAsObj = exception instanceof Error
? { name: exception.name, message: exception.message, stack: exception.stack }
: { value: String(exception) };
this.log.error({
msg: 'unhandled_exception',
...errorAsObj,
userId: req.user?.id,
correlationId: req.correlationId,
path: req.originalUrl,
});
this.reporter.report(exception, {
correlationId: req.correlationId,
userId: req.user?.id,
path: req.originalUrl,
});
}
const finalBody = {
...body,
requestId: req.correlationId,
timestamp: new Date().toISOString(),
path: req.originalUrl,
};
// En prod, JAMAIS de stack
if (process.env.NODE_ENV !== 'production' && exception instanceof Error && status === 500) {
(finalBody as any)._debugMessage = exception.message;
}
res.status(status).json(finalBody);
}
}AllExceptionsFilter est la dernière ligne de défense : il log tout, envoie à Sentry les 500, mais ne renvoie au client qu'un message générique. La stack est volontairement absente en prod pour éviter les leaks (paths, noms de tables, secrets).
Note staff — Sentry.captureException global vs DI. L'appel à
Sentry.captureException(...)ci-dessus passe par le client Sentry global (singleton initialisé dansinstrument.tsavantbootstrap). C'est acceptable car@sentry/nodeest conçu pour un client process-wide. Mais n'instancie jamais un client externe dans un champ du filter (private sentry = new SomeClient()) : tu perds la config injectée, le mock en test, et tu crées un client par instance request-scoped. La règle générale : tout ce qui a une dépendance (DSN,ConfigService, sampling, release tag) s'injecte par le constructeur. Pour un wrapper testable, injecte unErrorReporter:
// src/observability/error-reporter.ts
import { Injectable } from '@nestjs/common';
import * as Sentry from '@sentry/node';
@Injectable()
export class ErrorReporter {
// Échantillonnage côté app pour les 5xx récurrents (ex: une panne DB = 10k erreurs/min)
private readonly seen = new Map<string, number>();
report(exception: unknown, context: Record<string, unknown>) {
const key = exception instanceof Error ? `${exception.name}:${exception.message.slice(0, 80)}` : 'unknown';
const count = (this.seen.get(key) ?? 0) + 1;
this.seen.set(key, count);
// 1, 2, ... 10 puis 1/100 → évite de noyer Sentry et la facture pendant un incident
if (count <= 10 || count % 100 === 0) {
Sentry.captureException(exception, { extra: { ...context, occurrenceCount: count } });
}
}
}Le filter dépend alors de ErrorReporter (mockable en test, pas de Sentry en dur), et tu évites le piège classique : une seule panne DB qui génère 50 000 events Sentry et fait exploser ton quota au pire moment.
// src/transfers/transfers.service.ts
import { Injectable } from '@nestjs/common';
import {
InsufficientFundsError, AccountFrozenError,
DailyLimitExceededError, IbanInvalidError,
} from '../banking/errors';
import { AccountsRepository } from '../accounts/accounts.repository';
import { TransfersRepository } from './transfers.repository';
import { IbanValidator } from '../banking/iban.validator';
@Injectable()
export class TransfersService {
constructor(
private readonly accounts: AccountsRepository,
private readonly repo: TransfersRepository,
private readonly ibanValidator: IbanValidator,
) {}
async transfer(input: { fromAccountId: string; toIban: string; amount: number; currency: string }) {
const validIban = this.ibanValidator.validate(input.toIban);
if (!validIban.ok) throw new IbanInvalidError(input.toIban, validIban.reason);
const account = await this.accounts.findById(input.fromAccountId);
if (account.status === 'frozen') {
throw new AccountFrozenError(account.id, account.frozenReason!);
}
if (account.balance < input.amount) {
throw new InsufficientFundsError(input.amount, account.balance, account.currency);
}
const todayTotal = await this.repo.sumTodayForAccount(account.id);
const dailyLimit = account.dailyLimit ?? 10_000;
if (todayTotal + input.amount > dailyLimit) {
throw new DailyLimitExceededError(todayTotal + input.amount, dailyLimit);
}
return this.repo.create({ ...input, status: 'pending' });
}
}Le service ne sait rien d'HTTP. Il throw des erreurs domain typées. Les filters s'occupent du mapping.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { BankingErrorFilter } from './filters/banking-error.filter';
import { PrismaErrorFilter } from './filters/prisma-error.filter';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
@Module({
providers: [
ErrorReporter,
// ⚠️ APP_FILTER : résolution en ORDRE INVERSE de déclaration.
// Le DERNIER déclaré est tenté EN PREMIER. Donc :
// - le catch-all final (AllExceptionsFilter) en PREMIER dans le tableau,
// - les filters spécifiques APRÈS (tentés avant le catch-all).
{ provide: APP_FILTER, useClass: AllExceptionsFilter }, // filet de sécurité (tenté en dernier)
{ provide: APP_FILTER, useClass: PrismaErrorFilter },
{ provide: APP_FILTER, useClass: BankingErrorFilter }, // tenté en premier
],
})
export class AppModule {}Cet exemple illustre les patterns clés : (a) exceptions domain transport-agnostic, (b) filter dédié par type d'erreur métier, (c) filter dédié par technologie (Prisma), (d) fallback global qui capture tout avec log + Sentry mais SANS leak de stack, (e) format unifié { error, requestId, timestamp }, (f) séparation entre ce qu'on log (riche, structuré) et ce qu'on renvoie au client (minimal, safe). Cette architecture est la base d'une API bancaire conforme aux exigences ACPR et aux bonnes pratiques OWASP.
🔁 Quand utiliser / éviter
Utiliser un Filter :
- Format unifié d'erreur (envelope, code, requestId).
- Mapping domain error → HTTP status.
- Sentry / observability dans le path 500.
- Multi-transport (HTTP + WS + RPC) avec format cohérent.
Éviter un Filter, préférer une exception métier + service :
- La logique de "quand throw" reste dans le service. Le Filter ne fait que traduire.
Éviter un Filter, préférer un Interceptor :
- Si tu veux observer l'erreur (logger, metrics) sans modifier la réponse.
tap({ error })dans un Interceptor + Filter standard.
Éviter un Filter global trop intelligent :
- Si le filter contient une logique métier (genre "si erreur Stripe X, alors envoie un email"), c'est probablement du métier déguisé — sortir ça en handler/service dédié.
🤖 Servir un agent LLM : les erreurs après le premier byte
C'est le cas où les Exception Filters montrent leurs limites structurelles, et un staff doit savoir pourquoi. Quand tu sers des tokens Claude en streaming SSE depuis NestJS, ta réponse part avec 200 OK et Content-Type: text/event-stream dès le premier token. Si l'appel à Anthropic échoue au token 400 sur 500 (rate limit, overloaded_error, timeout réseau, client déconnecté), tu ne peux plus changer le status : les headers sont déjà envoyés. Un Exception Filter qui fait res.status(500).json(...) produira ERR_HTTP_HEADERS_SENT — le pitfall n°3, version production.
Mental model — le Filter répond aux erreurs avant le premier byte (résolution du DTO, auth, validation, choix du modèle). Une fois le stream ouvert, la gestion d'erreur bascule dans le handler/service : on émet un event d'erreur applicatif dans le flux SSE, puis on ferme proprement.
// llm.service.ts — client Anthropic injecté par DI (forRootAsync), JAMAIS `new Anthropic()` dans un champ
import { Inject, Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC_CLIENT } from './anthropic.tokens';
@Injectable()
export class LlmService {
// Le SDK gère ses propres retries (429/5xx/overloaded) avec backoff — ne pas réinventer
constructor(@Inject(ANTHROPIC_CLIENT) private readonly anthropic: Anthropic) {}
async *stream(prompt: string, signal: AbortSignal) {
const s = await this.anthropic.messages.stream(
{ model: 'claude-sonnet-4-6', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] },
{ signal }, // ← AbortController propagé : annule l'appel upstream si le client part
);
for await (const event of s) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield event.delta.text;
}
}
}
}// chat.controller.ts — SSE manuel pour garder le contrôle de la fermeture sur erreur
import { Controller, Post, Body, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
@Controller('chat')
export class ChatController {
constructor(private readonly llm: LlmService) {}
@Post('stream')
async stream(@Body() dto: ChatDto, @Req() req: Request, @Res() res: Response) {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
res.flushHeaders(); // ← à partir d'ici, l'Exception Filter ne peut plus changer le status
const ac = new AbortController();
req.on('close', () => ac.abort()); // client déconnecté → on annule l'appel Anthropic (coût économisé)
try {
for await (const token of this.llm.stream(dto.prompt, ac.signal)) {
res.write(`event: token\ndata: ${JSON.stringify({ text: token })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
} catch (err) {
// PAS de res.status() ici — on émet une erreur DANS le stream, le client la rend dans l'UI
const safe = err instanceof Anthropic.APIError
? { code: err.status === 429 ? 'rate_limited' : 'llm_error', retryable: err.status >= 500 || err.status === 429 }
: { code: 'internal_error', retryable: false };
res.write(`event: error\ndata: ${JSON.stringify(safe)}\n\n`);
} finally {
res.end();
}
}
}Ce que le Filter garde / cède :
| Phase | Qui gère l'erreur | Format |
|---|---|---|
Avant flushHeaders() (auth, validation, rate-limit edge, cost-guard) | Exception Filter | 4xx/5xx JSON normal { error, requestId } |
Après flushHeaders() (stream ouvert) | Handler (try/catch + event: error) | SSE event: error puis res.end() |
| Job BullMQ (génération asynchrone) | Worker : retry cost-aware, idempotence | persisté, pas de réponse HTTP |
Cost-guard & rate-limit à l'edge (avant le stream) — c'est là que le Filter brille. Un @Catch(CostBudgetExceededError) ou @Catch(RateLimitError) mappe vers 429 avant d'ouvrir le stream, avec Retry-After. Idée clé : la décision « refuser » se prend avant le premier byte précisément pour que le Filter puisse encore répondre proprement.
export class CostBudgetExceededError extends DomainError {
readonly code = 'cost_budget_exceeded';
constructor(public tenantId: string, public spentUsd: number, public capUsd: number) {
super(`Tenant ${tenantId} over budget`, { spentUsd, capUsd });
}
}
// → mappé en 429 dans le filter domain, avec header Retry-After.Jobs BullMQ AI (génération longue, asynchrone) — quand la génération est un job (rapport, batch d'embeddings), il n'y a pas de réponse HTTP à formater : l'« exception filter » du monde job, c'est le handler failed du worker. Règles staff :
- Idempotence keyée sur un
generationId: un retry ne doit pas re-facturer un appel déjà complété. Persistestatus: 'completed'+ l'output avant tout, et court-circuite si déjà fait. - Retry cost-aware : ne jamais retry les
4xxnon-retryables (400 invalid_request,401) — c'est de l'argent jeté. Retry seulement429/5xx/overloaded_erroravec backoff exponentiel. - Partial-output handling : si le stream casse au token 400/500, persiste le partiel + un flag
truncated: trueplutôt que de jeter 400 tokens déjà payés.
// @nestjs/bullmq (l'API actuelle) : Processor + WorkerHost.process — PAS @Process() (legacy @nestjs/bull)
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, UnrecoverableError } from 'bullmq';
@Processor('ai-generation')
export class GenerationWorker extends WorkerHost {
constructor(private readonly store: GenerationStore, private readonly llm: LlmService) {
super();
}
async process(job: Job<{ generationId: string; prompt: string }>) {
const existing = await this.store.find(job.data.generationId);
if (existing?.status === 'completed') return existing; // idempotence : pas de double facturation
try {
const out = await this.llm.complete(job.data.prompt);
return this.store.save(job.data.generationId, { status: 'completed', out });
} catch (err) {
if (err instanceof Anthropic.APIError && err.status < 500 && err.status !== 429) {
// 4xx non-retryable → on marque failed définitivement, pas de retry (coût)
await this.store.save(job.data.generationId, { status: 'failed', reason: err.message });
throw new UnrecoverableError(err.message); // BullMQ : stoppe les retries, ne consomme pas d'attempt
}
throw err; // 429/5xx → laisse BullMQ retry avec backoff exponentiel (attempts + backoff dans la config de la queue)
}
}
}Anthropic : modèles phares
claude-opus-4-8(raisonnement profond),claude-sonnet-4-6(équilibre/défaut),claude-haiku-4-5(latence/coût). Le SDK@anthropic-ai/sdkfait du retry automatique (429/5xx/overloaded_error) avec backoff — ne le double pas dans ton Filter.
🧰 Exemples avancés
Filter Prisma-aware
import { Prisma } from '@prisma/client';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const map: Record<string, { status: number; error: string }> = {
P2002: { status: 409, error: 'unique_constraint_violation' },
P2003: { status: 400, error: 'foreign_key_violation' },
P2025: { status: 404, error: 'record_not_found' },
P2034: { status: 409, error: 'transaction_conflict' },
};
const m = map[exception.code] ?? { status: 500, error: 'database_error' };
res.status(m.status).json({
error: m.error,
meta: exception.meta,
code: exception.code,
});
}
}Filter RFC 9457 Problem Details
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const req = host.switchToHttp().getRequest();
let status = 500;
let title = 'Internal Server Error';
let detail = 'An unexpected error occurred.';
let type = 'about:blank';
if (exception instanceof HttpException) {
status = exception.getStatus();
title = exception.message;
const r = exception.getResponse();
if (typeof r === 'object') {
detail = (r as any).detail ?? (r as any).message ?? title;
type = (r as any).type ?? type;
}
}
res.status(status).type('application/problem+json').send({
type, title, status, detail,
instance: req.originalUrl,
requestId: req.id,
});
}
}Hiérarchie filters scopée
// Plus spécifique en premier
@Controller('orders')
@UseFilters(PrismaExceptionFilter) // s'occupe des erreurs DB
export class OrdersController {
@Post() @UseFilters(StripeExceptionFilter) // route-level : erreurs Stripe en plus
charge(@Body() dto: ChargeDto) { /* ... */ }
}
// AllExceptionsFilter en global comme fallback🏋️ Exercices
Progression : implémenter → durcir en prod → casser puis réparer. Fais-les dans l'ordre.
1. Le filet de sécurité qui ne leak pas
Objectif — Écrire un AllExceptionsFilter global qui mappe HttpException correctement et transforme tout le reste en 500 { error: 'internal_error', requestId, timestamp, path }, sans jamais leaker message/stack.
Indice/Solution — @Catch() sans argument, branche sur exception instanceof HttpException. Pour le path inconnu : log la stack côté serveur, renvoie un corps générique. Test : catch(new Error('DB password: secret')) et assert que JSON.stringify(body) ne contient pas secret.
2. Domain → HTTP centralisé, zéro HTTP dans le service
Objectif — Hiérarchie DomainError + un @Catch(DomainError) avec table code → status. Le service throw InsufficientFundsError sans importer quoi que ce soit de @nestjs/common. Vérifie par un test e2e que GET /transfer impossible renvoie 422 { error: 'insufficient_funds' }.
Indice/Solution — grep ton dossier services/ : il ne doit y avoir aucun import de *Exception de Nest. Le mapping vit dans le filter uniquement.
3. Production-grade : ordre des filters + observabilité échantillonnée
Objectif — Ajouter PrismaErrorFilter + AllExceptionsFilter + BankingErrorFilter via APP_FILTER dans le BON ordre (catch-all déclaré en premier), injecter un ErrorReporter qui échantillonne les 5xx, et n'envoyer à l'observabilité QUE les 500 (pas les 4xx attendus).
Indice/Solution — Souviens-toi de la résolution inverse des APP_FILTER. Écris un test qui throw une DomainError connue et assert que le reporter n'est jamais appelé (4xx = bruit). Throw une Error brute → reporter appelé une fois.
4. Casser : ERR_HTTP_HEADERS_SENT en streaming
Objectif — Reproduire le bug : un endpoint SSE qui flushHeaders() puis, sur erreur upstream, tente res.status(500).json(...) dans un Filter. Observer le crash. Puis réparer en émettant event: error dans le flux + res.end(), et en court-circuitant le Filter une fois le stream ouvert.
Indice/Solution — Force l'erreur (mock le LLM qui throw au 3ᵉ token). Le fix : try/catch dans le handler, jamais de res.status() après flushHeaders(). Le Filter ne doit jamais voir cette erreur — catch-la dans le handler.
5. Casser : le mauvais ordre qui avale tout
Objectif — Déclarer AllExceptionsFilter en dernier dans providers et constater que DomainError repart en internal_error. Diagnostiquer (résolution inverse), réparer, et écrire un test de non-régression qui garantit que BankingErrorFilter gagne.
Indice/Solution — Le test : throw InsufficientFundsError, assert status 422 (pas 500). Réordonne le tableau providers.
6. Cost-guard à l'edge + abort propagé (hard)
Objectif — Avant d'ouvrir un stream LLM, un @Catch(CostBudgetExceededError) renvoie 429 + Retry-After. Une fois le stream ouvert, brancher req.on('close') sur un AbortController qui annule l'appel Anthropic. Prouver, via un log, que déconnecter le client coupe bien l'appel upstream (coût économisé).
Indice/Solution — La décision « refuser » se prend avant flushHeaders() pour que le Filter puisse répondre 429. Pour l'abort : passe signal à messages.stream(..., { signal }) et appelle ac.abort() dans le close. Vérifie que le for await s'interrompt par une APIUserAbortError.
7. Casser : le @Catch(DomainError) qui rate à cause d'instanceof (très hard)
Objectif — Reproduire le pitfall n°12 : ton @Catch(DomainError) matche en test unitaire mais tout repart en 500 quand l'erreur traverse une frontière de package. Provoquer la double-classe, diagnostiquer, réparer.
Indice/Solution — Crée deux fichiers exportant chacun leur propre class DomainError (simule deux copies dans node_modules). Throw l'instance de la copie A, donne @Catch(DomainErrorB) au filter → aucun match. Diagnostic : console.log(err.constructor.name, err instanceof DomainErrorB) (le name est identique, l'instanceof est false). Fix : une seule source partagée, OU un filter @Catch() global qui discrimine sur 'code' in exception && typeof exception.code === 'string' au lieu du nominal. Écris un test qui garantit le match malgré deux constructeurs distincts.
🎤 En entretien
Q : Quelle est la différence entre un Interceptor (catchError) et un Exception Filter pour gérer une erreur ? R : L'Interceptor observe/transforme dans le pipeline RxJS (logging, metrics, mapping d'un Observable d'erreur) et reste dans le flux ; le Filter est la dernière couche qui décide du format final de la réponse HTTP. Règle : observer → Interceptor (tap({ error })), formater la réponse → Filter. Les deux coexistent ; un seul doit écrire la réponse, sinon ERR_HTTP_HEADERS_SENT.
Q : Dans quel ordre les APP_FILTER sont-ils résolus, et où mets-tu ton catch-all @Catch() ? R : Résolution en ordre inverse de déclaration — le dernier provider APP_FILTER est tenté en premier. Donc le catch-all @Catch() (filet de sécurité) se déclare en premier dans le tableau providers, et les filters spécifiques après. À cela s'ajoute la précédence par scope : method > controller > global.
Q : Tu streames des tokens LLM en SSE et l'appel upstream échoue au milieu. Pourquoi ton Exception Filter ne peut-il pas renvoyer un 500, et que fais-tu ? R : Les headers sont déjà envoyés (200 text/event-stream) dès le premier token — changer le status lève ERR_HTTP_HEADERS_SENT. La gestion d'erreur bascule dans le handler : try/catch autour du for await, j'émets un event: error applicatif dans le flux puis res.end(). Le Filter ne gère que les erreurs avant le premier byte (auth, validation, cost-guard, rate-limit).
Q : Pourquoi APP_FILTER plutôt que app.useGlobalFilters(new MyFilter()) en prod ? R : APP_FILTER passe par le conteneur DI : le filter reçoit Logger, ConfigService, un ErrorReporter/client Sentry injectés et mockables en test. new MyFilter() court-circuite la DI → tu finis par instancier des clients à la main dans le filter (config perdue, non testable, parfois un client par requête). DI dès qu'il y a une dépendance.
Q : Ton @Catch(DomainError) marche en local mais tout repart en 500 en prod/CI. Diagnostic ? R : Classique du instanceof qui ment. Deux copies du package d'erreurs dans node_modules (monorepo mal hoisté, npm link, lib publiée + locale) → deux classes DomainError distinctes, donc instanceof false. Ou un target: es5 qui casse la chaîne prototype d'extends Error. Je log exception.constructor.name + la chaîne de prototypes, je vérifie le hoisting (npm ls <pkg>), je passe target ≥ es2017, je consolide en une seule source de vérité ; en dernier recours je discrimine sur une propriété ('code' in exception) au lieu du @Catch nominal.
🔗 Liens
- Docs Nest — Exception Filters
- HTTP status codes RFC 9110
- Problem Details for HTTP APIs — RFC 9457 — un standard d'envelope d'erreur à connaître.
- Sentry NestJS SDK
- Voir
03-interceptors.mdpour la frontière Interceptor (observe) vs Filter (respond).