Skip to content

J6 · Pipeline de requête : exceptions, filters, pipes, interceptors ​

Jour 6 · mardi 17 novembre · Livrable : gestion d'erreurs globale, logs de requêtes

Entre la socket HTTP et votre méthode de contrôleur, une requête Nest traverse une chaîne fixe de composants : middleware, guards, interceptors, pipes, puis les exception filters si quelque chose échoue. Spring répartit les mêmes rôles entre servlet filters, HandlerInterceptor, AOP et @ControllerAdvice. À la fin de cette journée, vous savez où placer une logique transverse et TaskFlow renvoie toutes ses erreurs dans un format unique, journalise chaque requête et suit chaque appel avec un identifiant de corrélation.

Objectifs ​

  • Connaître l'ordre d'exécution du pipeline et choisir le bon composant pour chaque besoin.
  • Lever les exceptions HTTP intégrées et les convertir en un format d'erreur unique avec un filtre global.
  • Utiliser ParseUUIDPipe, ParseEnumPipe et écrire un pipe custom.
  • Écrire un interceptor (logging, durée, mapping de réponse, cache).
  • Écrire un middleware, en classe ou en fonction, et l'enregistrer avec configure(consumer).
  • Livrer dans TaskFlow : AllExceptionsFilter (problem+json), mapping des erreurs TypeORM, LoggingInterceptor et CorrelationIdMiddleware.

Spring → NestJS ​

SpringNestJSÀ retenir
Filter servlet (OncePerRequestFilter)Middleware (NestMiddleware ou simple fonction)Voit la requête brute avant le routage, ne sait pas quel handler va répondre
Chaîne de filtres Spring Security, HandlerInterceptor.preHandle() qui renvoie falseGuard (CanActivate)Laisse passer ou non ; connaît le handler et ses métadonnées (J8)
AOP @Around, HandlerInterceptorInterceptor (NestInterceptor)Code avant et après le handler, autour d'un Observable RxJS
Converter, @InitBinder, @ValidPipe (PipeTransform)Convertit et valide chaque argument juste avant l'appel
@ControllerAdvice + @ExceptionHandlerFiltre global (APP_FILTER) + @Catch()Un seul endroit pour le format d'erreur de toute l'API
ResponseStatusExceptionHttpException, NotFoundException, ConflictException…On les lève, on ne les renvoie pas
ProblemDetail (Spring 6)Aucun équivalent natif : ProblemDetails maison dans TaskFlowLe format est à écrire vous-même dans le filtre
Traduction SQLException → DataIntegrityViolationExceptionAucune : lire QueryFailedError.driverError.codeTypeORM ne traduit pas les codes SQL

Comprendre ​

Ordre d'exécution ​

text
requête
  → Middleware            CorrelationIdMiddleware, helmet(), pino-http
  → Guards                ThrottlerGuard → JwtAuthGuard → RolesGuard
  → Interceptors (avant)  LoggingInterceptor, ClassSerializerInterceptor
  → Pipes                 ValidationPipe (global), ParseUUIDPipe, ParseEnumPipe (paramètre)
  → Handler               méthode du contrôleur, puis le service
  → Interceptors (après)  ordre inverse : ClassSerializerInterceptor, puis LoggingInterceptor
réponse
  ↳ Exception filters     AllExceptionsFilter, quelle que soit l'étape qui a levé l'exception

À chaque niveau, l'ordre va du plus large au plus précis : global, puis contrôleur, puis méthode (et paramètre pour les pipes). Pour les filtres, c'est l'inverse : Nest cherche d'abord un filtre sur la méthode, puis sur le contrôleur, puis un filtre global, et le premier dont le @Catch() correspond traite l'exception. Les filtres ne s'enchaînent pas.

TaskFlow déclare tous ses composants globaux comme providers dans AppModule :

ts
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
  {
    provide: APP_PIPE,
    useValue: new ValidationPipe({
      whitelist: true, // strip properties without a decorator
      forbidNonWhitelisted: true, // ...or reject the request: 400
      transform: true, // plain JSON -> DTO instances, "2" -> 2
    }),
  },
  { provide: APP_GUARD, useClass: ThrottlerGuard },
  { provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
  { provide: APP_GUARD, useClass: RolesGuard },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
],

Ce que cet ordre implique, concrètement :

  • Une requête sans token s'arrête dans JwtAuthGuard. Ni les interceptors ni les pipes ne s'exécutent : le filtre répond directement 401. Une requête sans token et avec un id invalide reçoit donc 401, pas 400.
  • /v1/projects/not-a-uuid avec un token passe les guards, démarre LoggingInterceptor, puis ParseUUIDPipe lève une BadRequestException. L'interceptor voit l'erreur passer, le filtre répond 400.
  • Le code de statut (200, 201 ou la valeur de @HttpCode()) est fixé par Nest après les guards et avant les interceptors. C'est pour cela qu'un interceptor peut le lire une fois le handler terminé.

Exceptions HTTP intégrées ​

HttpException(response, status) est la classe de base. Nest fournit une sous-classe par code courant : BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), InternalServerErrorException (500), ServiceUnavailableException (503), etc. Vous les levez n'importe où dans la chaîne (guard, pipe, service), et un filtre les convertit en réponse.

Sans filtre personnalisé, Nest utilise son filtre par défaut :

json
{ "message": "Project 42 not found", "error": "Not Found", "statusCode": 404 }

Toute autre erreur (TypeError, erreur du driver PostgreSQL…) devient { "statusCode": 500, "message": "Internal server error" }. Deux points comptent :

  • exception.getResponse() renvoie soit une chaîne, soit un objet. Le ValidationPipe y place un tableau de messages (["name should not be empty"], voir J2). Un filtre maison doit gérer les deux formes.
  • Le second argument accepte des options : new ConflictException('Email already registered', { cause: error }) garde l'erreur d'origine pour les logs sans l'exposer au client.

TaskFlow lève directement NotFoundException et ConflictException depuis ses services, comme on lève ResponseStatusException dans un @Service Spring. C'est pragmatique : le service connaît HTTP. Si vous voulez un domaine indépendant, levez vos propres exceptions métier et traduisez-les dans le filtre.

Exception filters et filtre global ​

Un filtre implémente ExceptionFilter et sa méthode catch(exception, host). @Catch(QueryFailedError) le limite à certains types ; @Catch() sans argument attrape tout, y compris les erreurs qui ne sont pas des HttpException. host (un ArgumentsHost) donne accès à la requête et à la réponse : host.switchToHttp().getRequest().

Trois façons de l'attacher :

PortéeDéclarationRemarque
Méthode ou contrôleur@UseFilters(MyFilter)Pour un cas local
Global, avec DI{ provide: APP_FILTER, useClass: AllExceptionsFilter } dans un modulePeut injecter des providers ; actif dans les tests e2e qui démarrent AppModule
Global, sans DIapp.useGlobalFilters(new AllExceptionsFilter(...)) dans main.tsVous construisez l'instance ; absent des tests qui n'exécutent pas main.ts

Le filtre global attrape aussi ce qui vient des middleware, des guards et des routes inconnues : une requête vers /v1/nope reçoit 404 avec le message Cannot GET /v1/nope, dans le même format que les autres erreurs.

Où l'analogie avec @ControllerAdvice s'arrête :

  • Spring choisit la méthode @ExceptionHandler la plus spécifique selon la hiérarchie de l'exception. Nest parcourt les filtres de la portée la plus proche à la plus large (méthode, contrôleur, global) et retient le premier dont le @Catch() correspond, sans comparer la précision des types.
  • Un filtre @Catch() remplace entièrement le comportement par défaut : journalisation des 500, erreurs inconnues, tout est à votre charge. Pour ne traiter que certains cas, étendez BaseExceptionFilter et appelez super.catch(exception, host) pour le reste.

Pipes ​

Un pipe reçoit un argument du handler (un @Param, un @Query, un @Body), le transforme ou le rejette. Il s'exécute juste avant l'appel du handler, après les guards et le début des interceptors. S'il lève une exception, le handler n'est jamais appelé.

Pipes intégrés utiles : ValidationPipe, ParseIntPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe, ParseEnumPipe, DefaultValuePipe. TaskFlow en utilise deux sur des paramètres :

  • @Param('id', ParseUUIDPipe) : passer la classe laisse Nest créer l'instance (et lui injecter des dépendances si besoin). /v1/projects/not-a-uuid → 400, message Validation failed (uuid is expected).
  • @Query('status', new ParseEnumPipe(TaskStatus, { optional: true })) : passer une instance permet de donner des options. ?status=LATE → 400, message Validation failed (enum string is expected) ; sans ?status, la valeur reste undefined grâce à optional: true.

Le contrôleur des tâches les combine :

ts
@ApiTags('tasks')
@ApiBearerAuth()
@Controller()
export class TasksController {
  constructor(private readonly tasks: TasksService) {}

  @Post('projects/:projectId/tasks')
  create(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Body() dto: CreateTaskDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.create(projectId, dto, user);
  }

  // Pipes convert and validate one parameter, like a Spring Converter:
  // "?status=foo" is rejected with 400 before the handler runs.
  @Get('projects/:projectId/tasks')
  list(
    @Param('projectId', ParseUUIDPipe) projectId: string,
    @Query('status', new ParseEnumPipe(TaskStatus, { optional: true })) status: TaskStatus | undefined,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.list(projectId, status, user);
  }

  @Patch('tasks/:id/status')
  updateStatus(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateTaskStatusDto,
    @CurrentUser() user: AuthUser,
  ) {
    return this.tasks.updateStatus(id, dto.status, user);
  }
}

Un pipe custom implémente PipeTransform. metadata.data contient le nom du paramètre, pratique pour le message d'erreur :

ts
// Exemple hors TaskFlow : "/v1/reports/2026-11" → { year: 2026, month: 11 }
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common';

export interface YearMonth {
  year: number;
  month: number;
}

@Injectable()
export class ParseYearMonthPipe implements PipeTransform<string, YearMonth> {
  transform(value: string, metadata: ArgumentMetadata): YearMonth {
    const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value);
    if (!match) throw new BadRequestException(`${metadata.data} must be YYYY-MM`);
    return { year: Number(match[1]), month: Number(match[2]) };
  }
}

// Utilisation : report(@Param('month', ParseYearMonthPipe) month: YearMonth)

Côté Spring, c'est un Converter<String, YearMonth> enregistré globalement. Différence : un pipe est attaché explicitement à un paramètre (ou à un handler avec @UsePipes(), ou à toute l'application avec APP_PIPE). Rien n'est converti « par magie » : sans pipe ni transform: true, un paramètre de route reste une chaîne.

Interceptors ​

Un interceptor implémente intercept(context, next). Le code écrit avant next.handle() s'exécute avant le handler ; next.handle() renvoie un Observable qui émettra le résultat du handler, et les opérateurs RxJS ajoutés avec .pipe() s'exécutent après. next.handle() joue le rôle de ProceedingJoinPoint.proceed() dans un @Around, avec trois différences :

  • next.handle() est paresseux : si vous ne renvoyez pas son Observable (ou un dérivé), le handler ne s'exécute pas. La réponse est alors ce que votre Observable émet, ou rien du tout.
  • Un interceptor entoure uniquement les handlers Nest (routes HTTP, gateways, microservices), pas n'importe quelle méthode de bean.
  • Il ne voit pas les requêtes rejetées avant lui (middleware, guards).

Les usages du plan :

UsageTechniqueDans TaskFlow
Logging et duréeMesure avant next.handle(), tap() aprèsLoggingInterceptor (cas TaskFlow)
Mapping de réponsemap() sur le résultatClassSerializerInterceptor applique @Exclude() à chaque réponse
CacheRenvoyer of(valeurEnCache) sans appeler next.handle()Non (voir ci-dessous)
Erreurs, délaicatchError(), timeout()Non

Deux illustrations courtes :

ts
// Exemple hors TaskFlow : envelopper chaque réponse dans { data }
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class WrapDataInterceptor implements NestInterceptor {
  intercept(_context: ExecutionContext, next: CallHandler): Observable<{ data: unknown }> {
    return next.handle().pipe(map((data) => ({ data })));
  }
}
ts
// Exemple hors TaskFlow : cache de 30 s, uniquement pour une route publique et identique pour tous
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request } from 'express';
import { Observable, of, tap } from 'rxjs';

@Injectable()
export class ShortCacheInterceptor implements NestInterceptor {
  private readonly cache = new Map<string, { value: unknown; expires: number }>();

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const key = context.switchToHttp().getRequest<Request>().originalUrl;
    const hit = this.cache.get(key);
    if (hit && hit.expires > Date.now()) return of(hit.value); // le handler n'est pas appelé
    return next.handle().pipe(tap((value) => this.cache.set(key, { value, expires: Date.now() + 30_000 })));
  }
}

Un cache indexé sur l'URL sert la même réponse à tout le monde

Dans TaskFlow, GET /v1/projects dépend de l'utilisateur (un membre ne voit que ses projets). Un cache indexé uniquement sur l'URL renverrait les projets d'Alice à Bob. Incluez l'identité dans la clé, ou réservez le cache aux données publiques. Pour un vrai cache (expiration, taille bornée, Redis), la doc officielle propose CacheInterceptor de @nestjs/cache-manager, non installé dans TaskFlow.

Middleware ​

Un middleware est une fonction Express (req, res, next). Il s'exécute avant le routage Nest : il ne connaît ni le contrôleur ni le handler, et ne peut donc pas lire @Public() ou @Roles(). C'est la raison pour laquelle l'authentification est un guard et non un middleware.

Deux formes :

  • Classe @Injectable() qui implémente NestMiddleware : elle peut recevoir des dépendances. On l'enregistre dans un module qui implémente NestModule, avec configure(consumer) : consumer.apply(...).exclude(...).forRoutes(...).
  • Fonction : sans état ni dépendance. Elle s'enregistre de la même façon, ou pour toute l'application avec app.use(). TaskFlow le fait pour helmet() dans setupApp().
ts
// Exemple hors TaskFlow : middleware fonctionnel
import type { NextFunction, Request, Response } from 'express';

export function noStore(_req: Request, res: Response, next: NextFunction) {
  res.setHeader('Cache-Control', 'no-store'); // réponses propres à chaque utilisateur
  next();
}

// Dans AppModule.configure() : consumer.apply(CorrelationIdMiddleware, noStore).forRoutes(...)

Où l'analogie avec un servlet Filter s'arrête : dans doFilter(), le code placé après chain.doFilter() s'exécute une fois la réponse produite. Un middleware Express appelle next() et ne récupère rien en retour ; pour agir après la réponse, il faut écouter res.on('finish', ...). Et un middleware qui n'appelle ni next() ni res.end() laisse la requête suspendue.

Cas TaskFlow ​

AllExceptionsFilter global ​

ts
// Like @ControllerAdvice + @ExceptionHandler(Exception.class): one place that
// turns every error into the same problem+json body.
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  constructor(private readonly adapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const { httpAdapter } = this.adapterHost;
    const request = host.switchToHttp().getRequest<Request>();
    const { status, code, message } = this.describe(exception);
    if (status >= 500) this.logger.error(exception);

    const body: ProblemDetails = {
      status,
      code,
      message,
      path: request.url,
      timestamp: new Date().toISOString(),
      correlationId: request.headers[CORRELATION_HEADER] as string | undefined,
    };
    const response = host.switchToHttp().getResponse();
    httpAdapter.setHeader(response, 'Content-Type', 'application/problem+json');
    httpAdapter.reply(response, body, status);
  }

  private describe(exception: unknown): Pick<ProblemDetails, 'status' | 'code' | 'message'> {
    if (exception instanceof HttpException) {
      const res = exception.getResponse();
      const message = typeof res === 'string' ? res : ((res as { message?: string | string[] }).message ?? exception.message);
      return { status: exception.getStatus(), code: HttpStatus[exception.getStatus()] ?? 'ERROR', message };
    }
    if (exception instanceof EntityNotFoundError) {
      return { status: HttpStatus.NOT_FOUND, code: 'NOT_FOUND', message: 'Resource not found' };
    }
    if (exception instanceof QueryFailedError) {
      const pgCode = (exception.driverError as { code?: string } | undefined)?.code;
      if (pgCode === PG_UNIQUE_VIOLATION) {
        return { status: HttpStatus.CONFLICT, code: 'CONFLICT', message: 'Resource already exists' };
      }
      if (pgCode === PG_FOREIGN_KEY_VIOLATION) {
        return { status: HttpStatus.UNPROCESSABLE_ENTITY, code: 'INVALID_REFERENCE', message: 'Referenced resource does not exist' };
      }
    }
    // Never leak internal details (SQL, stack traces) to the client.
    return { status: HttpStatus.INTERNAL_SERVER_ERROR, code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected error' };
  }
}
  • @Catch() sans argument : toutes les erreurs passent par describe(), qui choisit status, code et message. Les HttpException gardent leur code et leur message (tableau compris) ; code vient de l'enum HttpStatus (404 → NOT_FOUND).
  • correlationId est lu dans l'en-tête posé par le middleware, path vient de request.url.
  • HttpAdapterHost répond sans dépendre d'Express (httpAdapter.reply()), le filtre fonctionnerait avec Fastify. Comme il est injecté par le constructeur, le filtre est enregistré via APP_FILTER.
  • Seules les erreurs >= 500 sont journalisées ici, avec la pile. Le client ne reçoit jamais le message d'origine : le test unitaire vérifie qu'une erreur contenant password donne une 500 générique.

Le test e2e envoie une requête sans token avec son propre identifiant :

http
GET /v1/projects
x-correlation-id: test-123
http
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=utf-8
x-correlation-id: test-123

{
  "status": 401,
  "code": "UNAUTHORIZED",
  "message": "Unauthorized",
  "path": "/v1/projects",
  "timestamp": "2026-11-17T10:04:51.312Z",
  "correlationId": "test-123"
}

problem+json, mais pas la RFC 9457 à la lettre

TaskFlow reprend le type de média application/problem+json et le champ status. La RFC 9457 définit aussi type, title, detail et instance ; TaskFlow utilise code, message et path à la place (la RFC autorise des champs d'extension). Si un client attend la RFC stricte, renommez message en detail, path en instance et ajoutez title.

Mapping des erreurs TypeORM ​

TypeORM ne traduit pas les erreurs SQL comme le fait Spring (DataIntegrityViolationException). Le filtre lit donc lui-même ce qui l'intéresse, dans describe() ci-dessus :

ErreurOrigineRéponse
EntityNotFoundErrorfindOneOrFail(), findOneByOrFail()404 NOT_FOUND
QueryFailedError, code PostgreSQL 23505Violation de contrainte unique409 CONFLICT
QueryFailedError, code 23503Clé étrangère vers une ligne absente422 INVALID_REFERENCE
Toute autre erreurBug, base indisponible…500 INTERNAL_SERVER_ERROR, sans détail

Le code est le SQLSTATE PostgreSQL renvoyé par le driver pg (exception.driverError.code). Il dépend du SGBD : avec MySQL, les valeurs seraient différentes.

Comment ces branches servent dans TaskFlow :

  • E-mail en double. AuthService.register() vérifie d'abord l'e-mail et lève ConflictException('Email already registered') : c'est la 409 vérifiée par le test e2e. Mais deux inscriptions simultanées peuvent passer cette vérification ensemble ; l'index unique de users.email refuse alors la seconde (23505) et le filtre répond aussi 409. Le test unitaire du filtre couvre ce cas.
  • Ressource absente. Les services utilisent findOneBy() puis lèvent NotFoundException avec un message précis. La branche EntityNotFoundError est un filet de sécurité pour qui utiliserait findOneByOrFail() ; aucun appel de ce type n'existe aujourd'hui.
  • Référence invalide. Créer une tâche avec un assigneeId qui ne correspond à aucun utilisateur déclenche une violation de clé étrangère, donc une 422 d'après le code (cas non couvert par les tests e2e).
http
POST /v1/auth/register
Content-Type: application/json

{ "email": "twice@example.com", "password": "a long enough password" }
json
{
  "status": 409,
  "code": "CONFLICT",
  "message": "Email already registered",
  "path": "/v1/auth/register",
  "timestamp": "2026-11-17T10:06:12.054Z",
  "correlationId": "0f6c2a5e-8f0d-4c7e-9a43-4a1b2b8d5e11"
}

Ne renvoyez jamais le contenu d'une erreur SQL

Un QueryFailedError transporte la requête SQL (query), ses paramètres et les champs du driver, dont le detail PostgreSQL (Key (email)=(...) already exists). Le renvoyer au client expose le schéma et des données. C'est pour cela que le filtre renvoie des messages fixes ; le détail reste dans les logs du serveur (pile des 500, avertissement du LoggingInterceptor).

LoggingInterceptor : méthode, route, durée ​

ts
// Like an AOP @Around advice: code before next.handle() runs before the
// handler, the RxJS pipe runs after it (or when it fails).
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const http = context.switchToHttp();
    const request = http.getRequest<Request>();
    const started = performance.now();
    const route = `${request.method} ${request.route?.path ?? request.url}`;

    return next.handle().pipe(
      tap({
        next: () => {
          const status = http.getResponse<Response>().statusCode;
          this.logger.log(`${route} ${status} ${Math.round(performance.now() - started)}ms`);
        },
        error: (error: unknown) => {
          this.logger.warn(`${route} failed after ${Math.round(performance.now() - started)}ms: ${String(error)}`);
        },
      }),
    );
  }
}
  • request.route?.path donne le modèle de route (/v1/projects/:id) et non l'URL réelle : les lignes se regroupent par route et ne contiennent pas d'identifiants.
  • Dans tap.next, le statut est déjà fixé (voir l'ordre d'exécution). En cas d'erreur, tap.error journalise un avertissement puis laisse l'erreur continuer vers le filtre.
  • Il est enregistré avant ClassSerializerInterceptor : il démarre en premier et termine en dernier, la durée inclut donc la sérialisation.

Les messages produits ressemblent à ceci (contexte HTTP) :

text
GET /v1/projects 200 0ms
GET /v1/projects/:id failed after 0ms: NotFoundException: Project … not found

Une requête rejetée par un guard (401, 403, 429) ou une route inconnue ne produit aucune ligne : l'interceptor ne s'exécute pas. Si vous devez journaliser ces cas, journalisez dans le filtre, ou activez le log automatique de pino-http (désactivé dans TaskFlow, voir J7).

CorrelationIdMiddleware ​

ts
// The equivalent of a servlet Filter: runs before guards, sees the raw request.
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    ensureCorrelationId(req, res);
    next();
  }
}

Le travail est fait par ensureCorrelationId() (src/common/correlation-id.ts) : il reprend l'en-tête x-correlation-id reçu s'il s'agit d'une chaîne de 100 caractères au plus, sinon il génère un UUID (randomUUID()). Il écrit la valeur dans req.headers (le filtre la relit là) et la renvoie dans l'en-tête de réponse. Le logger pino appelle la même fonction pour son genReqId : le premier des deux qui s'exécute fixe l'identifiant, le second le réutilise.

Le middleware s'applique à toutes les routes :

ts
configure(consumer: MiddlewareConsumer) {
  consumer.apply(CorrelationIdMiddleware).forRoutes({ path: '{*splat}', method: RequestMethod.ALL });
}

{*splat} est la syntaxe de path-to-regexp v8, utilisée par Express 5 et donc par Nest 11 et 12 : un joker nommé, entre accolades pour qu'il reste optionnel. Les jokers sans nom de Nest 10 ('projects/*') ne sont plus acceptés tels quels : Nest tente de les convertir et affiche un avertissement au démarrage.

Côté Spring, c'est un OncePerRequestFilter qui place l'identifiant dans le MDC. Node n'a pas de ThreadLocal par requête : nestjs-pino utilise AsyncLocalStorage pour que chaque ligne de log porte l'identifiant (J7).

Situations ​

  • Toutes les erreurs doivent avoir le même format → filtre @Catch() enregistré via APP_FILTER (filtre global).
  • Un e-mail déjà utilisé doit donner 409 → ConflictException dans le service, plus contrainte unique et mapping 23505 pour les accès concurrents (erreurs TypeORM).
  • Un paramètre de route doit être un UUID ou une valeur d'enum → ParseUUIDPipe, ParseEnumPipe (pipes).
  • Un format maison doit être converti en objet (2026-11) → pipe custom (pipes).
  • Vous devez mesurer la durée de chaque appel → interceptor ; si les 401 et 429 doivent aussi être mesurés, middleware avec res.on('finish') (interceptors).
  • Chaque requête doit porter un identifiant de bout en bout → middleware qui lit ou crée x-correlation-id (corrélation).
  • Toutes les réponses doivent être transformées → interceptor avec map() (interceptors).
  • Une 500 ne doit rien révéler → message générique côté client, pile journalisée avec le correlationId (filtre).

Pièges ​

Filtre global enregistré dans main.ts

app.useGlobalFilters() (comme useGlobalInterceptors() et useGlobalGuards()) ne s'applique qu'à l'application créée par main.ts : un test e2e qui construit AppModule reçoit l'ancien format d'erreur. Le filtre n'a pas non plus accès à l'injection de dépendances. Préférez APP_FILTER.

Interceptor qui oublie de renvoyer next.handle()

Renvoyer of(...) ou un Observable qui n'inclut pas next.handle() court-circuite le handler. C'est voulu pour un cache, c'est un bug partout ailleurs. Même chose pour un middleware qui oublie next() : la requête reste suspendue jusqu'à un timeout.

  • Jokers sans nom hérités de Nest 10 (forRoutes('projects/*')) : Nest les convertit avec un avertissement. Écrivez directement 'projects/{*path}', ou { path: '{*splat}', method: RequestMethod.ALL } pour toutes les routes.
  • Un filtre @Catch(HttpException) laisse passer les erreurs TypeORM et les TypeError : elles reçoivent le format par défaut de Nest. Pour un format unique, attrapez tout (@Catch()).
  • Répondre avec response.status().json() dans un filtre lie le code à Express ; HttpAdapterHost évite ce couplage.
  • Une exception levée dans une promesse non attendue n'atteint aucun filtre : elle devient une unhandledRejection, qui arrête le processus Node par défaut (await oublié).
  • @Res() dans un handler désactive les interceptors de réponse (@Res() manuel).
  • Attendre du LoggingInterceptor qu'il trace les 401 : les guards passent avant lui.

Exercices ​

  • [ ] Écrire AllExceptionsFilter (@Catch()) qui renvoie status, code, message, path, timestamp, correlationId en application/problem+json, et l'enregistrer via APP_FILTER.
  • [ ] Mapper les erreurs TypeORM : violation de contrainte unique → 409, entité non trouvée → 404 ; tester la 409 avec un QueryFailedError portant le code 23505.
  • [ ] Écrire LoggingInterceptor (méthode, route, statut, durée) et l'enregistrer via APP_INTERCEPTOR.
  • [ ] Écrire CorrelationIdMiddleware, l'appliquer avec {*splat} et vérifier avec curl -i que x-correlation-id revient dans l'en-tête et dans le corps des erreurs.
  • [ ] Vérifier avec curl : sans token → 401, /v1/projects/not-a-uuid → 400, ?status=LATE → 400, /v1/nope → 404, tous au même format.
  • [ ] Écrire un pipe custom et un interceptor de mapping (map()), puis les retirer.
  • [ ] Ajouter un log temporaire dans un middleware, un guard, un interceptor, un pipe et le handler, et observer l'ordre réel.
  • [ ] Noter dans LEARNINGS.md les différences avec @ControllerAdvice, le servlet Filter et @Around.

Pour aller plus loin ​