Skip to content

Error handling — domain vs HTTP, propagation, envelopes

TL;DR — Sépare les erreurs domaine (logique métier : OrderAlreadyPaid, InsufficientStock) des erreurs HTTP (BadRequestException, NotFoundException). Ne mélange jamais. Une couche de mapping unique traduit domaine → HTTP. Utilise problem+json (RFC 9457) pour les réponses. Jette pour les exceptions exceptionnelles, retourne un Result pour les erreurs prévues sur les hot paths. Et surtout : never eat errors — un catch {} muet est un bug latent.

🧠 Mental model — ASCII diagram + analogy

   Controller layer (HTTP)
        │ throws HttpException ◄────┐
        │                            │ mapped here
        ▼                            │
   Domain service                    │
        │ throws DomainError ────────┘


   Repository / Gateway
        │ throws InfraError (DB down, timeout)

        [logs + retry policy + dead letter]

Analogie : ton domaine est une cuisine. Si le poisson est rance (StaleFishError), le chef ne dit pas "HTTP 422" à la salle — il dit "rance" en interne. Le maître d'hôtel (controller/filter) traduit pour le client : "Désolé, plat indisponible, code 422". Un cuisinier qui parle HTTP au client = couplage merdique, refactoring impossible.

Sous-règles :

  • Erreurs attendues (validation, business rules) → exceptions typées dans le domaine, mappées HTTP au bord.
  • Erreurs inattendues (bug, DB hors ligne) → bubble up, capturées par un ExceptionFilter global qui répond 500 et alerte.
  • Result<T, E> sur les opérations qui échouent souvent par design (parsing, hot path) — évite le coût d'unwind de pile.

Comment un staff engineer raisonne là-dessus

La vraie décision n'est pas "exception vs Result" — c'est où vit la connaissance du status HTTP. Un junior met throw new BadRequestException() dans le service. Un senior considère que le service ne sait pas qu'il parle HTTP : il pourrait être appelé par un consumer Kafka, un cron, un resolver GraphQL, un worker BullMQ, ou un endpoint MCP qui sert un agent. Le service jette un DomainError ; une et une seule couche d'adaptation (filter HTTP, error-mapper GraphQL, handler de job) traduit vers le protocole de sortie. C'est l'hexagonal architecture appliquée aux erreurs : le domaine est au centre, les adaptateurs au bord.

Trois canaux de propagation, à choisir par fréquence et frontière :

CanalQuandCoûtVisibilité dans la signatureFailure mode si abusé
throw DomainErrorErreur attendue mais rare sur ce chemin (paiement refusé, conflit)Unwind de pile + capture stack (~µs)Invisible (Promise<T> ment)Exceptions utilisées comme control-flow → spaghetti, perf
Result<T, E>Erreur fréquente / sur hot path (parsing, lookup optionnel, validation en masse)Aucun unwind, juste un objetExplicite (Promise<Result<T, E>>)Verbosité, if (!r.ok) partout sans helper
throw InfraError (wrap)Frontière I/O (DB, HTTP sortant, LLM)Idem throw + { cause }InvisibleOublier le wrap → QueryFailedError TypeORM fuit dans le domaine

Règle de cohérence : décide par module, pas par fonction. Un module qui mélange Result et throw pour ses erreurs métier est illisible — l'appelant ne sait jamais s'il doit try ou if (!r.ok).

Mental model du status code comme contrat d'alerting, pas comme cosmétique : un OrderAlreadyPaid mappé en 500 va réveiller l'astreinte à 3h du matin pour un non-bug. Le mapping domaine→HTTP est aussi un mapping erreur→SLO : les 4xx ne comptent pas dans ton taux d'erreur serveur, les 5xx oui. Mal mapper, c'est polluer tes métriques et brûler ton error budget pour rien.

🛠️ Code minimal

Hiérarchie d'erreurs domaine

ts
// errors/domain-error.ts
export abstract class DomainError extends Error {
  abstract readonly code: string;
  abstract readonly status: number;
  constructor(message: string, public readonly context?: Record<string, unknown>) {
    super(message);
    this.name = this.constructor.name;
  }
}

export class NotFoundError extends DomainError {
  readonly code = 'not_found';
  readonly status = 404;
}

export class ConflictError extends DomainError {
  readonly code = 'conflict';
  readonly status = 409;
}

export class ValidationError extends DomainError {
  readonly code = 'validation_failed';
  readonly status = 422;
}

// errors/order-errors.ts
export class OrderAlreadyPaidError extends ConflictError {
  readonly code = 'order_already_paid';
  constructor(orderId: string) {
    super(`Order ${orderId} already paid`, { orderId });
  }
}

Global exception filter → problem+json

ts
// filters/all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { DomainError } from '../errors/domain-error';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const req = host.switchToHttp().getRequest();
    const traceId = req.headers['x-trace-id'];

    let status = 500;
    let body: Record<string, unknown> = {
      type: 'about:blank',
      title: 'Internal Server Error',
      status: 500,
      traceId,
    };

    if (exception instanceof DomainError) {
      status = exception.status;
      body = {
        type: `https://errors.example.com/${exception.code}`,
        title: exception.message,
        status,
        code: exception.code,
        ...exception.context,
        traceId,
      };
    } else if (exception instanceof HttpException) {
      status = exception.getStatus();
      const r = exception.getResponse();
      body = typeof r === 'object' ? { ...r, status, traceId } : { title: String(r), status, traceId };
    } else if (exception instanceof Error) {
      this.logger.error({ err: exception, traceId }, 'Unhandled exception');
    }

    res.status(status).type('application/problem+json').send(body);
  }
}

Bind global

ts
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

Result pattern (pour les hot paths)

ts
// shared/result.ts
export type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });

// usage
async function parseTransaction(raw: string): Promise<Result<Tx, ParseError>> {
  try {
    return Ok(JSON.parse(raw));
  } catch (e) {
    return Err(new ParseError('invalid_json', { raw }));
  }
}

Map domain → HTTP via interceptor (variante)

Si tu préfères au lieu du filter (pour tester chaque domaine isolément) :

ts
@Injectable()
export class DomainErrorInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      catchError((err) => {
        if (err instanceof DomainError) {
          throw new HttpException({ code: err.code, message: err.message, ...err.context }, err.status);
        }
        throw err;
      }),
    );
  }
}

🎯 Patterns courants

  1. Erreur typée + code stablecode: 'order_already_paid' est un contrat. Le client peut switcher dessus. Le message peut changer librement.
  2. Problem+JSON (RFC 9457) — format standard avec type, title, status, detail, instance. Adopté par .NET, supporté par plein d'outils.
  3. Wrap repository errorsDbConnectionError extends InfraError. Le service métier ne voit jamais un QueryFailedError TypeORM. Couplage cassé.
  4. Retry à la frontière — pour 5xx infra, retry avec backoff. Pour erreurs domaine (4xx), pas de retry — c'est la faute du client.
  5. Dead letter queue — pour les messages async qui échouent N fois, push sur une DLQ avec contexte. Tu inspectes, replay manuellement.
  6. Logger une fois — log dans le filter global, pas dans chaque catch intermédiaire. Sinon tu vois 5 lignes pour la même erreur.

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

  • 7 : HttpException simple, pas de status dans getResponse().
  • 8 : HttpStatus enum exhaustif. ExceptionFilter typage amélioré.
  • 9 : @Catch() sans argument capture tout (avant fallait @Catch(Error)).
  • 10 : meilleur support des erreurs async dans les guards/interceptors. await dans canActivate correctement propagé.
  • 11 : cause (ES2022) propagé dans les exceptions Nest. Tu peux faire throw new BadRequestException('foo', { cause: originalErr }) et le filter voit la chaîne. Stack traces enrichies.

Côté Node : depuis 16+, Error.cause natif. Depuis 20+, --unhandled-rejections=strict par défaut → un Promise rejected non géré crashe le process. Avant : warning silencieux. Plus de "le service tourne mais répond bizarre".

⚠️ Pitfalls

  1. catch (e) {} muet — la pire erreur. Au minimum log + relance, ou commente pourquoi tu avales (rare).
  2. throw new Error('message') sans type — perd toute info pour le filter. Hierarchie sinon instanceof ne marche pas (regarde la name en fallback).
  3. Erreurs async non awaitservice.foo() sans await dans un controller : la promise rejected sort du flux Nest, devient unhandledRejection. ESLint @typescript-eslint/no-floating-promises obligatoire.
  4. Mélange Result et throw dans un même service — incohérent. Décide par module : tout Result ou tout throw.
  5. Exposer la stack trace en prod — fuite d'info (chemins, lib versions). Filter doit retourner detail générique, log la stack côté serveur.
  6. message instable — si ton client front matche sur la string du message, un i18n casse tout. Toujours un code machine-readable.
  7. HTTP 500 pour des erreurs métierOrderAlreadyPaid n'est pas un bug, c'est un 409. Mauvais status = mauvais alerting, mauvaise UX.
  8. Filter qui swallow l'erreur sans la logger — silent failure. Toujours logger les 5xx, jamais swallow.

🧪 Testing

ts
it('maps OrderAlreadyPaidError to 409 + problem+json', async () => {
  jest.spyOn(orderService, 'pay').mockRejectedValue(new OrderAlreadyPaidError('o1'));
  const res = await request(app.getHttpServer()).post('/orders/o1/pay').expect(409);
  expect(res.headers['content-type']).toContain('application/problem+json');
  expect(res.body.code).toBe('order_already_paid');
  expect(res.body.orderId).toBe('o1');
});
  • Unit test sur le domaine : expect(() => service.pay(paidOrder)).toThrow(OrderAlreadyPaidError).
  • Property-based : fast-check génère des inputs aléatoires, vérifie qu'aucun ne fait crasher en 500 (tout doit être attrapé proprement).

🎬 Cas d'usage concrets

Scénario 1 — Erreurs domaine bancaires mappées en RFC 9457

Qui : core banking d'une néobanque PME. Domaine riche : AccountFrozen, InsufficientFunds, DailyLimitExceeded, KycPending, BeneficiaryNotWhitelisted, etc. Problème : le code historique jetait BadRequestException('Insufficient funds') partout. Les apps mobile et web matchaient sur les messages en français, et un i18n a tout cassé. La direction veut un code stable, un format standard, et un mapping clair entre erreurs domaine et codes HTTP.

ts
// errors/banking.errors.ts
export class InsufficientFundsError extends DomainError {
  readonly code = 'insufficient_funds';
  readonly status = 422;
  constructor(accountId: string, requested: number, available: number) {
    super(`Account ${accountId} has ${available} available, ${requested} requested`, {
      accountId, requested, available,
    });
  }
}

export class DailyLimitExceededError extends DomainError {
  readonly code = 'daily_limit_exceeded';
  readonly status = 422;
  constructor(accountId: string, limit: number, attemptedTotal: number) {
    super(`Daily limit ${limit} would be exceeded`, { accountId, limit, attemptedTotal });
  }
}

export class BeneficiaryNotWhitelistedError extends DomainError {
  readonly code = 'beneficiary_not_whitelisted';
  readonly status = 403;
  constructor(beneficiaryIban: string) {
    super('Beneficiary must be added and validated first', { beneficiaryIban: mask(beneficiaryIban) });
  }
}

// filter renvoie problem+json
// { "type": "https://errors.bank.example/insufficient_funds",
//   "title": "Account ... has 12.50 available, 100.00 requested",
//   "status": 422,
//   "code": "insufficient_funds",
//   "accountId": "...", "requested": 100, "available": 12.50,
//   "traceId": "..." }

Gains : les apps front matchent désormais sur code === 'insufficient_funds', indépendant des messages traduits. L'app mobile affiche un écran spécifique (proposition de découvert) pour ce code, et un message générique pour les autres. Le compliance team reconnaît beneficiary_not_whitelisted en clair dans les logs ACPR.

Scénario 2 — Erreurs typées dans un onboarding RH multi-étapes

Qui : SaaS RH qui gère l'onboarding d'employés (contrat → coordonnées bancaires → mutuelle → URSSAF). Chaque étape peut échouer pour des raisons spécifiques. Problème : le service OnboardingService.advance(step) retournait { success: false, error: 'something' } sans typage. L'UI ne savait pas quoi afficher, le support recevait "j'ai une erreur" sans contexte.

ts
// errors/onboarding.errors.ts
export class IbanInvalidError extends ValidationError {
  readonly code = 'iban_invalid';
  constructor(iban: string) { super('IBAN failed mod-97 check', { iban: mask(iban) }); }
}

export class UrssafEnrollmentFailedError extends DomainError {
  readonly code = 'urssaf_enrollment_failed';
  readonly status = 502;
  constructor(public readonly externalReason: string, public readonly retryAfter?: number) {
    super(`URSSAF rejected enrollment: ${externalReason}`, { externalReason, retryAfter });
  }
}

export class MutuelleAlreadyEnrolledError extends ConflictError {
  readonly code = 'mutuelle_already_enrolled';
  constructor(employeeId: string, mutuelleProvider: string) {
    super('Employee already enrolled with this provider', { employeeId, mutuelleProvider });
  }
}

// service usage
async advance(employeeId: string, step: OnboardingStep) {
  if (step === 'banking') {
    const iban = await this.iban.get(employeeId);
    if (!isValidIban(iban)) throw new IbanInvalidError(iban);
  }
  if (step === 'urssaf') {
    try {
      await this.urssaf.enroll(employeeId);
    } catch (e) {
      if (e instanceof UrssafApiError) {
        throw new UrssafEnrollmentFailedError(e.code, e.retryAfter);
      }
      throw e;
    }
  }
}

Gains : l'UI affiche un message + une action spécifique par code (iban_invalid → champ surligné, urssaf_enrollment_failed → retry plus tard avec date). Le retry pour urssaf_enrollment_failed est planifié automatiquement avec backoff (champ retryAfter). Le support voit dans Sentry un groupement par code, plus une longue liste de messages français.

Scénario 3 — Workflow juridique avec erreurs métier riches

Qui : LegalTech qui automatise la création de SAS (statuts, dépôt greffe, BODACC, INSEE). Workflow de 12 étapes avec validation et calls externes (Infogreffe, INPI). Problème : un échec à l'étape 8 (dépôt greffe) pouvait laisser le user dans un état inconsistant — statuts générés, BODACC publié, mais pas de KBIS. L'erreur générique masquait la cause exacte.

ts
// errors/sas-creation.errors.ts
export class SiretAlreadyTakenError extends ConflictError {
  readonly code = 'siret_already_taken';
  constructor(siret: string) { super(`SIRET ${siret} already registered`, { siret }); }
}

export class GreffeRejectedError extends DomainError {
  readonly code = 'greffe_rejected';
  readonly status = 422;
  constructor(public readonly reasonCode: string, public readonly remediation: string) {
    super(`Greffe rejected: ${reasonCode}`, { reasonCode, remediation });
  }
}

export class CapitalInsufficientError extends ValidationError {
  readonly code = 'capital_insufficient';
  constructor(provided: number, minimum: number) {
    super(`Capital ${provided} EUR < minimum ${minimum} EUR`, { provided, minimum });
  }
}

export class ShareholderKycPendingError extends DomainError {
  readonly code = 'shareholder_kyc_pending';
  readonly status = 409;
  constructor(public readonly shareholderId: string) {
    super('Shareholder KYC must be completed before proceeding', { shareholderId });
  }
}

Gains : l'UI guide l'utilisateur pas à pas — capital_insufficient ouvre un modal avec le montant minimum légal, greffe_rejected propose la remédiation textuelle envoyée par le greffe, shareholder_kyc_pending redirige vers la fiche du shareholder. Le taux de complétion du workflow est passé de 62% à 84% en 3 mois.

🛠️ Exemple end-to-end

Mise en situation : tu implémentes un service de paiement par virement dans une néobanque. Tu veux : hiérarchie d'erreurs domaine claire, un filter global qui produit du application/problem+json conforme RFC 9457 (la révision qui obsolète RFC 7807, même format), des logs propres (une seule ligne par erreur), un test e2e qui prouve le mapping, et la propagation du traceId dans la réponse.

ts
// src/shared/errors/domain-error.ts
export abstract class DomainError extends Error {
  abstract readonly code: string;
  abstract readonly status: number;
  constructor(message: string, public readonly context?: Record<string, unknown>) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace?.(this, this.constructor);
  }
}
export abstract class InfraError extends Error {
  abstract readonly code: string;
}
ts
// src/payments/errors/payment.errors.ts
import { DomainError } from '../../shared/errors/domain-error';

export class PaymentInsufficientFundsError extends DomainError {
  readonly code = 'payment_insufficient_funds';
  readonly status = 422;
  constructor(accountId: string, requested: number, available: number) {
    super(`Insufficient funds`, { accountId, requested, available });
  }
}
export class PaymentAccountFrozenError extends DomainError {
  readonly code = 'payment_account_frozen';
  readonly status = 403;
  constructor(accountId: string, since: Date) { super('Account is frozen', { accountId, since }); }
}
export class PaymentBeneficiaryUnverifiedError extends DomainError {
  readonly code = 'payment_beneficiary_unverified';
  readonly status = 409;
  constructor(beneficiaryId: string) { super('Beneficiary not verified yet', { beneficiaryId }); }
}
export class PaymentDailyLimitExceededError extends DomainError {
  readonly code = 'payment_daily_limit_exceeded';
  readonly status = 422;
  constructor(limitEur: number, usedEur: number) { super('Daily limit exceeded', { limitEur, usedEur }); }
}
export class PaymentIdempotencyConflictError extends DomainError {
  readonly code = 'payment_idempotency_conflict';
  readonly status = 409;
  constructor(key: string) { super('Same idempotency key with different payload', { key }); }
}
ts
// src/shared/filters/problem-json.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { DomainError } from '../errors/domain-error';

@Catch()
export class ProblemJsonFilter implements ExceptionFilter {
  private readonly logger = new Logger(ProblemJsonFilter.name);
  private readonly errorBaseUrl = process.env.ERROR_BASE_URL ?? 'https://errors.bank.example';

  catch(exception: unknown, host: ArgumentsHost) {
    const http = host.switchToHttp();
    const res = http.getResponse();
    const req = http.getRequest();
    const traceId = req.headers['x-trace-id'] ?? req.traceId;

    if (exception instanceof DomainError) {
      const body = {
        type: `${this.errorBaseUrl}/${exception.code}`,
        title: exception.message,
        status: exception.status,
        code: exception.code,
        instance: req.url,
        traceId,
        ...exception.context,
      };
      res.status(exception.status).type('application/problem+json').send(body);
      return;
    }

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const r = exception.getResponse();
      const body = typeof r === 'object' ? { ...(r as object), status, instance: req.url, traceId }
                                          : { title: String(r), status, instance: req.url, traceId };
      res.status(status).type('application/problem+json').send(body);
      return;
    }

    // unexpected
    this.logger.error({ err: exception, traceId, path: req.url }, 'unhandled exception');
    res.status(500).type('application/problem+json').send({
      type: `${this.errorBaseUrl}/internal_server_error`,
      title: 'Internal Server Error',
      status: 500,
      instance: req.url,
      traceId,
    });
  }
}
ts
// src/payments/payments.service.ts
import { Injectable } from '@nestjs/common';
import {
  PaymentInsufficientFundsError, PaymentAccountFrozenError,
  PaymentBeneficiaryUnverifiedError, PaymentDailyLimitExceededError,
  PaymentIdempotencyConflictError,
} from './errors/payment.errors';

@Injectable()
export class PaymentsService {
  constructor(
    private readonly accounts: AccountsRepository,
    private readonly beneficiaries: BeneficiariesRepository,
    private readonly limits: LimitsService,
    private readonly idempotency: IdempotencyStore,
    private readonly ledger: LedgerService,
  ) {}

  async execute(input: ExecutePaymentInput) {
    // 1. idempotency check
    // ⚠️ Ce lookup/store en deux temps est lisible mais c'est une TOCTOU : deux requêtes
    //    concurrentes avec la même clé passent toutes deux le lookup avant le store → double virement.
    //    En prod, claim la clé ATOMIQUEMENT (INSERT ... ON CONFLICT / SET NX) avant d'exécuter.
    //    Voir l'exercice 8. On garde la version pédagogique ici pour la lisibilité du flux.
    const existing = await this.idempotency.lookup(input.idempotencyKey);
    if (existing) {
      if (!sameRequest(existing.input, input)) throw new PaymentIdempotencyConflictError(input.idempotencyKey);
      return existing.result;
    }

    // 2. domain validations
    const source = await this.accounts.get(input.fromAccountId);
    if (source.frozen) throw new PaymentAccountFrozenError(source.id, source.frozenSince!);
    if (source.balanceEur < input.amountEur) {
      throw new PaymentInsufficientFundsError(source.id, input.amountEur, source.balanceEur);
    }

    const beneficiary = await this.beneficiaries.get(input.beneficiaryId);
    if (beneficiary.status !== 'verified') {
      throw new PaymentBeneficiaryUnverifiedError(beneficiary.id);
    }

    const dailyUsed = await this.limits.getDailyUsage(source.id);
    if (dailyUsed + input.amountEur > source.dailyLimitEur) {
      throw new PaymentDailyLimitExceededError(source.dailyLimitEur, dailyUsed);
    }

    // 3. execute
    const result = await this.ledger.transact(input);
    await this.idempotency.store(input.idempotencyKey, input, result);
    return result;
  }
}
ts
// test/payments.e2e-spec.ts
describe('Payments — error mapping (e2e)', () => {
  it('maps insufficient funds to 422 problem+json', async () => {
    const res = await request(app.getHttpServer())
      .post('/payments')
      .set('Authorization', `Bearer ${userToken}`)
      .set('Idempotency-Key', 'k-1')
      .send({ fromAccountId: 'acc_poor', beneficiaryId: 'bene_1', amountEur: 10_000 })
      .expect(422);

    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body).toMatchObject({
      type: expect.stringContaining('payment_insufficient_funds'),
      code: 'payment_insufficient_funds',
      status: 422,
      accountId: 'acc_poor',
      requested: 10_000,
      available: expect.any(Number),
    });
    expect(res.body.traceId).toBeDefined();
  });

  it('maps frozen account to 403', async () => {
    const res = await request(app.getHttpServer())
      .post('/payments').set('Idempotency-Key', 'k-2').send({ fromAccountId: 'acc_frozen', beneficiaryId: 'bene_1', amountEur: 50 })
      .expect(403);
    expect(res.body.code).toBe('payment_account_frozen');
  });

  it('returns 409 with same key + different payload', async () => {
    await request(app.getHttpServer()).post('/payments').set('Idempotency-Key', 'k-3').send({ fromAccountId: 'acc_ok', beneficiaryId: 'bene_1', amountEur: 100 }).expect(201);
    const res = await request(app.getHttpServer()).post('/payments').set('Idempotency-Key', 'k-3').send({ fromAccountId: 'acc_ok', beneficiaryId: 'bene_1', amountEur: 999 }).expect(409);
    expect(res.body.code).toBe('payment_idempotency_conflict');
  });
});

Effets concrets : le front affiche un message spécifique par code (proposition de découvert pour payment_insufficient_funds, redirection KYC pour payment_beneficiary_unverified). Les logs montrent une seule ligne par requête (le filter loggue, le service ne loggue pas). Le traceId dans le problem+json permet au support de remonter directement à la trace Tempo. Aucun stack trace n'est exposé au client en prod.

🏭 Production concerns — observabilité, sécurité, scale

Le code "marche" en dev. En prod, l'error handling devient un système de signalisation. Ce qu'un staff engineer câble dès le départ :

Observabilité — l'erreur doit être actionnable

  • Logger une fois, au bord. Le filter global est le seul endroit qui loggue les 5xx. Tout catch intermédiaire qui re-loggue produit du bruit corrélé (5 lignes pour 1 incident) et fausse les compteurs d'alerting.
  • Structured logging, pas de string. logger.error({ err, traceId, code, userId, path }, 'unhandled') — pas logger.error('payment failed: ' + e.message). Tu veux pouvoir filtrer code='payment_insufficient_funds' AND env='prod' dans Loki/Datadog.
  • code = clé de groupement. Sentry/Datadog groupent par code machine-readable, pas par message i18n. Sans code, chaque message traduit devient un "issue" distinct → fingerprinting cassé.
  • Propagation du contexte de trace. Le traceId (ou traceparent W3C) doit traverser : header entrant → AsyncLocalStorage → log → réponse problem+json. Le support colle le traceId du client dans Tempo/Jaeger et voit la trace complète. Sans ça, "j'ai une erreur" est un ticket non-débogable.
ts
// shared/trace-context.ts — propage le traceId sans le passer en argument partout
import { AsyncLocalStorage } from 'node:async_hooks';

export const traceStore = new AsyncLocalStorage<{ traceId: string }>();
export const currentTraceId = () => traceStore.getStore()?.traceId;

// middleware: traceStore.run({ traceId: req.headers['traceparent'] ?? randomUUID() }, next)
// filter + logger lisent currentTraceId() — aucun couplage de signature

Sécurité — l'erreur ne doit pas fuiter

  • Jamais de stack trace en prod. Chemins de fichiers, versions de libs, structure interne = reconnaissance pour un attaquant. Le filter loggue la stack côté serveur, renvoie un detail générique côté client.
  • Sanitize le context. Le pattern ...exception.context dans la réponse est pratique mais dangereux : un InsufficientFundsError qui met available: 12.50 dans le contexte, OK ; mais un IBAN, un token, un email en clair → fuite. Masque (mask(iban)) au moment de construire l'erreur, jamais au moment de la sérialiser (trop tard, trop facile à oublier).
  • N'expose pas l'existence. NotFoundError vs ForbiddenError sur une ressource : renvoyer 404 pour les deux évite de leak "cette ressource existe mais tu n'y as pas accès" (énumération). Décision business, mais le mapping doit être conscient.
  • Rate-limit les erreurs de validation coûteuses. Un endpoint qui throw ValidationError après un calcul lourd (mod-97 IBAN, vérif crypto) est un vecteur de DoS. Valide cheap-first (format avant signature).

Scale & résilience — l'erreur pilote le comportement

  • Retry classifié par type. 5xx infra → retry avec backoff exponentiel + jitter. 4xx domaine → jamais de retry (c'est déterministe, ça réessaiera de même). Mettre la décision retryable sur la classe d'erreur (InfraError.retryable = true) plutôt qu'au call site.
  • Circuit breaker au bord. Quand un downstream (URSSAF, Infogreffe, un LLM) tombe, le wrap InfraError + un breaker (opossum) évite de marteler un service mort et de transformer un incident downstream en cascade.
  • Dead letter queue pour l'async. Un message qui échoue N fois part sur une DLQ avec son contexte complet. Tu inspectes, tu corriges, tu replay — sans bloquer la queue principale.

🤖 Servir un agent IA depuis NestJS — error handling sous streaming

C'est là que l'error handling devient vraiment dur : quand tu streames des tokens d'un LLM (Claude) vers le client, une erreur peut survenir après que le status HTTP 200 et les premiers chunks soient déjà partis. Tu ne peux plus "renvoyer un 500" — les headers sont flushés. Le pattern change complètement.

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

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

@Module({
  providers: [
    {
      provide: Anthropic,
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) =>
        new Anthropic({
          apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
          maxRetries: 3, // le SDK retry 429/5xx avec backoff — ne le réimplémente pas
          timeout: 60_000,
        }),
    },
  ],
  exports: [Anthropic],
})
export class LlmModule {}

Pourquoi DI : testabilité (mock du client en e2e), config centralisée, un seul endroit pour le pool de connexions et la politique de retry. new Anthropic() dans un champ de service = clé hardcodée, intestable, dupliquée.

Streaming SSE : l'erreur après le premier chunk

La règle d'or : une fois le stream ouvert, une erreur n'est plus un status HTTP — c'est un événement error dans le flux SSE. Le client doit savoir distinguer "fin normale" de "interruption".

ts
// chat/chat.controller.ts
import { Controller, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
import Anthropic from '@anthropic-ai/sdk';

@Controller('chat')
export class ChatController {
  constructor(private readonly anthropic: Anthropic) {}

  @Post('stream')
  async stream(@Body() dto: ChatDto, @Res() res: Response, @Req() req: Request) {
    res.set({
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    });
    res.flushHeaders(); // ⚠️ à partir d'ici, plus aucun status HTTP possible

    // Annulation : si le client ferme l'onglet, on coupe l'appel LLM (sinon on paie des tokens pour rien)
    const ac = new AbortController();
    req.on('close', () => ac.abort());

    try {
      const llmStream = await this.anthropic.messages.stream(
        {
          model: 'claude-opus-4-8',
          max_tokens: 4096,
          thinking: { type: 'adaptive' },
          messages: dto.messages,
        },
        { signal: ac.signal },
      );

      for await (const event of llmStream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          res.write(`event: token\ndata: ${JSON.stringify({ text: event.delta.text })}\n\n`);
        }
      }
      res.write(`event: done\ndata: {}\n\n`); // signal de fin propre
    } catch (err) {
      if (ac.signal.aborted) return; // client parti, rien à envoyer
      // Erreur APRÈS flush : pas de 500 possible. On émet un event SSE typé + on loggue.
      const code = mapLlmErrorToCode(err); // 'rate_limited' | 'overloaded' | 'llm_error'
      res.write(`event: error\ndata: ${JSON.stringify({ code })}\n\n`);
      // le filter global ne verra jamais cette erreur — c'est ici qu'on loggue
    } finally {
      res.end();
    }
  }
}

function mapLlmErrorToCode(err: unknown): string {
  // Du plus spécifique au plus général — comme un instanceof domaine.
  if (err instanceof Anthropic.RateLimitError) return 'rate_limited';       // 429
  if (err instanceof Anthropic.OverloadedError) return 'overloaded';        // 529
  if (err instanceof Anthropic.InternalServerError) return 'llm_5xx';       // 500
  if (err instanceof Anthropic.BadRequestError) return 'bad_prompt';        // 400 — déterministe, pas de retry
  if (err instanceof Anthropic.APIError) return 'llm_error';                // base, status exposé via err.status
  return 'internal_error';
}

Points seniors qui distinguent un vrai service de prod :

  • AbortController câblé sur req.on('close') : un agent peut streamer 30s ; si l'utilisateur ferme, tu coupes l'appel LLM (le SDK propage le signal) → tu arrêtes de brûler des tokens facturés. C'est l'erreur de coût n°1 en prod LLM. Le SDK lève une Anthropic.APIUserAbortError quand le signal s'abort — d'où le if (ac.signal.aborted) return avant tout mapping (sinon tu loggues un faux incident pour un départ utilisateur normal).
  • Le filter global ne voit rien après flushHeaders(). Toute la gestion d'erreur post-flush vit dans le catch du controller. C'est une exception à la règle "logguer au bord" : ici, le bord est le controller.
  • Typer l'erreur LLM avec les classes du SDK (Anthropic.RateLimitError, Anthropic.OverloadedError, etc.) — jamais de err.message.includes('429'). Le SDK expose des exceptions typées exactement comme ton domaine ; toutes héritent de Anthropic.APIError (qui porte .status et .type), donc tu branches du plus spécifique au plus général. Distinction qui compte : 529 OverloadedError (service saturé, backoff plus long) vs 500 InternalServerError (bug côté provider) vs 429 RateLimitError (ton quota, lis retry-after). Mal les confondre = mauvaise stratégie de retry.
  • Le SDK retry déjà 429/5xx/529 avec backoff exponentiel (maxRetries: 3 sur le client DI). Donc une RateLimitError qui arrive jusqu'à ton catch a déjà épuisé les retries SDK — ne la re-retry pas naïvement par-dessus, tu doublerais le backoff.

Le piège du display: 'omitted' sous streaming

Sur Opus 4.8 / 4.7, thinking.display vaut 'omitted' par défaut : les blocs thinking arrivent dans le stream mais avec un champ texte vide. Si ton UI streame le raisonnement, le défaut ressemble à une longue pause avant le premier token — facile à confondre avec un hang ou un timeout côté front, et tu vas "réparer" un bug qui n'existe pas. Si tu surfaces le reasoning, demande-le explicitement :

ts
const llmStream = await this.anthropic.messages.stream(
  {
    model: 'claude-opus-4-8',
    max_tokens: 4096,
    thinking: { type: 'adaptive', display: 'summarized' }, // sinon: pause silencieuse au front
    messages: dto.messages,
  },
  { signal: ac.signal },
);

C'est un failure mode d'observabilité, pas de code : le service marche, mais l'UX paraît cassée. Le genre de chose qu'un staff engineer anticipe parce qu'il a lu les defaults du modèle, pas juste la signature de la méthode.

Le tool-use loop agentique : erreurs partielles et idempotence

Un agent qui appelle des tools (le loop tool_use → exécute → renvoie le résultat → re-appelle) multiplie les points de défaillance : le LLM peut refuser, un tool peut throw, le loop peut diverger. Stratégie :

  • Erreur d'un tool ≠ erreur de la requête. Si run_tests throw, tu ne fais pas planter le loop — tu renvoies { type: 'tool_result', is_error: true, content: '...' } à Claude, qui s'adapte (réessaie autrement ou demande clarification). C'est du Result au niveau du protocole.
  • Borne le loop (max_continuations) — sinon une erreur récurrente de tool boucle à l'infini et explose ta facture.
  • stop_reason: 'refusal' (Claude peut décliner) est un état valide à gérer, pas une exception — vérifie stop_reason avant de lire content.

Jobs IA en BullMQ : idempotence, retry cost-aware, sortie partielle

Pour les générations longues (rapport, batch), tu mets le job en queue. Les erreurs y ont une saveur particulière :

ts
// generation.processor.ts
@Processor('ai-generation')
export class GenerationProcessor extends WorkerHost {
  constructor(private readonly anthropic: Anthropic, private readonly store: GenerationStore) {
    super();
  }

  async process(job: Job<GenJobData>) {
    const { generationId, messages } = job.data;

    // 1. Idempotence keyée sur le generationId : un retry BullMQ ne doit pas régénérer
    //    (et re-facturer) ce qui est déjà produit.
    const existing = await this.store.find(generationId);
    if (existing?.status === 'completed') return existing.result;

    try {
      const msg = await this.anthropic.messages.create({
        model: 'claude-opus-4-8',
        max_tokens: 8192,
        thinking: { type: 'adaptive' },
        messages,
      });
      if (msg.stop_reason === 'refusal') {
        // pas une exception : un résultat métier "refusé", terminal, pas de retry
        await this.store.markRefused(generationId, msg.stop_reason);
        return;
      }
      const result = msg.content.find((b) => b.type === 'text')?.text ?? '';
      await this.store.complete(generationId, result);
      return result;
    } catch (err) {
      // 2. Retry COST-AWARE : seules les erreurs transientes méritent un retry.
      //    Un 400 (prompt invalide) re-throwé = N retries facturés pour rien.
      const transient =
        err instanceof Anthropic.RateLimitError ||      // 429
        err instanceof Anthropic.OverloadedError ||     // 529
        err instanceof Anthropic.InternalServerError;   // 500
      if (transient) {
        throw err; // BullMQ retry avec backoff (configuré sur la queue)
      }
      // erreur permanente → on marque échoué, on ne retry pas
      await this.store.markFailed(generationId, mapLlmErrorToCode(err));
      return; // pas de throw → pas de retry inutile
    }
  }
}

Les trois invariants à retenir :

  1. Idempotence keyée sur un generationId stable : le retry est garanti par BullMQ, donc process() doit être rejouable sans double-facturation ni double-effet. Tu checkes l'état persisté avant de re-générer.
  2. Retry cost-aware : ne re-throw (= déclenche un retry BullMQ) que sur les erreurs transientes (RateLimitError 429, OverloadedError 529, InternalServerError 500). Un BadRequestError 400 est déterministe — le retry brûle des tokens et de la latence pour rien, et épuise tes attempts avant qu'un vrai job transient passe. Marque échoué et sors. Subtilité : configure le backoff BullMQ en exponential avec jitter ({ type: 'exponential', delay: 2000 }) — sans jitter, un incident provider qui rejette 1000 jobs simultanément les fait tous retry à la même milliseconde (thundering herd) et tu DDoS le provider au moment où il se relève.
  3. Sortie partielle : pour un stream interrompu en cours de génération, persiste les tokens déjà reçus avant de propager l'erreur — un retry reprend depuis le checkpoint au lieu de tout refaire.

Idempotency / rate-limit / cost-guard au bord

Avant même d'atteindre le LLM, le edge (guard/interceptor) protège : idempotency key (même clé + même payload → réponse cachée, pas de re-génération), rate-limit par utilisateur (un agent peut spammer), cost-guard (refuse si l'utilisateur a dépassé son quota de tokens du mois → PaymentRequiredError 402, un DomainError comme les autres). Ces garde-fous sont des erreurs domaine classiques, mappées HTTP par le même filter — l'IA ne change pas le pattern, elle l'étend.

🔁 Quand utiliser / éviter

  • Throw exceptions : flux exceptionnel, validation HTTP, contraintes invariantes du domaine.
  • Result type : opérations qui échouent souvent (parsing, lookup optionnel, validation lourde). Évite l'unwinding coûteux et explicite l'API ("cette fonction peut échouer").
  • Problem+JSON : APIs publiques, communication cross-team. Pour une API interne 100% privée, JSON simple { error: { code, message } } peut suffire.
  • Évite un mega-try/catch autour de tout — perds la précision. Préfère catch ciblés.

Async error propagation — gotchas Node/Nest

Quelques pièges concrets :

  1. forEach async[1,2].forEach(async i => await something(i)) ne await pas la boucle. Une rejection est unhandled. Préfère for...of + await, ou Promise.all(items.map(async ...)).
  2. setTimeout callback asyncsetTimeout(async () => { throw ... }, 100) → unhandled rejection. Wrap : setTimeout(() => fn().catch(reportError), 100).
  3. EventEmitter listeners async — Node's EventEmitter n'attend pas les promesses. Les erreurs ne remontent pas. Utilise events.captureRejections = true (Node 13+) ou un bus custom.
  4. Streams — un pipe() chaîne sans await ; les erreurs vont via 'error' event. Utilise stream.pipeline(src, dst, cb) ou pipeline promisified.
  5. Top-level await dans ESM — les erreurs au chargement du module crashent le process. Wrap en try/catch dans bootstrap().
ts
// pattern global anti-crash
process.on('unhandledRejection', (reason) => {
  logger.error({ err: reason }, 'unhandled rejection');
  // selon strictness: process.exit(1) en prod (let K8s restart)
});
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaught exception');
  process.exit(1);
});

"Never eat errors" — anti-patterns

Catalogue de catch toxiques rencontrés en review :

ts
// ❌ avale silencieusement
try { await risky(); } catch {}

// ❌ log mais cache la cause
try { await risky(); } catch (e) { logger.error('something failed'); }

// ❌ relance un message dégradé sans cause
try { await risky(); } catch (e) { throw new Error('Operation failed'); }

// ✅ relance avec cause (ES2022)
try { await risky(); } catch (e) {
  throw new InfraError('Could not fetch user profile', { cause: e });
}

// ✅ catch ciblé, le reste remonte
try { await risky(); } catch (e) {
  if (e instanceof NotFoundError) return null;
  throw e;
}

🏋️ Exercices

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

1. Hiérarchie + filter problem+json (implémenter)

Objectif : monter une DomainError abstraite, 3 sous-classes (NotFound 404, Conflict 409, Validation 422), un ProblemJsonFilter global, et prouver le mapping par un test e2e.

Indice/Solution : abstract class DomainError extends Error avec abstract readonly code + abstract readonly status. Le filter @Catch() (sans argument) capture tout, branche sur instanceof DomainError, fallback 500 pour le reste. Test : mockRejectedValue(new ConflictError(...)).expect(409) + expect(res.headers['content-type']).toContain('application/problem+json').

2. Le wrap infra (production-grade)

Objectif : garantir qu'aucun QueryFailedError TypeORM, AxiosError, ou Anthropic.APIError ne fuit jamais dans la couche domaine. Le service métier ne doit voir que des InfraError typées portant { cause } (ES2022).

Indice/Solution : un repository qui catch l'erreur native, throw new DbConnectionError('...', { cause: e }). Ajoute un flag readonly retryable: boolean sur InfraError. Vérifie en test que la cause est préservée (expect(thrown.cause).toBeInstanceOf(QueryFailedError)) et que le domaine ne dépend d'aucun import TypeORM.

3. Result sur le hot path (production-grade)

Objectif : convertir un parser appelé 10k fois/req de throw vers Result<T, ParseError>, mesurer la différence, et garder la cohérence (tout le module en Result).

Indice/Solution : type Result<T,E> = { ok: true; value: T } | { ok: false; error: E } + helpers Ok/Err. Benchmark : 100k parses qui échouent, throw/catch vs Result — l'unwind de pile domine. Le piège : un seul throw résiduel dans le module casse la garantie ; grep throw pour le prouver.

4. Streaming SSE qui survit à l'erreur post-flush (casser puis réparer)

Objectif : un endpoint qui streame des tokens Claude. Casse-le : fais échouer le LLM au 3e chunk (mock qui throw après 2 deltas). Constate que res.status(500) ne marche plus (headers flushés). Répare : émets un event: error SSE typé, loggue côté serveur, ferme proprement.

Indice/Solution : après res.flushHeaders(), le catch ne peut plus toucher au status. La réparation : res.write('event: error\ndata: ...') + res.end() dans finally. Bonus : câble un AbortController sur req.on('close') et prouve par un log que l'appel LLM est bien annulé quand le client part en cours de stream (sinon tu paies les tokens jusqu'au bout).

5. Job BullMQ idempotent et cost-aware (casser puis réparer)

Objectif : un processor de génération IA. Casse-le : configure attempts: 3, fais throw un BadRequestError (prompt invalide) — observe 3 appels LLM facturés pour une erreur déterministe, plus une double-génération sur retry après succès partiel. Répare : idempotence keyée sur generationId + retry uniquement sur RateLimitError/InternalServerError.

Indice/Solution : check store.find(generationId) en tête de process() ; ne re-throw (= retry BullMQ) que sur les erreurs transientes, sinon markFailed + return. Test : un mock qui throw BadRequestError doit produire 1 appel LLM, pas 3 ; un mock qui réussit puis est rejoué doit retourner le résultat caché sans 2e appel.

6. Filter qui ne masque jamais (casser puis réparer)

Objectif : auditer un filter qui avale silencieusement une erreur (le pire bug latent). Casse : un catch (e) {} muet quelque part dans la chaîne. Répare : garantis qu'aucun 5xx ne part sans log structuré, via un test property-based.

Indice/Solution : fast-check génère des erreurs aléatoires (types variés, messages, contextes) ; assert que pour toute erreur non-DomainError, le logger a été appelé exactement une fois avec { traceId, err } et que la réponse est 500 + problem+json sans stack trace. Le property-based attrape les cas que tes 3 tests à la main ratent.

7. Le mapping erreur→SLO (casser puis réparer)

Objectif : prouver qu'un mauvais mapping domaine→HTTP empoisonne l'alerting, pas juste l'UX. Casse : mappe un OrderAlreadyPaidError (non-bug) en 500 au lieu de 409. Répare : corrige le mapping, puis ajoute un garde-fou de test qui empêche la régression — aucune DomainError métier ne doit jamais produire un 5xx.

Indice/Solution : écris un test qui itère sur toutes les sous-classes de DomainError du module (récupère-les via un registre ou un Object.values d'un index d'erreurs) et assert err.status < 500 pour chacune — seul l'InfraError/unhandled a le droit au 5xx. Bonus observabilité : simule un dashboard en comptant 5xx sur une rafale de requêtes OrderAlreadyPaid ; montre que le taux d'erreur serveur passe de 0% (correct) à 100% (bug) selon le mapping. Le point staff : le status n'est pas cosmétique, c'est le contrat avec ton système d'alerting et ton error budget.

8. Le store d'idempotence sous course (casser puis réparer)

Objectif : deux requêtes concurrentes avec la même Idempotency-Key arrivent en parallèle (double-clic, retry réseau client). Casse : implémente le check naïf lookupif (!existing) { execute(); store() } et lance 2 appels simultanés — observe une double-exécution (double virement / double génération facturée), car les deux passent le lookup avant que l'un ait store. Répare : rends le claim atomique.

Indice/Solution : le lookup/store en deux temps est une TOCTOU (time-of-check-to-time-of-use). Répare avec un INSERT ... ON CONFLICT DO NOTHING (Postgres) ou un SET key val NX EX (Redis) qui claim la clé atomiquement avant d'exécuter : le perdant de la course reçoit le conflit, attend/relit le résultat du gagnant, et renvoie une réponse cohérente — soit le résultat partagé, soit un 409 PaymentIdempotencyConflictError si le payload diffère. Test : Promise.all([execute(input), execute(input)]) doit produire 1 seul effet de bord et 2 réponses identiques. C'est l'erreur la plus chère du fichier — elle ne casse pas en dev (mono-thread, séquentiel), seulement en prod sous charge.

🎤 En entretien

Q : Pourquoi ne pas jeter directement des HttpException depuis tes services ? Parce que le service ne sait pas qu'il parle HTTP. Le même service peut être appelé par un consumer Kafka, un cron, un resolver GraphQL ou un endpoint qui sert un agent. Le domaine jette des DomainError ; une seule couche d'adaptation au bord (filter, mapper) traduit vers le protocole de sortie. Coupler le domaine à HTTP rend le service intestable hors-HTTP et non réutilisable.

Q : Exception ou Result<T, E> — comment tu choisis ? Par fréquence et par frontière, et je décide par module pas par fonction. Exception pour l'erreur attendue mais rare (le coût d'unwind est négligeable, et la signature ment mais c'est acceptable). Result pour ce qui échoue souvent par design sur un hot path (parsing, validation en masse) — pas d'unwind, et la signature Promise<Result<T,E>> rend l'échec explicite. Mélanger les deux dans un même module est le vrai anti-pattern : l'appelant ne sait jamais s'il doit try ou tester r.ok.

Q : Tu streames des tokens d'un LLM et il plante au milieu. Comment tu renvoies l'erreur ? Je ne peux pas — le 200 et les premiers chunks sont déjà flushés, le status HTTP est figé. L'erreur devient un événement dans le flux : event: error en SSE avec un code machine-readable, je loggue côté serveur dans le catch du controller (le filter global ne voit jamais cette erreur, post-flush), et je ferme proprement. Détail clé : un AbortController câblé sur req.on('close') pour couper l'appel LLM si le client part — sinon je continue à payer des tokens facturés pour un stream que personne ne lit.

Q : Un job de génération IA en queue, retry à 3 tentatives. Quels pièges ? Deux : la double-facturation et le retry inutile. Idempotence d'abord — le job doit être rejouable, donc je check l'état persisté keyé sur un generationId stable avant de régénérer, sinon un retry après succès partiel re-génère et re-facture. Ensuite retry cost-aware — je ne re-throw (ce qui déclenche le retry BullMQ) que sur les erreurs transientes (RateLimitError 429, OverloadedError 529, InternalServerError 500) ; un BadRequestError 400 est déterministe, le retry brûle 3× le coût pour rien, donc je marque échoué et je sors sans throw. Et stop_reason: 'refusal' est un état métier terminal, pas une exception : pas de retry non plus.

Q : Pourquoi le mapping domaine→HTTP est aussi un problème d'alerting, pas juste de cosmétique ? Parce que le status code pilote ton SLO et ton error budget. Les 5xx comptent dans ton taux d'erreur serveur et réveillent l'astreinte ; les 4xx non (c'est la faute du client). Si je mappe un OrderAlreadyPaid — un non-bug — en 500, je pollue mes métriques, je brûle mon error budget pour rien, et je réveille quelqu'un à 3h pour une UX nominale. Le mapping erreur→status est donc un mapping erreur→SLO : un 409 dit "comportement attendu, ne page pas", un 500 dit "bug, investigue". Corollaire côté retry : un client/SDK retry les 5xx, pas les 4xx — mal mapper une erreur déterministe en 5xx provoque des retries inutiles qui amplifient l'incident.

Q : Result<T,E> rend l'erreur explicite dans la signature — pourquoi ne pas tout passer en Result alors ? Parce que Result déplace le coût ailleurs, il ne l'élimine pas. Sur un chemin où l'erreur est rare, throw garde le happy path lisible (pas de if (!r.ok) à chaque appel) et la propagation est gratuite — la stack remonte toute seule jusqu'au filter. Result force l'appelant à dépiler l'erreur à chaque niveau intermédiaire à la main (ou avec un combinateur type andThen), ce qui est exactement ce que tu veux sur un hot path (pas d'unwind, échec dans le type) mais du bruit pur ailleurs. Le vrai critère c'est la fréquence × la frontière, pas "explicite c'est mieux". Et la cohérence par module prime : un module mi-throw mi-Result est le pire des deux mondes. Si je veux la rigueur d'Effect-TS partout, c'est un choix d'architecture global, pas un panachage fonction par fonction.

🔗 Liens

Bibliothèque tech perso — Achref