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. Utiliseproblem+json(RFC 9457) pour les réponses. Jette pour les exceptions exceptionnelles, retourne unResultpour les erreurs prévues sur les hot paths. Et surtout : never eat errors — uncatch {}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
ExceptionFilterglobal 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 :
| Canal | Quand | Coût | Visibilité dans la signature | Failure mode si abusé |
|---|---|---|---|---|
throw DomainError | Erreur 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 objet | Explicite (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 } | Invisible | Oublier 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
// 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
// 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
// main.ts
app.useGlobalFilters(new AllExceptionsFilter());Result pattern (pour les hot paths)
// 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) :
@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
- Erreur typée + code stable —
code: 'order_already_paid'est un contrat. Le client peut switcher dessus. Lemessagepeut changer librement. - Problem+JSON (RFC 9457) — format standard avec
type,title,status,detail,instance. Adopté par .NET, supporté par plein d'outils. - Wrap repository errors —
DbConnectionError extends InfraError. Le service métier ne voit jamais unQueryFailedErrorTypeORM. Couplage cassé. - Retry à la frontière — pour 5xx infra, retry avec backoff. Pour erreurs domaine (4xx), pas de retry — c'est la faute du client.
- Dead letter queue — pour les messages async qui échouent N fois, push sur une DLQ avec contexte. Tu inspectes, replay manuellement.
- 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 :
HttpExceptionsimple, pas destatusdansgetResponse(). - 8 :
HttpStatusenum exhaustif.ExceptionFiltertypage amélioré. - 9 :
@Catch()sans argument capture tout (avant fallait@Catch(Error)). - 10 : meilleur support des erreurs async dans les guards/interceptors.
awaitdanscanActivatecorrectement propagé. - 11 :
cause(ES2022) propagé dans les exceptions Nest. Tu peux fairethrow 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
catch (e) {}muet — la pire erreur. Au minimum log + relance, ou commente pourquoi tu avales (rare).throw new Error('message')sans type — perd toute info pour le filter. Hierarchie sinoninstanceofne marche pas (regarde lanameen fallback).- Erreurs async non await —
service.foo()sansawaitdans un controller : la promise rejected sort du flux Nest, devientunhandledRejection. ESLint@typescript-eslint/no-floating-promisesobligatoire. - Mélange
Resultetthrowdans un même service — incohérent. Décide par module : toutResultou toutthrow. - Exposer la stack trace en prod — fuite d'info (chemins, lib versions). Filter doit retourner
detailgénérique, log la stack côté serveur. messageinstable — si ton client front matche sur la string du message, un i18n casse tout. Toujours uncodemachine-readable.- HTTP 500 pour des erreurs métier —
OrderAlreadyPaidn'est pas un bug, c'est un 409. Mauvais status = mauvais alerting, mauvaise UX. - Filter qui swallow l'erreur sans la logger — silent failure. Toujours logger les 5xx, jamais swallow.
🧪 Testing
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-checkgé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.
// 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.
// 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.
// 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.
// 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;
}// 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 }); }
}// 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,
});
}
}// 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;
}
}// 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
catchintermé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')— paslogger.error('payment failed: ' + e.message). Tu veux pouvoir filtrercode='payment_insufficient_funds' AND env='prod'dans Loki/Datadog. code= clé de groupement. Sentry/Datadog groupent parcodemachine-readable, pas par message i18n. Sanscode, chaque message traduit devient un "issue" distinct → fingerprinting cassé.- Propagation du contexte de trace. Le
traceId(outraceparentW3C) doit traverser : header entrant → AsyncLocalStorage → log → réponseproblem+json. Le support colle letraceIddu client dans Tempo/Jaeger et voit la trace complète. Sans ça, "j'ai une erreur" est un ticket non-débogable.
// 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 signatureSé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
detailgénérique côté client. - Sanitize le
context. Le pattern...exception.contextdans la réponse est pratique mais dangereux : unInsufficientFundsErrorqui metavailable: 12.50dans 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.
NotFoundErrorvsForbiddenErrorsur 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
ValidationErroraprè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
// 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".
// 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 :
AbortControllercâblé surreq.on('close'): un agent peut streamer 30s ; si l'utilisateur ferme, tu coupes l'appel LLM (le SDK propage lesignal) → 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 uneAnthropic.APIUserAbortErrorquand lesignals'abort — d'où leif (ac.signal.aborted) returnavant 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 lecatchdu 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 deerr.message.includes('429'). Le SDK expose des exceptions typées exactement comme ton domaine ; toutes héritent deAnthropic.APIError(qui porte.statuset.type), donc tu branches du plus spécifique au plus général. Distinction qui compte : 529OverloadedError(service saturé, backoff plus long) vs 500InternalServerError(bug côté provider) vs 429RateLimitError(ton quota, lisretry-after). Mal les confondre = mauvaise stratégie de retry. - Le SDK retry déjà 429/5xx/529 avec backoff exponentiel (
maxRetries: 3sur le client DI). Donc uneRateLimitErrorqui 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 :
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_teststhrow, 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 duResultau 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érifiestop_reasonavant de lirecontent.
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 :
// 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 :
- Idempotence keyée sur un
generationIdstable : le retry est garanti par BullMQ, doncprocess()doit être rejouable sans double-facturation ni double-effet. Tu checkes l'état persisté avant de re-générer. - Retry cost-aware : ne re-throw (= déclenche un retry BullMQ) que sur les erreurs transientes (
RateLimitError429,OverloadedError529,InternalServerError500). UnBadRequestError400 est déterministe — le retry brûle des tokens et de la latence pour rien, et épuise tesattemptsavant qu'un vrai job transient passe. Marque échoué et sors. Subtilité : configure le backoff BullMQ enexponentialavec 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. - 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/catchautour de tout — perds la précision. Préfère catch ciblés.
Async error propagation — gotchas Node/Nest
Quelques pièges concrets :
forEachasync —[1,2].forEach(async i => await something(i))neawaitpas la boucle. Une rejection est unhandled. Préfèrefor...of+await, ouPromise.all(items.map(async ...)).setTimeoutcallback async —setTimeout(async () => { throw ... }, 100)→ unhandled rejection. Wrap :setTimeout(() => fn().catch(reportError), 100).- EventEmitter listeners async — Node's
EventEmittern'attend pas les promesses. Les erreurs ne remontent pas. Utiliseevents.captureRejections = true(Node 13+) ou un bus custom. - Streams — un
pipe()chaîne sansawait; les erreurs vont via'error'event. Utilisestream.pipeline(src, dst, cb)oupipelinepromisified. - Top-level await dans ESM — les erreurs au chargement du module crashent le process. Wrap en
try/catchdansbootstrap().
// 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 :
// ❌ 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 lookup → if (!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
- RFC 9457 — Problem Details for HTTP APIs
- Nest exception filters
- Effect-TS — Result/Either typed errors en TS
- Node.js error handling best practices
- Article : "Parse, don't validate" — Alexis King
- Livre : Release It! — Michael Nygard (stabilité, circuit breakers, error handling)