Skip to content

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 : un AllExceptionsFilter global qui maps HttpException → 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 / sentry

Analogie — 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 :

  1. Précédence par scope — Nest tente d'abord les filters method-scoped (@UseFilters sur le handler), puis controller-scoped, puis global (APP_FILTER / app.useGlobalFilters). Le plus proche du handler gagne. Un @UseFilters au niveau route remplace (n'additionne pas) le controller pour le matching — mais le global reste un fallback si aucun scope local ne matche.
  2. Ordre des APP_FILTER — au sein du même scope global, les providers APP_FILTER sont 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 tableau providers, et les filters spécifiques après.
ts
@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 AllExceptionsFilter en dernier dans le tableau « par habitude » → il intercepte tout en premier → les filters spécifiques ne sont jamais atteints. Si tes DomainError repartent en internal_error, c'est ça. (Voir aussi le bloc end-to-end plus bas qui montre l'ordre correct.)

ScopeDécorateur / wiringDI possible ?Cas d'usage
Method@UseFilters(Filter) sur le handlerSi instancié par Nest (classe, pas new)Override ultra-local, rare
Controller@UseFilters(Filter) sur la classeOui (classe)Erreurs propres à un domaine
Global (useGlobalFilters)app.useGlobalFilters(new F())Non — instance manuelle, pas de DIBootstrap simple, pas de deps
Global (APP_FILTER)provider dans un moduleOui — DI complèteLe 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

ts
// 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();
ts
// 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'); }
}
ts
// 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 {}
ts
// 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 Fastifyres.status(s).json(o) est l'API Express. Sous Fastify c'est res.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 via HttpAdapterHost :

ts
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

  1. Error envelope unifiée — toujours { error: 'snake_case_code', message, meta, requestId, timestamp, path }. Le client switch sur error, pas sur message (i18n).
  2. Domain → HTTP mapping centralisé — table code → status dans un seul endroit. Pas de throw new BadRequestException dans le service (couple HTTP et domain).
  3. Multiple @Catch() classes@Catch(UserNotFoundError, ProductNotFoundError) accepte une liste.
  4. Global filter + per-controller override@UseFilters() au niveau controller/route remplace le global pour ce scope.
  5. WS / RPC filtersBaseWsExceptionFilter, BaseRpcExceptionFilter pour ne pas casser le protocole non-HTTP.
  6. Sentry / observability — dans le filter pour le path "inconnu" (non HttpException), envoyer à Sentry avec requestId et user context. Ne pas envoyer les 4xx attendus (bruit).
  7. RFC 9457 Problem Details — format standardisé { type, title, status, detail, instance }. Adopté par certaines APIs publiques pour des erreurs lisibles par humains et machines.
  8. i18n des messages — le filter peut traduire error.code via i18next/nestjs-i18n selon Accept-Language. Garder le code stable, traduire message.
  9. Fallback HTML pour les routes server-rendered — si le client Accept: text/html, rendre une page d'erreur ; sinon JSON. Détection via req.headers.accept.
  10. 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

VersionNotes
Nest 7HttpException accepte string | object en arg.
Nest 8HttpException peut wrapper une cause (Error nativement).
Nest 9HttpException constructor signature stabilisée. Filter peut être request-scoped.
Nest 10BaseExceptionFilter.catch reçoit host: ArgumentsHost (avant : host: any).
Nest 11Support 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

  1. Throw d'Error brut en @Catch() typé@Catch(HttpException) ne catch pas les Error. Avoir un @Catch() (sans args) global comme safety net.
  2. Leak d'info en 500 — renvoyer exception.stack ou error.message brut au client peut leak des paths internes, des noms de tables, des secrets. Toujours mapper.
  3. 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.
  4. Filter qui throw — un filter qui throw lui-même ⇒ Nest balance un 500 brut. Toujours try/catch à l'intérieur du catch().
  5. getResponse() Fastify vs Expressres.status(s).json(o) est Express. En Fastify : res.status(s).send(o). Utiliser HttpAdapterHost pour rester agnostique.
  6. Validation errors illisibles — par défaut ValidationPipe renvoie { message: ['x must be ...'], error: 'Bad Request', statusCode: 400 }. Customiser via exceptionFactory pour produire un format { error: 'validation_failed', fields: { email: ['must_be_email'] } }.
  7. 404 sur route inexistante — pas un HttpException au sens applicatif, mais Nest balance NotFoundException. Ok. Mais si tu as un proxy ou un static handler avant Nest, le 404 peut venir d'avant et bypass le filter.
  8. 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.
  9. Conditional sensitive fields — en NODE_ENV=development, on peut leak la stack pour debug. En prod, JAMAIS. Penser à une flag process.env.NODE_ENV !== 'production'.
  10. 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).
  11. Erreur dans onApplicationBootstrap — pas catché par le Filter HTTP (l'app n'a pas encore démarré). Logger explicitement dans main.ts avec un try/catch top-level.
  12. instanceof qui ment (le piège le plus vicieux) — un @Catch(DomainError) repose sur instanceof DomainError. Si deux copies du même package coexistent dans node_modules (monorepo mal hoisté, lib publiée + version locale liée, npm link), tu as deux classes DomainError distinctes : l'erreur throw par le service est instanceof la copie A, ton filter teste la copie B → le @Catch ne matche jamais et tout repart en 500. Pire avec target: es5 qui casse la chaîne prototype extends Error. Symptôme : « mon filter domain marche en local mais pas en CI/prod ». Diagnostic : log exception.constructor.name et Object.getPrototypeOf ; mitigation : targetes2017, 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.
  13. @Catch() ne voit pas les erreurs des Interceptors après le handler — si un Interceptor mappe l'Observable et 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 un catchError(() => of(fallback)) ne le fera jamais — c'est voulu, mais c'est une source de confusion.

🧪 Testing

ts
// 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' }));
});
ts
// 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.

ts
// 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 }.

ts
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.

ts
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 aideWorkflowError 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.

ts
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.

ts
// 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.

ts
// 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,
    });
  }
}
ts
// 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(),
    });
  }
}
ts
// 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é dans instrument.ts avant bootstrap). C'est acceptable car @sentry/node est 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 un ErrorReporter :

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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;
      }
    }
  }
}
ts
// 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 :

PhaseQui gère l'erreurFormat
Avant flushHeaders() (auth, validation, rate-limit edge, cost-guard)Exception Filter4xx/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, idempotencepersisté, 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.

ts
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é. Persiste status: 'completed' + l'output avant tout, et court-circuite si déjà fait.
  • Retry cost-aware : ne jamais retry les 4xx non-retryables (400 invalid_request, 401) — c'est de l'argent jeté. Retry seulement 429/5xx/overloaded_error avec backoff exponentiel.
  • Partial-output handling : si le stream casse au token 400/500, persiste le partiel + un flag truncated: true plutôt que de jeter 400 tokens déjà payés.
ts
// @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/sdk fait du retry automatique (429/5xx/overloaded_error) avec backoff — ne le double pas dans ton Filter.

🧰 Exemples avancés

Filter Prisma-aware

ts
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

ts
@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

ts
// 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/Solutiongrep 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 targetes2017, 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

Bibliothèque tech perso — Achref