J6 · Pipeline de requête : exceptions, filters, pipes, interceptors
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,ParseEnumPipeet é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,LoggingInterceptoretCorrelationIdMiddleware.
Spring → NestJS
| Spring | NestJS | À 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 false | Guard (CanActivate) | Laisse passer ou non ; connaît le handler et ses métadonnées (J8) |
AOP @Around, HandlerInterceptor | Interceptor (NestInterceptor) | Code avant et après le handler, autour d'un Observable RxJS |
Converter, @InitBinder, @Valid | Pipe (PipeTransform) | Convertit et valide chaque argument juste avant l'appel |
@ControllerAdvice + @ExceptionHandler | Filtre global (APP_FILTER) + @Catch() | Un seul endroit pour le format d'erreur de toute l'API |
ResponseStatusException | HttpException, NotFoundException, ConflictException… | On les lève, on ne les renvoie pas |
ProblemDetail (Spring 6) | Aucun équivalent natif : ProblemDetails maison dans TaskFlow | Le format est à écrire vous-même dans le filtre |
Traduction SQLException → DataIntegrityViolationException | Aucune : lire QueryFailedError.driverError.code | TypeORM ne traduit pas les codes SQL |
Comprendre
Ordre d'exécution
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 :
// 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-uuidavec un token passe les guards, démarreLoggingInterceptor, puisParseUUIDPipelève uneBadRequestException. 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 :
{ "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. LeValidationPipey 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ée | Déclaration | Remarque |
|---|---|---|
| Méthode ou contrôleur | @UseFilters(MyFilter) | Pour un cas local |
| Global, avec DI | { provide: APP_FILTER, useClass: AllExceptionsFilter } dans un module | Peut injecter des providers ; actif dans les tests e2e qui démarrent AppModule |
| Global, sans DI | app.useGlobalFilters(new AllExceptionsFilter(...)) dans main.ts | Vous 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
@ExceptionHandlerla 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, étendezBaseExceptionFilteret appelezsuper.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, messageValidation failed (uuid is expected).@Query('status', new ParseEnumPipe(TaskStatus, { optional: true })): passer une instance permet de donner des options.?status=LATE→ 400, messageValidation failed (enum string is expected); sans?status, la valeur resteundefinedgrâce àoptional: true.
Le contrôleur des tâches les combine :
@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 :
// 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 sonObservable(ou un dérivé), le handler ne s'exécute pas. La réponse est alors ce que votreObservableé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 :
| Usage | Technique | Dans TaskFlow |
|---|---|---|
| Logging et durée | Mesure avant next.handle(), tap() après | LoggingInterceptor (cas TaskFlow) |
| Mapping de réponse | map() sur le résultat | ClassSerializerInterceptor applique @Exclude() à chaque réponse |
| Cache | Renvoyer of(valeurEnCache) sans appeler next.handle() | Non (voir ci-dessous) |
| Erreurs, délai | catchError(), timeout() | Non |
Deux illustrations courtes :
// 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 })));
}
}// 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émenteNestMiddleware: elle peut recevoir des dépendances. On l'enregistre dans un module qui implémenteNestModule, avecconfigure(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 pourhelmet()danssetupApp().
// 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
// 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 pardescribe(), qui choisitstatus,codeetmessage. LesHttpExceptiongardent leur code et leur message (tableau compris) ;codevient de l'enumHttpStatus(404→NOT_FOUND).correlationIdest lu dans l'en-tête posé par le middleware,pathvient derequest.url.HttpAdapterHostrépond sans dépendre d'Express (httpAdapter.reply()), le filtre fonctionnerait avec Fastify. Comme il est injecté par le constructeur, le filtre est enregistré viaAPP_FILTER.- Seules les erreurs
>= 500sont journalisées ici, avec la pile. Le client ne reçoit jamais le message d'origine : le test unitaire vérifie qu'une erreur contenantpassworddonne une 500 générique.
Le test e2e envoie une requête sans token avec son propre identifiant :
GET /v1/projects
x-correlation-id: test-123HTTP/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 :
| Erreur | Origine | Réponse |
|---|---|---|
EntityNotFoundError | findOneOrFail(), findOneByOrFail() | 404 NOT_FOUND |
QueryFailedError, code PostgreSQL 23505 | Violation de contrainte unique | 409 CONFLICT |
QueryFailedError, code 23503 | Clé étrangère vers une ligne absente | 422 INVALID_REFERENCE |
| Toute autre erreur | Bug, 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èveConflictException('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 deusers.emailrefuse 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èventNotFoundExceptionavec un message précis. La brancheEntityNotFoundErrorest un filet de sécurité pour qui utiliseraitfindOneByOrFail(); aucun appel de ce type n'existe aujourd'hui. - Référence invalide. Créer une tâche avec un
assigneeIdqui 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).
POST /v1/auth/register
Content-Type: application/json
{ "email": "twice@example.com", "password": "a long enough password" }{
"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
// 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?.pathdonne 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.errorjournalise 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) :
GET /v1/projects 200 0ms
GET /v1/projects/:id failed after 0ms: NotFoundException: Project … not foundUne 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
// 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 :
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é viaAPP_FILTER(filtre global). - Un e-mail déjà utilisé doit donner 409 →
ConflictExceptiondans le service, plus contrainte unique et mapping23505pour 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 lesTypeError: 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
LoggingInterceptorqu'il trace les 401 : les guards passent avant lui.
Exercices
- [ ] Écrire
AllExceptionsFilter(@Catch()) qui renvoiestatus,code,message,path,timestamp,correlationIdenapplication/problem+json, et l'enregistrer viaAPP_FILTER. - [ ] Mapper les erreurs TypeORM : violation de contrainte unique → 409, entité non trouvée → 404 ; tester la 409 avec un
QueryFailedErrorportant le code23505. - [ ] Écrire
LoggingInterceptor(méthode, route, statut, durée) et l'enregistrer viaAPP_INTERCEPTOR. - [ ] Écrire
CorrelationIdMiddleware, l'appliquer avec{*splat}et vérifier aveccurl -iquex-correlation-idrevient 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.mdles différences avec@ControllerAdvice, le servletFilteret@Around.