Skip to content

Interceptors

TL;DR — Un Interceptor implémente NestInterceptor.intercept(ctx, next): Observable<any>. C'est un AOP wrapper autour du handler : il s'exécute avant ET après, peut transformer la réponse, mesurer la latence, cacher, retry, timeout, transformer les erreurs. Il vit dans le monde RxJS, ce qui le distingue du middleware pur callback. C'est l'outil de prédilection pour la mécanique transverse autour du résultat du handler.

🧠 Mental model

Request ─► Guard ─► [Interceptor.pre] ─► Pipe ─► Handler ─► [Interceptor.post] ─► Response
                                  │                                  ▲
                                  └─ next.handle() ──────────────────┘
                                       (renvoie un Observable)

Analogie — Un Interceptor c'est un décorateur Python autour d'une fonction, ou un middleware Koa style (around) : const result = await next(); /* transform */ return result. Avant next.handle() ⇒ phase pre. .pipe(map(...)) après ⇒ phase post.

next.handle() retourne toujours un Observable. Même si le handler est async, Nest l'enveloppe. Donc tout opérateur RxJS est dispo : map, tap, catchError, timeout, retry, mergeMap, finalize.

Choisir le bon opérateur RxJS dans la phase post — la confusion map/tap/finalize est l'erreur n°1 des juniors :

OpérateurQuand l'utiliserPiège
map(fn)Transformer la réponse (envelope, projection). fn doit être pur.Un side-effect dans map (log, cache write) est un anti-pattern : il s'exécute autant de fois qu'il y a de subscribers et casse la composition.
tap({ next, error })Side-effect sans modifier la valeur (audit, métrique, cache write).Si tap throw, l'erreur part dans catchError. try/catch interne si la stabilité du handler en dépend.
catchError(fn)Mapper/rethrow une erreur.of(null) silencieux = bug invisible. Toujours rethrow ou mapper vers une HttpException.
finalize(fn)Cleanup garanti (fermer un span, ac.abort(), libérer une connexion) — s'exécute sur next, error ET unsubscribe.C'est le SEUL endroit qui voit l'unsubscribe (annulation client). Mettre le cleanup ailleurs = fuite.
mergeMap(fn)Brancher sur un Observable/Promise asynchrone (cache lookup, budget check) AVANT/AUTOUR de next.handle().mergeMap ne court-circuite pas next.handle() tout seul — il faut return of(hit) explicitement pour skip le handler.

🛠️ Code minimal

ts
// 1) Logging + latence
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly log = new Logger('IN');

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    const start = Date.now();
    return next.handle().pipe(
      tap({
        next: () => this.log.log(`${req.method} ${req.url} OK ${Date.now() - start}ms`),
        error: (e) => this.log.error(`${req.method} ${req.url} KO ${Date.now() - start}ms ${e.message}`),
      }),
    );
  }
}
ts
// 2) Response envelope { data, meta }
@Injectable()
export class EnvelopeInterceptor<T> implements NestInterceptor<T, { data: T; meta: any }> {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(
      map((data) => ({
        data,
        meta: { requestId: req.id, timestamp: new Date().toISOString() },
      })),
    );
  }
}
ts
// 3) Timeout + error mapping
import { catchError, throwError, timeout, TimeoutError } from 'rxjs';
import { RequestTimeoutException } from '@nestjs/common';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly ms = 5000) {}

  intercept(_ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      timeout(this.ms),
      catchError((err) =>
        throwError(() => (err instanceof TimeoutError ? new RequestTimeoutException() : err)),
      ),
    );
  }
}
ts
// 4) Cache simple — clé = URL + user
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(private readonly cache: Cache) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (req.method !== 'GET') return next.handle();
    const key = `cache:${req.user?.id ?? 'anon'}:${req.originalUrl}`;
    return from(this.cache.get(key)).pipe(
      mergeMap((hit) =>
        hit ? of(hit) : next.handle().pipe(tap((res) => this.cache.set(key, res, 30))),
      ),
    );
  }
}
ts
// 5) Wiring
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_INTERCEPTOR, useClass: EnvelopeInterceptor },
    { provide: APP_INTERCEPTOR, useFactory: () => new TimeoutInterceptor(8000) },
  ],
})
export class AppModule {}

🎯 Patterns courants

  1. Response envelope — wrapper { data, meta, errors } cohérent pour le client. Mais attention à le rendre opt-in ou opt-out par décorateur (@SkipEnvelope()) sinon les endpoints de fichiers/streams cassent.
  2. Class serializationClassSerializerInterceptor + @Exclude(), @Expose(), @Transform() de class-transformer pour masquer password, etc. Préférer aux DTOs de sortie verbeux. À combiner avec @SerializeOptions({ groups: ['admin'] }) pour des projections par rôle.
  3. Cache@nestjs/cache-manager v2 expose CacheInterceptor. Pour cache custom, voir exemple ci-dessus. Toujours invalider sur mutation (cache-aside via clés par tag).
  4. Retry / timeouttimeout(ms) + retry({ count, delay }) pour les routes qui appellent un service externe instable. Avec retryWhen + backoff exponentiel pour les erreurs transientes (network, 503).
  5. Audit logtap({ next: (data) => audit.log(req, data) }). Garder léger, sinon mettre en file (BullMQ).
  6. Tx wrapper — démarrer une transaction, l'attacher au request (ALS), commit en tap, rollback en catchError. Voir 03-data-layer/04-transactions.md.
  7. OpenTelemetry tracing — un Interceptor qui ouvre un span par handler, l'enrichit avec req.user.id, req.tenantId, et le ferme en finalize. Combiné avec @opentelemetry/instrumentation-http pour le span parent HTTP.
  8. Idempotency — lit un header Idempotency-Key, regarde Redis : si déjà servi, retourne la réponse cachée ; sinon exécute et cache le résultat pendant 24h. Indispensable pour les POST de paiement.
  9. Field-level redaction — pour les logs : un Interceptor tap qui copie la réponse, redacte certains champs (password, token, pii.*), et envoie au logger. Le response client n'est pas modifié.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

VersionNotes
Nest 7RxJS 6 (pipe(map(...)) existe, tap() syntaxe partial-observer pas standard).
Nest 8Toujours RxJS 6 pour la plupart. CacheInterceptor dans @nestjs/common.
Nest 9RxJS 7 : tap({ next, error, complete }) au lieu de tap(next, error, complete). mergeMap au lieu de flatMap.
Nest 10@nestjs/cache-manager extrait en package séparé (avant : dans @nestjs/common).
Nest 11ClassSerializerInterceptor accepte options par requête via @SerializeOptions(). AsyncLocalStorage natif facilite le pattern "tx interceptor".

RxJS 7 piègetap(fn) continue à marcher mais tap(nextFn, errFn, complFn) est deprecated. Toujours utiliser tap({ next, error, complete }).

⚠️ Pitfalls

  1. Oublier de retourner next.handle() — si tu écris next.handle().pipe(map(...)) mais que tu ne le return pas, le handler ne s'exécute pas. Symptôme : 200 avec body vide ou pending.
  2. Side-effects dans mapmap doit être pur. Pour les side-effects (log, cache write), utiliser tap.
  3. Cache trop large — un Interceptor cache global qui mémorise toutes les routes ⇒ cache poisoning entre utilisateurs si la clé n'inclut pas l'identité. Toujours inclure user.id ou tenant.id dans la clé.
  4. ClassSerializerInterceptor sur des objets non-class — il ne sérialise que des instances de classe (objets décorés). Un plainObject passe tel quel — surprise quand on retourne un raw query result au lieu d'une entité.
  5. Erreur masquéecatchError qui retourne of(null) silencieusement ⇒ bug invisible. Toujours rethrow ou map vers une HttpException.
  6. Ordre des interceptors — appliqués dans l'ordre d'enregistrement pour le pre, ordre inverse pour le post (LIFO). Important pour combiner logging + envelope.
  7. Interceptor request-scoped — coûteux (toute la chaîne). N'utiliser que si vraie dépendance request-scoped.
  8. Stream / file response — appliquer un envelope sur StreamableFile ⇒ le client reçoit du JSON au lieu du fichier. Skip via @SkipEnvelope() décorateur custom.
  9. timeout() ne tue pas le handler — RxJS timeout arrête de subscribe, mais le handler async continue jusqu'au bout côté Node (fuite ressources). Pour vraiment annuler, utiliser un AbortController propagé via le contexte.
  10. retry aveugle — un retry sur une POST non-idempotente ⇒ double création. Filtrer par type d'erreur : retry({ count: 3, delay: 100, when: e => e.code === 'ECONNRESET' }).
  11. tap qui throw — un side-effect dans tap qui lance une erreur la propage à catchError. Toujours try/catch à l'intérieur de tap si la stabilité du handler en dépend.

🧪 Testing

ts
// Unitaire — fabrique du CallHandler
import { of, lastValueFrom } from 'rxjs';

const callHandler = (val: any) => ({ handle: () => of(val) });
const ctx: any = { switchToHttp: () => ({ getRequest: () => ({ method: 'GET', url: '/x', id: 'r1' }) }) };

it('envelope wrappe la réponse', async () => {
  const i = new EnvelopeInterceptor();
  const out = await lastValueFrom(i.intercept(ctx, callHandler({ a: 1 })));
  expect(out).toEqual({ data: { a: 1 }, meta: expect.objectContaining({ requestId: 'r1' }) });
});

it('timeout déclenche RequestTimeoutException', async () => {
  const slow = { handle: () => timer(100).pipe(map(() => 'x')) };
  const i = new TimeoutInterceptor(10);
  await expect(lastValueFrom(i.intercept(ctx, slow))).rejects.toBeInstanceOf(RequestTimeoutException);
});
ts
// Intégration — override via TestingModule
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideInterceptor(LoggingInterceptor)
  .useValue({ intercept: (_c, n) => n.handle() })
  .compile();

Tester les Interceptors RxJS demande de bien manipuler Observable — utiliser lastValueFrom ou firstValueFrom pour convertir en Promise dans Jest.

ts
// e2e — vérifier l'envelope sur une route réelle
await request(app.getHttpServer())
  .get('/users/1')
  .expect(200)
  .expect(({ body }) => {
    expect(body).toHaveProperty('data.id');
    expect(body.meta.requestId).toBeDefined();
  });

Pour les Interceptors qui dépendent du timing (retry, timeout), utiliser marbles de RxJS (TestScheduler) ou Jest fake timers selon le pattern.

🎬 Cas d'usage concrets

Scénario 1 — E-commerce : cache produits agressif

Qui — Un retailer DTC FR (mode + lifestyle, 90M€ CA) avec un catalogue produits visité 14M fois/mois sur site + apps mobiles. ≈ 2 200 RPS en pointe sur /api/products/*.

Problème métier — La DB PG saturait à chaque pic (Black Friday, drops). Les requêtes étaient pourtant à 95% identiques (mêmes filtres, mêmes catégories). Sans cache, coût infra DB x3 pendant les pics.

Comment ce concept aideCacheInterceptor custom qui invalide finement par tag (product:sku-XXX, category:cat-YY). Chaque mutation produit invalide les tags concernés. Stale-while-revalidate pour les listes (servir le cache même légèrement périmé pendant qu'on régénère).

ts
@Injectable()
export class ProductCacheInterceptor implements NestInterceptor {
  constructor(private readonly cache: TaggedCacheService) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (req.method !== 'GET') return next.handle();

    const tags = this.computeTags(req);
    const key = `prod:${req.originalUrl}`;
    return from(this.cache.get<any>(key)).pipe(
      mergeMap((hit) => {
        if (hit && hit.expiresAt > Date.now()) return of(hit.data);
        return next.handle().pipe(
          tap((data) => this.cache.setWithTags(key, data, tags, 600)),
        );
      }),
    );
  }

  private computeTags(req: any): string[] {
    const tags: string[] = ['products'];
    if (req.params.sku) tags.push(`product:${req.params.sku}`);
    if (req.query.category) tags.push(`category:${req.query.category}`);
    return tags;
  }
}

Gains chiffrés — RPS DB tombé de 2 200 à 110 (cache hit ratio 95%), latence p99 des endpoints produits passée de 280ms à 18ms, coût infra DB divisé par 2.5, 0 incident pendant Black Friday 2025.

Scénario 2 — Banque : DTO mapping RGPD-safe

Qui — Une banque privée FR (180 ETP). Les entités domain contiennent des champs sensibles (internalNotes, riskScore, kycDocs) qui ne doivent JAMAIS sortir au client.

Problème métier — Avant l'interceptor, chaque service avait son mapToDto() manuel. Oublis fréquents : un dev qui retournait entity au lieu de toDto(entity) exposait des champs internes. 2 audits CNIL ont relevé des fuites mineures.

Comment ce concept aideClassSerializerInterceptor global + @Exclude() sur les champs sensibles dans les entités. Combiné avec @SerializeOptions({ groups: ['admin'] }) pour des projections différentielles par rôle. Plus de map manuel, l'oubli est impossible (la classe instanciée applique les decorators).

ts
import { Exclude, Expose } from 'class-transformer';

export class PrivateClient {
  @Expose() id!: string;
  @Expose() name!: string;
  @Expose({ groups: ['advisor'] }) iban?: string;
  @Exclude() internalNotes!: string;
  @Exclude() riskScore!: number;
  @Exclude() kycDocsUrls!: string[];
  @Expose({ groups: ['admin'] }) auditTrail!: any[];
}

@Controller('private-clients')
@UseInterceptors(ClassSerializerInterceptor)
export class PrivateClientsController {
  @Get(':id')
  @SerializeOptions({ groups: ['advisor'] })
  byId(@Param('id') id: string) { return this.svc.findById(id); }

  @Get('admin/:id')
  @Roles('admin')
  @SerializeOptions({ groups: ['admin'] })
  adminView(@Param('id') id: string) { return this.svc.findById(id); }
}

Gains chiffrés — 0 fuite de champ sensible depuis 14 mois (vs 2 leaks par audit CNIL avant), code des controllers tombé de ~25 lignes à ~10 lignes par endpoint (plus de mapToDto), audit CNIL validé sans réserve.

Scénario 3 — PropTech : audit immuable des transactions immobilières

Qui — Une PropTech FR (35 ETP) qui édite un SIRH pour notaires et agents immobiliers. ≈ 60 000 transactions/mois transitent.

Problème métier — Chaque modification d'une transaction (prix, parties, conditions suspensives) doit être audité dans un journal immuable, avec qui/quand/avant/après. Loi Hoguet + jurisprudence stricte. Avant : audit manuel dans chaque service, à 70% incomplet.

Comment ce concept aideAuditTrailInterceptor qui (a) lit l'état pré-modification dans pre, (b) laisse passer le handler, (c) compare état post et émet l'event dans Kafka via tap. Le catchError log aussi les tentatives échouées.

ts
@Injectable()
export class TransactionAuditInterceptor implements NestInterceptor {
  constructor(
    private readonly auditProducer: AuditProducer,
    private readonly transactions: TransactionsService,
  ) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    const handler = ctx.getHandler().name;
    if (!['updateTransaction', 'addCondition', 'updatePrice'].includes(handler)) {
      return next.handle();
    }

    const transactionId = req.params.id;
    return from(this.transactions.findByIdRaw(transactionId)).pipe(
      mergeMap((before) =>
        next.handle().pipe(
          tap({
            next: (after) => this.auditProducer.emit({
              event: `transaction.${handler}`,
              actor: req.user.id,
              transactionId,
              before,
              after,
              timestamp: new Date().toISOString(),
              correlationId: req.correlationId,
            }),
            error: (err) => this.auditProducer.emit({
              event: `transaction.${handler}.failed`,
              actor: req.user.id,
              transactionId,
              error: err.message,
              timestamp: new Date().toISOString(),
            }),
          }),
        ),
      ),
    );
  }
}

Gains chiffrés — Couverture d'audit passée de ~30% manuel à 100% automatique, contrôle Hoguet annuel validé en 2h (vs 2 semaines avant), traçabilité complète permettant de retrouver l'origine de toute modif en < 1 min.

🛠️ Exemple end-to-end

Use case — E-commerce. On bâtit la chaîne d'interceptors pour /api/products : caching tagué par produit/catégorie, envelope { data, meta }, timing + tracing OpenTelemetry, redaction des champs internes dans la réponse, retry contrôlé sur erreurs transientes upstream (catalogue PIM externe).

ts
// src/cache/tagged-cache.service.ts
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class TaggedCacheService {
  constructor(@Inject('REDIS') private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const raw = await this.redis.get(key);
    return raw ? (JSON.parse(raw) as T) : null;
  }

  async setWithTags(key: string, value: any, tags: string[], ttlSec: number): Promise<void> {
    const pipeline = this.redis.pipeline();
    pipeline.set(key, JSON.stringify(value), 'EX', ttlSec);
    for (const tag of tags) {
      pipeline.sadd(`tag:${tag}`, key);
      pipeline.expire(`tag:${tag}`, ttlSec + 60);
    }
    await pipeline.exec();
  }

  async invalidateTag(tag: string): Promise<number> {
    const keys = await this.redis.smembers(`tag:${tag}`);
    if (!keys.length) return 0;
    const pipeline = this.redis.pipeline();
    for (const k of keys) pipeline.del(k);
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
    return keys.length;
  }
}
ts
// src/interceptors/product-cache.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, from, of, mergeMap, tap } from 'rxjs';
import { TaggedCacheService } from '../cache/tagged-cache.service';

@Injectable()
export class ProductCacheInterceptor implements NestInterceptor {
  constructor(private readonly cache: TaggedCacheService) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (req.method !== 'GET') return next.handle();

    const key = `prod:v1:${req.originalUrl}`;
    const tags = this.computeTags(req);

    return from(this.cache.get<any>(key)).pipe(
      mergeMap((hit) => {
        if (hit) return of(hit);
        return next.handle().pipe(
          tap((data) => this.cache.setWithTags(key, data, tags, 600).catch(() => null)),
        );
      }),
    );
  }

  private computeTags(req: any): string[] {
    const tags = ['products'];
    if (req.params?.sku) tags.push(`product:${req.params.sku}`);
    if (req.query?.category) tags.push(`category:${req.query.category}`);
    return tags;
  }
}
ts
// src/interceptors/envelope.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Reflector } from '@nestjs/core';

export const SKIP_ENVELOPE = 'skip_envelope';

@Injectable()
export class EnvelopeInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const skip = this.reflector.getAllAndOverride<boolean>(SKIP_ENVELOPE, [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (skip) return next.handle();

    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(
      map((data) => ({
        data,
        meta: {
          requestId: req.correlationId,
          timestamp: new Date().toISOString(),
          version: 'v1',
        },
      })),
    );
  }
}
ts
// src/interceptors/tracing.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap, finalize } from 'rxjs';
import { trace, SpanStatusCode } from '@opentelemetry/api';

@Injectable()
export class TracingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const klass = ctx.getClass().name;
    const handler = ctx.getHandler().name;
    const tracer = trace.getTracer('nestjs-app');
    const span = tracer.startSpan(`${klass}.${handler}`);
    const req = ctx.switchToHttp().getRequest();
    span.setAttributes({
      'http.method': req.method,
      'http.url': req.originalUrl,
      'user.id': req.user?.id ?? 'anon',
    });

    return next.handle().pipe(
      tap({
        error: (err) => {
          span.recordException(err);
          span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
        },
      }),
      finalize(() => span.end()),
    );
  }
}
ts
// src/interceptors/retry-upstream.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, retry, timer } from 'rxjs';

@Injectable()
export class RetryUpstreamInterceptor implements NestInterceptor {
  intercept(_ctx: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      retry({
        count: 3,
        delay: (error, retryIndex) => {
          const transient = ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error.code);
          if (!transient) throw error;
          return timer(100 * Math.pow(2, retryIndex - 1));
        },
      }),
    );
  }
}
ts
// src/products/products.controller.ts
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ProductsService } from './products.service';

@Controller({ path: 'products', version: '1' })
export class ProductsController {
  constructor(private readonly products: ProductsService) {}

  @Get()
  list(@Query('category') category?: string, @Query('limit') limit = '20') {
    return this.products.search({ category, limit: Number(limit) });
  }

  @Get(':sku')
  bySku(@Param('sku') sku: string) {
    return this.products.findBySku(sku);
  }
}

Le controller ne sait rien des interceptors — pas de @UseInterceptors éparpillé. Tout est branché globalement et ordonné dans AppModule.

ts
// src/products/products.controller-admin.ts
import { Controller, Delete, Param, Post, Body } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SetMetadata } from '@nestjs/common';
import { TaggedCacheService } from '../cache/tagged-cache.service';
import { SKIP_ENVELOPE } from '../interceptors/envelope.interceptor';

@Controller({ path: 'admin/products', version: '1' })
export class ProductsAdminController {
  constructor(
    private readonly products: ProductsService,
    private readonly cache: TaggedCacheService,
  ) {}

  @Post()
  async create(@Body() dto: CreateProductDto) {
    const product = await this.products.create(dto);
    await this.cache.invalidateTag('products');
    return product;
  }

  @Post(':sku/restock')
  async restock(@Param('sku') sku: string, @Body() dto: RestockDto) {
    const result = await this.products.restock(sku, dto);
    await this.cache.invalidateTag(`product:${sku}`);
    return result;
  }
}

Le controller admin invalide explicitement les tags cache après mutation — pattern cache-aside avec tag-based invalidation.

ts
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProductCacheInterceptor } from './interceptors/product-cache.interceptor';
import { EnvelopeInterceptor } from './interceptors/envelope.interceptor';
import { TracingInterceptor } from './interceptors/tracing.interceptor';
import { RetryUpstreamInterceptor } from './interceptors/retry-upstream.interceptor';

@Module({
  providers: [
    // Ordre = ordre du pre-handler ; inverse pour le post-handler
    { provide: APP_INTERCEPTOR, useClass: TracingInterceptor },     // 1. span ouvert en premier
    { provide: APP_INTERCEPTOR, useClass: ProductCacheInterceptor }, // 2. cache check
    { provide: APP_INTERCEPTOR, useClass: RetryUpstreamInterceptor }, // 3. retry autour du handler
    { provide: APP_INTERCEPTOR, useClass: EnvelopeInterceptor },    // 4. envelope appliquée en dernier post-handler
  ],
})
export class AppModule {}

L'ordre des interceptors est crucial : tracing en premier (capture la latence totale), cache ensuite (court-circuite si hit, donc skip les retry et l'envelope), retry autour du handler, envelope en dernier (s'applique sur tout, y compris les hits cache). Le post-handler LIFO fait que EnvelopeInterceptor voit la réponse en premier au retour, puis TracingInterceptor ferme le span avec la latence complète.

🔁 Quand utiliser / éviter

Utiliser un Interceptor :

  • Transformer le résultat du handler (envelope, serialization).
  • Mesurer la durée du handler (vs middleware = durée du round-trip).
  • Cache de réponse, retry, timeout autour du handler.
  • AOP : audit log, tracing OpenTelemetry, transaction wrapping.

Éviter un Interceptor, préférer Middleware :

  • Logique purement HTTP avant pipeline Nest (helmet, raw body parsing).

Éviter un Interceptor, préférer Guard :

  • Refuser l'accès. Un Interceptor qui throw 403 est sémantiquement faux.

Éviter un Interceptor, préférer Pipe :

  • Transformer le payload entrant (DTO validation/coercion).

Éviter un Interceptor, préférer Exception Filter :

  • Mapper toutes les erreurs vers un format unifié. Un Interceptor peut le faire via catchError, mais le Filter est plus idiomatique car déclenché par n'importe quel throw dans la chaîne.

🧰 Exemples avancés

Idempotency interceptor

ts
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(private readonly cache: Cache) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (!['POST', 'PUT', 'PATCH'].includes(req.method)) return next.handle();
    const key = req.headers['idempotency-key'];
    if (!key) return next.handle();

    const cacheKey = `idem:${req.user?.id}:${key}`;
    return from(this.cache.get(cacheKey)).pipe(
      mergeMap((hit) => {
        if (hit) return of(hit);
        return next.handle().pipe(
          tap((data) => this.cache.set(cacheKey, data, 86_400)),
        );
      }),
    );
  }
}

Tracing OpenTelemetry

ts
import { trace, SpanStatusCode } from '@opentelemetry/api';

@Injectable()
export class TracingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const handler = ctx.getHandler().name;
    const klass = ctx.getClass().name;
    const tracer = trace.getTracer('nestjs');
    const span = tracer.startSpan(`${klass}.${handler}`);
    const req = ctx.switchToHttp().getRequest();
    span.setAttributes({
      'http.method': req.method,
      'http.url': req.originalUrl,
      'user.id': req.user?.id,
    });
    return next.handle().pipe(
      tap({
        error: (err) => {
          span.recordException(err);
          span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
        },
      }),
      finalize(() => span.end()),
    );
  }
}

🤖 Servir un agent IA depuis un Interceptor (stack Python + NestJS + Angular)

Quand NestJS devient la gateway entre ton front Angular et un LLM (Claude), l'Interceptor est l'endroit idiomatique pour la mécanique transverse autour d'un appel LLM : cost-guard, idempotency, rate-limit par tenant, propagation de l'annulation client, observabilité du coût en tokens. Le handler reste pur (il appelle le service LLM) ; l'Interceptor enveloppe.

Mental model staff — un appel LLM n'est pas une requête HTTP comme une autre : il est long (10s–plusieurs minutes en streaming), coûteux (facturé au token), non-idempotent (retry aveugle = double facturation), et annulable (le user ferme l'onglet → tu dois couper le upstream sinon tu paies des tokens pour rien). Ces quatre propriétés font de l'Interceptor — phase pre (guard de coût) + phase post (mesure usage) + finalize (cleanup) — l'outil exact.

1) Le client LLM est un provider DI, jamais un new Anthropic() dans un champ

Anti-pattern n°1 : instancier le SDK dans un service (private client = new Anthropic()). Tu perds la testabilité (impossible de mocker), la config par environnement, et les SDK retries partagés. Injecte-le via forRootAsync.

ts
// src/llm/llm.module.ts
import Anthropic from '@anthropic-ai/sdk';
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

export const ANTHROPIC = Symbol('ANTHROPIC');

@Module({})
export class LlmModule {
  static forRootAsync(): DynamicModule {
    return {
      module: LlmModule,
      global: true,
      providers: [
        {
          provide: ANTHROPIC,
          inject: [ConfigService],
          useFactory: (cfg: ConfigService) =>
            new Anthropic({
              apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
              maxRetries: 3, // SDK retries 429/5xx avec backoff exponentiel
            }),
        },
      ],
      exports: [ANTHROPIC],
    };
  }
}

Modèles Anthropic (faits à jour) — flagship claude-opus-4-8, équilibré claude-sonnet-4-6, rapide/éco claude-haiku-4-5. Utiliser l'async/streaming + les retries du SDK. Pour la pensée, thinking: { type: 'adaptive' } (le budget_tokens est retiré sur Opus 4.7/4.8 — il renvoie un 400 ; idem temperature/top_p/top_k). La profondeur se règle via output_config: { effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' }. Toute requête à max_tokens élevé doit streamer (les SDK timeoutent au-delà de ~16K en non-stream ; Sonnet 4.6 plafonne à 64K, Opus 4.8 à 128K).

Pourquoi un client DI et pas un singleton global ? Un new Anthropic() dans un champ couple ta classe au SDK : impossible de mocker en test, impossible de changer la config par environnement (clé prod vs staging), et tu perds le partage de l'instance (connection pool, retries). Le forRootAsync ci-dessus rend le client injectable, testable (overrideProvider(ANTHROPIC).useValue(mockClient)), et configurable. C'est exactement le même raisonnement que pour un DataSource TypeORM ou un Redis client — un staff engineer ne fait jamais new d'un client réseau dans un champ.

2) Cost-guard à l'edge — un Interceptor qui refuse AVANT de brûler des tokens

Phase pre : vérifie le budget tokens/jour du tenant dans Redis. Si dépassé, throw 429 sans jamais toucher le LLM. C'est le cas d'usage canonique du pre-handler.

ts
// src/llm/cost-guard.interceptor.ts
import {
  CallHandler, ExecutionContext, Injectable, NestInterceptor,
  HttpException, HttpStatus,
} from '@nestjs/common';
import { from, mergeMap, tap, Observable } from 'rxjs';

@Injectable()
export class CostGuardInterceptor implements NestInterceptor {
  constructor(private readonly budget: TokenBudgetService) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    const tenant = req.tenantId;

    return from(this.budget.remaining(tenant)).pipe(
      mergeMap((remaining) => {
        if (remaining <= 0) {
          throw new HttpException(
            { error: 'daily_token_budget_exhausted', retryAfter: this.budget.secondsUntilReset() },
            HttpStatus.TOO_MANY_REQUESTS,
          );
        }
        // Le handler retourne { usage: { input_tokens, output_tokens } } dans res.usage
        return next.handle().pipe(
          tap((res) => {
            const used = (res?.usage?.input_tokens ?? 0) + (res?.usage?.output_tokens ?? 0);
            // débit best-effort, ne bloque pas la réponse
            this.budget.debit(tenant, used).catch(() => void 0);
          }),
        );
      }),
    );
  }
}

Pourquoi un Interceptor et pas un Guard ? Le Guard ne voit que le pre — il ne peut pas mesurer l'usage réel en sortie (res.usage) pour débiter le budget. L'Interceptor enveloppe les deux phases : refus en pre, débit en post. C'est précisément ce que le Guard ne sait pas faire.

3) Streaming SSE des tokens — l'Interceptor ne touche PAS au flux, il l'observe

Piège critique : un EnvelopeInterceptor ou un map(...) global casse le streaming (il bufferise le StreamableFile/Observable SSE en JSON). Règle : les routes de streaming LLM doivent skip la transformation, et l'Interceptor se limite à tap/finalize pour l'observabilité.

ts
// Le handler renvoie un Observable<MessageEvent> consommé par @Sse() de Nest
import { Sse, Controller, Param, Req } from '@nestjs/common';
import { Observable } from 'rxjs';

@Controller('agents')
export class AgentController {
  constructor(private readonly agent: AgentService) {}

  @Sse(':conversationId/stream')
  @SkipEnvelope() // décorateur custom — sinon l'envelope bufferise le flux
  stream(@Param('conversationId') id: string, @Req() req): Observable<MessageEvent> {
    return this.agent.streamTokens(id, req); // émet { data: { type, text } }
  }
}
ts
// src/agent/agent.service.ts — boucle de streaming serveur-side avec AbortController
import { Inject, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';

@Injectable()
export class AgentService {
  constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}

  streamTokens(conversationId: string, req: any): Observable<MessageEvent> {
    return new Observable<MessageEvent>((subscriber) => {
      const ac = new AbortController();

      // CRUCIAL : si le client ferme l'onglet, on coupe l'upstream LLM
      // (sinon on continue à payer des tokens pour personne).
      req.on('close', () => ac.abort());

      (async () => {
        try {
          const stream = this.anthropic.messages.stream(
            {
              model: 'claude-sonnet-4-6',
              max_tokens: 64000, // streaming → on peut viser haut
              thinking: { type: 'adaptive' },
              messages: await this.loadHistory(conversationId),
            },
            { signal: ac.signal }, // propage l'annulation au SDK
          );

          stream.on('text', (delta) =>
            subscriber.next({ data: { type: 'token', text: delta } } as MessageEvent),
          );

          const final = await stream.finalMessage();
          subscriber.next({ data: { type: 'done', usage: final.usage } } as MessageEvent);
          subscriber.complete();
        } catch (err: any) {
          if (ac.signal.aborted) return; // annulation propre, pas une erreur
          subscriber.error(err);
        }
      })();

      // teardown de l'Observable = teardown de l'Interceptor `finalize`
      return () => ac.abort();
    });
  }
}

Le lien Interceptor ↔ AbortController — l'Observable RxJS a une teardown function appelée à l'unsubscribe. RxJS timeout(), le req.on('close'), ou un Stop côté Angular qui ferme l'EventSource ⇒ unsubscribe ⇒ teardown ⇒ ac.abort(). C'est le pont entre l'annulation côté client et l'annulation côté serveur (et donc l'arrêt de la facturation). Un staff engineer vérifie systématiquement que ce chemin est branché — c'est la fuite de coût n°1 en prod LLM.

Le piège refusalstream.finalMessage() peut renvoyer stop_reason === 'refusal' (les classifieurs de sécurité ont décliné). C'est un succès HTTP 200, pas une exception. Lire final.content[0].text sans vérifier stop_reason d'abord plante sur un content vide (refus pré-output, non facturé) ou expose un partiel (refus mid-stream, facturé — à jeter). Toujours brancher stop_reason AVANT de lire content. Le même Interceptor d'observabilité doit logger ces refus comme une métrique métier distincte (taux de refus par tenant), pas comme une erreur 5xx.

3bis) La boucle agentique tool-use côté serveur — l'Interceptor borne, le service boucle

Quand l'agent appelle des outils (function-calling), le handler exécute une boucle : messages.createstop_reason === 'tool_use' → exécute les outils → renvoie les tool_result → recommence jusqu'à end_turn. Cette boucle peut faire N appels LLM. L'Interceptor ne boucle pas — il borne la boucle (timeout global, budget tokens cumulé, AbortController propagé à chaque itération) et observe l'usage total en sortie.

ts
// src/agent/agent.service.ts — boucle tool-use bornée par un AbortSignal injecté par l'Interceptor
async runToolLoop(messages: Anthropic.MessageParam[], signal: AbortSignal) {
  const tools: Anthropic.Tool[] = [/* tes outils typés */];
  let totalUsage = { input: 0, output: 0 };

  for (let turn = 0; turn < 10; turn++) {
    if (signal.aborted) throw new Error('aborted'); // l'Interceptor a abort() en finalize
    const res = await this.anthropic.messages.create(
      {
        model: 'claude-opus-4-8',
        max_tokens: 16000,
        thinking: { type: 'adaptive' },
        tools,
        messages,
      },
      { signal }, // propage l'annulation au SDK à CHAQUE itération
    );
    totalUsage.input += res.usage.input_tokens;
    totalUsage.output += res.usage.output_tokens;

    if (res.stop_reason === 'refusal') return { refused: true, usage: totalUsage };
    if (res.stop_reason !== 'tool_use') return { content: res.content, usage: totalUsage };

    messages.push({ role: 'assistant', content: res.content });
    const toolResults = await Promise.all(
      res.content
        .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
        .map(async (b) => ({
          type: 'tool_result' as const,
          tool_use_id: b.id,
          content: await this.execTool(b.name, b.input), // tes outils internes
        })),
    );
    messages.push({ role: 'user', content: toolResults });
  }
  throw new Error('max_turns_exceeded'); // garde-fou anti-boucle infinie
}

Mental model staff — la boucle agentique transforme une requête HTTP en N appels LLM facturés. Le max_turns (ici 10) est un garde-fou anti-boucle-infinie ; le signal propagé à chaque itération est ce qui rend l'annulation client efficace même au tour 7. L'Interceptor CostGuard débite alors totalUsage, pas l'usage d'une seule itération. C'est précisément ce que res.usage d'un seul appel ne capture pas.

4) BullMQ pour les jobs IA longs — idempotency par generationId, retry cost-aware

Pour un agent qui tourne plusieurs minutes (génération de rapport, batch), tu ne streames pas dans la requête HTTP : tu enqueues. Un Interceptor peut faire l'idempotency à l'edge (header Idempotency-KeygenerationId stable) pour qu'un double-POST (retry réseau du front, double-clic) ne lance pas deux générations facturées.

ts
// src/agent/enqueue.interceptor.ts
@Injectable()
export class EnqueueIdempotencyInterceptor implements NestInterceptor {
  constructor(private readonly cache: Cache) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (req.method !== 'POST') return next.handle();

    const key = req.headers['idempotency-key'];
    if (!key) return next.handle();
    const cacheKey = `gen:${req.tenantId}:${key}`;

    return from(this.cache.get(cacheKey)).pipe(
      mergeMap((existing) =>
        existing
          ? of(existing) // déjà enqueué → renvoie le même generationId
          : next.handle().pipe(
              tap((res) => this.cache.set(cacheKey, res, 86_400)), // 24h
            ),
      ),
    );
  }
}

Côté worker, les règles de production d'un job IA :

PréoccupationRègle
IdempotencyLa clé du job = generationId (déterministe), pas un UUID random à chaque enqueue. Sinon retry = doublon facturé.
Retry cost-awareNe retry que les erreurs transientes (overloaded_error 529, rate_limit_error 429, réseau). Un 400 invalid_request ou un refusal ne se retry jamais — c'est de l'argent jeté.
Partial-outputEn cas d'échec mid-stream, persiste les tokens déjà reçus (checkpoint) ; au retry, reprends ou repars proprement — ne resoumets pas l'intégralité si tu peux éviter.
refusalstop_reason === 'refusal' est un succès HTTP 200 mais un échec métier. Branche-le explicitement : ne lis pas content[0] sans vérifier stop_reason d'abord.

5) Exposer un endpoint MCP / agent

Si NestJS doit exposer ses propres outils à un agent (pattern tool-use serveur-side), l'Interceptor sert de couche d'auth + rate-limit + audit autour des invocations d'outils, exactement comme pour n'importe quelle route — la différence est que la latence/coût se mesurent en tokens, pas en ms. Le TracingInterceptor ci-dessus s'enrichit d'un attribut gen_ai.usage.input_tokens / output_tokens (convention OpenTelemetry GenAI) pour corréler coût et trace.

🏋️ Exercices

Progression : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.

Exercice 1 — Envelope opt-out (implémenter)

Objectif — Écrire un EnvelopeInterceptor global qui wrappe { data, meta } SAUF sur les routes décorées @SkipEnvelope() et SAUF les StreamableFile/SSE. Indice/SolutionReflector.getAllAndOverride(SKIP_ENVELOPE, [getHandler, getClass]) pour le décorateur ; détecter le stream avec data instanceof StreamableFile (et passer next.handle() tel quel pour les @Sse()). Tester qu'un download de fichier ne reçoit PAS de JSON.

Exercice 2 — Timeout qui annule vraiment (production-grade)

Objectif — Le timeout(ms) RxJS arrête le subscribe mais ne tue pas le handler async (fuite de ressources). Faire en sorte qu'un timeout propage un AbortController jusqu'à un fetch/SDK upstream. Indice/Solution — Crée l'AbortController dans intercept, mets req.abortController = ac, et dans finalize(() => ac.abort()). Le handler lit req.abortController.signal et le passe à l'appel upstream. Vérifie avec un faux upstream qui log s'il continue après le timeout.

Exercice 3 — Cost-guard multi-tenant (production-grade)

Objectif — Implémenter le CostGuardInterceptor de la section IA : budget tokens/jour par tenant dans Redis, refus 429 avec Retry-After en pre, débit du res.usage réel en post. Indice/Solutionfrom(budget.remaining(tenant))mergeMap → throw ou next.handle().pipe(tap(débit)). Attention : le débit doit être best-effort (.catch(() => void 0)) pour ne pas faire échouer une réponse déjà calculée. Bonus : utilise un script Lua Redis pour remaining + débit atomique et éviter la race entre deux requêtes concurrentes du même tenant.

Exercice 4 — Streaming + Stop bouton (production-grade, full-stack)

Objectif — Route @Sse() qui streame des tokens Claude ; côté serveur, req.on('close')ac.abort(). Prouver que fermer l'EventSource côté client coupe l'appel SDK serveur (donc la facturation). Indice/Solutionnew Observable(subscriber => { const ac = ...; req.on('close', () => ac.abort()); ...; return () => ac.abort() }). Log côté serveur le nombre de tokens reçus avant abort. Test : EventSource.close() après 3 tokens → le serveur doit s'arrêter à ~3 tokens, pas générer la réponse complète.

Exercice 5 — Casser puis réparer : cache poisoning + ordre LIFO

Objectif — Introduire deux bugs réalistes, observer le symptôme, corriger. Indice/Solution — (a) Cache poisoning : retire user.id/tenant.id de la clé du CacheInterceptor → user B reçoit la réponse de user A. Corrige en ajoutant l'identité à la clé. (b) Ordre LIFO : enregistre Logging puis Envelope puis change l'ordre → le log mesure la latence AVANT ou APRÈS l'envelope selon la position. Démontre par un test que le post-handler est LIFO et fixe l'ordre dans AppModule.

Exercice 6 — Retry idempotent sur job BullMQ IA (casser puis réparer)

Objectif — Montrer qu'un retry aveugle d'un POST de génération crée un doublon facturé, puis le rendre idempotent. Indice/Solution — Sans Idempotency-Key, simule un double-POST → deux generationId → deux appels Claude facturés. Ajoute le EnqueueIdempotencyInterceptor (clé = gen:${tenant}:${key}) → le second POST renvoie le même generationId. Bonus : filtre le retry worker pour ne PAS retry un stop_reason === 'refusal' ni un 400.

🎤 En entretien

Q : Interceptor, Middleware, Guard, Pipe, Exception Filter — comment tu choisis ? Donne la frontière exacte. R : Middleware = avant le pipeline Nest (raw body, helmet), ne voit pas le contexte d'exécution typé. Guard = autoriser/refuser l'accès (retourne un booléen, pre uniquement). Pipe = transformer/valider le payload entrant. Interceptor = AOP autour du handler (pre + post + RxJS), transforme le résultat, mesure la durée du handler. Exception Filter = mapper les erreurs vers un format unifié. Règle : si je dois lire la réponse du handler (envelope, usage tokens, cache), c'est un Interceptor ; si je refuse l'accès, c'est un Guard même si techniquement l'Interceptor pourrait throw.

Q : Le timeout() RxJS dans un Interceptor garantit-il que le handler s'arrête ? R : Non. timeout() se désabonne (unsubscribe) de l'Observable et émet une TimeoutError, mais le handler async côté Node continue son exécution jusqu'au bout — fuite de ressources et, pour un appel LLM, facturation continue. Pour une vraie annulation il faut propager un AbortController via le contexte/request et l'abort() dans finalize, et que l'upstream (fetch, SDK Anthropic via { signal }) respecte ce signal.

Q : Pourquoi le post-handler des interceptors est-il en LIFO, et quand ça compte ? R : Les interceptors s'empilent : pre dans l'ordre d'enregistrement, post en ordre inverse (le dernier entré est le premier à voir la réponse au retour, comme une pile d'appels). Ça compte dès qu'on combine logging + envelope + tracing : si Tracing est enregistré en premier, il ouvre le span en premier (pre) et le ferme en dernier (post finalize) → il capture la latence totale, y compris le travail des interceptors plus internes. Inverser l'ordre fausse la mesure.

Q : Tu sers un LLM derrière NestJS. Quels sont les pièges qu'un Interceptor doit couvrir et que le code naïf rate ? R : Quatre. (1) Annulation : req.on('close')AbortController.abort() propagé au SDK, sinon on paie des tokens après que le user soit parti. (2) Idempotency : clé stable (generationId) sur les POST de génération, sinon un retry réseau double-facture. (3) Cost-guard : refuser en pre selon un budget tenant et débiter le res.usage réel en post — un Guard ne sait pas faire le post. (4) refusal : stop_reason === 'refusal' est un 200 mais un échec métier ; ne jamais retry ni lire content[0] avant d'avoir vérifié stop_reason. Et le client LLM doit être un provider DI (forRootAsync), pas un new Anthropic() dans un champ.

Q : Une route agentique (tool-use) fait N appels LLM dans une seule requête HTTP. Comment ton Interceptor mesure-t-il le coût et borne-t-il l'exécution ? R : L'Interceptor ne boucle pas — la boucle (stop_reason === 'tool_use' → exécute outils → tool_result → recommence) vit dans le service. L'Interceptor fait trois choses autour : (1) un timeout() ou un AbortController en finalize propagé via req au signal de chaque itération messages.create, sinon un Stop client au tour 7 ne coupe rien ; (2) en post, débiter l'usage cumulé (totalUsage) renvoyé par le service, pas l'usage d'un seul appel — c'est l'erreur classique ; (3) un garde-fou max_turns côté service contre la boucle infinie. Le tap ne voit que la valeur finale émise, donc le service doit remonter totalUsage dans sa réponse pour que l'Interceptor puisse débiter le budget tenant correctement.

🔗 Liens

Bibliothèque tech perso — Achref