From 67362461e48849e38f995d1e842e7245ef518fe2 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:55 +0200 Subject: [PATCH] fix(api): filet attrape-tout au lieu d une erreur 500 nue Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../unhandled-exception.filter.spec.ts | 164 ++++++++++++++++++ .../filters/unhandled-exception.filter.ts | 161 +++++++++++++++++ apps/backend/src/i18n/en/error.json | 4 +- apps/backend/src/i18n/fr/error.json | 4 +- apps/backend/src/main.ts | 13 +- 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/application/filters/unhandled-exception.filter.spec.ts create mode 100644 apps/backend/src/application/filters/unhandled-exception.filter.ts diff --git a/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts b/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts new file mode 100644 index 0000000..b158501 --- /dev/null +++ b/apps/backend/src/application/filters/unhandled-exception.filter.spec.ts @@ -0,0 +1,164 @@ +import { ArgumentsHost, BadRequestException, HttpStatus, NotFoundException } from '@nestjs/common'; +import { UnhandledExceptionFilter, isDependencyUnavailable } from './unhandled-exception.filter'; + +const i18n = { + translate: jest.fn((key: string) => `translated:${key}`), +}; + +/** Le double garde son type ; seul le passage au filtre est force. */ +const filterWith = () => new UnhandledExceptionFilter(i18n as never); + +function hostFor(headers: Record = {}, url = '/api/v1/auth/register') { + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const host = { + switchToHttp: () => ({ + getResponse: () => ({ status }), + getRequest: () => ({ url, method: 'POST', headers }), + }), + } as unknown as ArgumentsHost; + + return { host, status, json, body: () => json.mock.calls[0][0] }; +} + +describe('UnhandledExceptionFilter', () => { + const filter = filterWith(); + beforeEach(() => jest.clearAllMocks()); + + it('lets a deliberate HTTP response through untouched', () => { + const { host, status, body } = hostFor(); + + filter.catch(new NotFoundException('Réservation introuvable'), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(body()).toMatchObject({ message: 'Réservation introuvable' }); + }); + + it('keeps a validation response intact, fields included', () => { + const { host, status, body } = hostFor(); + + filter.catch(new BadRequestException({ message: ['email must be an email'] }), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(body()).toMatchObject({ message: ['email must be an email'] }); + }); + + it('turns a database outage into a 503 that invites a retry', () => { + // C'est l'erreur exacte qu'a renvoyee l'inscription pendant que PostgreSQL + // redemarrait, disque plein : un 500 laissait croire a une donnee refusee. + const { host, status, body } = hostFor(); + + filter.catch(new Error('the database system is not yet accepting connections'), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE); + expect(body()).toMatchObject({ + code: 'service_unavailable', + message: 'translated:error.SERVICE_UNAVAILABLE', + }); + }); + + it('gives an unexpected failure a reference instead of a stack trace', () => { + const { host, status, body } = hostFor(); + + filter.catch(new TypeError("Cannot read properties of undefined (reading 'id')"), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + const payload = body(); + expect(payload).toMatchObject({ + code: 'unexpected_error', + message: 'translated:error.UNEXPECTED_ERROR', + }); + expect(payload.reference).toMatch(/^[0-9a-f]{8}$/); + + // Le detail technique reste dans le journal, jamais dans la reponse. + expect(JSON.stringify(payload)).not.toContain('Cannot read properties'); + expect(JSON.stringify(payload)).not.toContain('stack'); + }); + + it('gives each incident its own reference', () => { + const first = hostFor(); + const second = hostFor(); + + filter.catch(new Error('boom'), first.host); + filter.catch(new Error('boom'), second.host); + + expect(first.body().reference).not.toBe(second.body().reference); + }); + + it('classifies a DNS failure as an outage, not as a bug', () => { + // C'est l'erreur observee quand le conteneur PostgreSQL est arrete : + // `getaddrinfo ENOTFOUND postgres`. Elle sortait en 500. + const { host, status, body } = hostFor(); + + filter.catch(new Error('getaddrinfo ENOTFOUND postgres'), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE); + expect(body()).toMatchObject({ code: 'service_unavailable' }); + }); + + it('answers in the language of the request', () => { + // `I18nContext.current()` n'est pas garanti dans un filtre : sans relecture + // des en-tetes, la reponse repartait toujours en francais. + filter.catch(new Error('boom'), hostFor({ 'x-lang': 'en' }).host); + expect(i18n.translate).toHaveBeenLastCalledWith( + 'error.UNEXPECTED_ERROR', + expect.objectContaining({ lang: 'en' }) + ); + + filter.catch(new Error('boom'), hostFor({ 'accept-language': 'en-GB,en;q=0.8' }).host); + expect(i18n.translate).toHaveBeenLastCalledWith( + 'error.UNEXPECTED_ERROR', + expect.objectContaining({ lang: 'en' }) + ); + }); + + it('falls back to French for an unsupported language', () => { + filter.catch(new Error('boom'), hostFor({ 'x-lang': 'de' }).host); + + expect(i18n.translate).toHaveBeenLastCalledWith( + 'error.UNEXPECTED_ERROR', + expect.objectContaining({ lang: 'fr' }) + ); + }); + + it('rethrows outside an HTTP context rather than writing nowhere', () => { + const host = { + switchToHttp: () => ({ getResponse: () => ({}), getRequest: () => ({}) }), + } as unknown as ArgumentsHost; + + expect(() => filter.catch(new Error('boom'), host)).toThrow('boom'); + }); +}); + +describe('isDependencyUnavailable', () => { + it.each([ + 'the database system is not yet accepting connections', + 'the database system is in recovery mode', + 'terminating connection due to administrator command', + 'connect ECONNREFUSED 127.0.0.1:5432', + 'Connection terminated unexpectedly', + 'getaddrinfo ENOTFOUND postgres', + 'socket hang up', + ])('recognises %p', message => { + expect(isDependencyUnavailable(new Error(message))).toBe(true); + }); + + it.each(['57P03', '08006', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN'])( + 'recognises the driver code %p', + code => { + expect(isDependencyUnavailable(Object.assign(new Error('nope'), { code }))).toBe(true); + } + ); + + it.each([ + 'duplicate key value violates unique constraint', + "Cannot read properties of undefined (reading 'id')", + 'null value in column "email" violates not-null constraint', + ])('does not mistake the application fault %p for an outage', message => { + expect(isDependencyUnavailable(new Error(message))).toBe(false); + }); + + it('ignores a non-error throw', () => { + expect(isDependencyUnavailable('boom')).toBe(false); + }); +}); diff --git a/apps/backend/src/application/filters/unhandled-exception.filter.ts b/apps/backend/src/application/filters/unhandled-exception.filter.ts new file mode 100644 index 0000000..16c0aa7 --- /dev/null +++ b/apps/backend/src/application/filters/unhandled-exception.filter.ts @@ -0,0 +1,161 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { Request, Response } from 'express'; +import { I18nContext, I18nService } from 'nestjs-i18n'; +import { DEFAULT_LOCALE, Locale, isLocale } from '@domain/value-objects/locale.vo'; + +/** + * Dernier recours avant la reponse HTTP. + * + * Sans lui, toute exception non prevue sortait avec le message par defaut de + * NestJS — « Internal server error » — affiche tel quel dans le navigateur. Ce + * message ne dit rien de ce qui s'est passe, rien de ce qu'il faut faire, et + * n'existe dans aucune langue. + * + * Trois cas, dans cet ordre : + * + * 1. **Une `HttpException`** est une reponse deliberee (404, 400, 409...) : + * elle passe telle quelle, avec son statut et son message. + * 2. **Une base de donnees indisponible** n'est pas une erreur du client ni un + * bogue : c'est un `503` temporaire, et le message invite a reessayer. Le + * 500 precedent laissait croire a une donnee refusee. + * 3. **Tout le reste** est un defaut : `500`, message generique — le detail + * technique ne sort jamais — et une **reference** courte, journalisee avec + * la trace. L'utilisateur peut la donner au support, qui retrouve l'incident. + */ +@Catch() +export class UnhandledExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('UnhandledException'); + + constructor(private readonly i18n: I18nService>) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + // Hors contexte HTTP (WebSocket, tache planifiee), il n'y a pas de reponse + // a former : laisser remonter plutot que d'ecrire dans le vide. + if (!response?.status) throw exception; + + if (exception instanceof HttpException) { + response.status(exception.getStatus()).json(exception.getResponse()); + return; + } + + const lang = resolveLocale(request); + const unavailable = isDependencyUnavailable(exception); + const status = unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.INTERNAL_SERVER_ERROR; + const key = unavailable ? 'error.SERVICE_UNAVAILABLE' : 'error.UNEXPECTED_ERROR'; + + // La reference relie ce que voit l'utilisateur a la trace du journal ; elle + // n'apprend rien a un attaquant et evite de lui montrer la pile. + const reference = randomUUID().slice(0, 8); + + this.logger.error( + `[${reference}] ${request.method} ${request.url} — ${describe(exception)}`, + exception instanceof Error ? exception.stack : undefined + ); + + response.status(status).json({ + statusCode: status, + error: unavailable ? 'ServiceUnavailable' : 'UnexpectedError', + code: unavailable ? 'service_unavailable' : 'unexpected_error', + message: this.translate(key, lang), + reference, + timestamp: new Date().toISOString(), + path: request.url, + }); + } + + private translate(key: string, lang: Locale): string { + const translated = this.i18n.translate(key, { lang, defaultValue: key }); + return typeof translated === 'string' ? translated : key; + } +} + +const describe = (exception: unknown): string => + exception instanceof Error ? `${exception.name}: ${exception.message}` : String(exception); + +/** + * L'erreur vient-elle d'une dependance injoignable, plutot que d'une requete + * fautive ou d'un defaut du code ? + * + * Le perimetre n'est pas la seule base de donnees : Redis, le stockage objet, + * le SMTP ou le fournisseur d'IA produisent les memes symptomes, et appellent + * la meme reponse — « reessayez dans un instant » — la ou un `500` laisserait + * croire a une donnee refusee. + * + * Les codes couvrent la resolution DNS (`ENOTFOUND`, observe quand le conteneur + * PostgreSQL est arrete), le refus de connexion, les coupures, et les etats de + * demarrage ou d'arret de PostgreSQL (`57P03` : la base n'accepte pas encore de + * connexions — exactement ce qu'a renvoye l'inscription pendant que le serveur + * redemarrait apres saturation du disque). + */ +export function isDependencyUnavailable(exception: unknown): boolean { + if (!(exception instanceof Error)) return false; + + const code = (exception as { code?: string }).code; + if (code && UNAVAILABLE_CODES.has(code)) return true; + + return /not yet accepting connections|in recovery mode|terminating connection|Connection terminated|getaddrinfo|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|Client has encountered a connection error/i.test( + exception.message + ); +} + +const UNAVAILABLE_CODES = new Set([ + // PostgreSQL + '57P01', // admin_shutdown + '57P02', // crash_shutdown + '57P03', // cannot_connect_now + '08000', // connection_exception + '08003', // connection_does_not_exist + '08006', // connection_failure + // Reseau et DNS + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EPIPE', +]); + +/** + * Langue de la reponse. + * + * `I18nContext.current()` n'est pas garanti dans un filtre d'exception : le + * contexte asynchrone peut avoir ete quitte, et la reponse repartait alors + * toujours en francais. La chaine est donc relue depuis la requete, dans le + * meme ordre que les resolveurs de l'application — sans la preference + * utilisateur, qui demanderait la base, parfois justement indisponible. + */ +function resolveLocale(request: Request): Locale { + const header = request.headers['x-lang'] ?? request.headers['x-locale']; + const cookie = (request as { cookies?: Record }).cookies?.NEXT_LOCALE; + const accept = request.headers['accept-language']?.split(',')[0]; + + const candidates = [ + I18nContext.current()?.lang, + typeof header === 'string' ? header : header?.[0], + cookie, + accept, + ]; + + // `isLocale` et non `toLocale` : ce dernier retombe sur le francais des le + // premier candidat absent, et la chaine ne serait jamais parcourue. + for (const candidate of candidates) { + const short = candidate?.slice(0, 2).toLowerCase(); + if (isLocale(short)) return short; + } + + return DEFAULT_LOCALE; +} diff --git a/apps/backend/src/i18n/en/error.json b/apps/backend/src/i18n/en/error.json index a1222cc..a88b031 100644 --- a/apps/backend/src/i18n/en/error.json +++ b/apps/backend/src/i18n/en/error.json @@ -19,5 +19,7 @@ "RATE_QUOTE_NOT_FOUND": "Rate quote not found", "RATE_QUOTE_EXPIRED": "Rate quote has expired", "CARRIER_NOT_FOUND": "Carrier not found", - "NO_LICENSES_AVAILABLE": "No licenses available for this organization" + "NO_LICENSES_AVAILABLE": "No licenses available for this organization", + "SERVICE_UNAVAILABLE": "The service is temporarily unavailable. Try again in a moment; if the problem persists, contact support@xpeditis.com.", + "UNEXPECTED_ERROR": "Something went wrong on our side. Try again, and if it happens again, send the reference below to support@xpeditis.com." } diff --git a/apps/backend/src/i18n/fr/error.json b/apps/backend/src/i18n/fr/error.json index f0e76e6..d9eec85 100644 --- a/apps/backend/src/i18n/fr/error.json +++ b/apps/backend/src/i18n/fr/error.json @@ -19,5 +19,7 @@ "RATE_QUOTE_NOT_FOUND": "Cotation introuvable", "RATE_QUOTE_EXPIRED": "La cotation a expiré", "CARRIER_NOT_FOUND": "Transporteur introuvable", - "NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation" + "NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation", + "SERVICE_UNAVAILABLE": "Service momentanément indisponible. Réessayez dans quelques instants ; si le problème persiste, contactez support@xpeditis.com.", + "UNEXPECTED_ERROR": "Une erreur inattendue s'est produite de notre côté. Réessayez, et si cela se reproduit, transmettez la référence ci-dessous à support@xpeditis.com." } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 62f8a3c..dae1f29 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -10,6 +10,7 @@ import { AppModule } from './app.module'; import { Logger } from 'nestjs-pino'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; import { DomainExceptionFilter } from './application/filters/domain-exception.filter'; +import { UnhandledExceptionFilter } from './application/filters/unhandled-exception.filter'; import type { Request, Response, NextFunction } from 'express'; async function bootstrap() { @@ -60,11 +61,17 @@ async function bootstrap() { }) ); - // Global exception filters — each filter declares its target via @Catch(), - // so they don't overlap: DomainExceptionFilter handles DomainException, - // I18nValidationExceptionFilter handles class-validator errors. + // Global exception filters — each filter declares its target via @Catch(): + // DomainExceptionFilter handles DomainException, I18nValidationExceptionFilter + // handles class-validator errors. + // + // UnhandledExceptionFilter est le filet : il attrape @Catch() sans argument, + // donc tout le reste. Nest resout les filtres du dernier declare vers le + // premier, il est donc place EN PREMIER pour rester le dernier consulte — + // sans quoi il court-circuiterait les deux autres. const i18nService = app.get(I18nService) as I18nService>; app.useGlobalFilters( + new UnhandledExceptionFilter(i18nService), new DomainExceptionFilter(i18nService), new I18nValidationExceptionFilter({ detailedErrors: false }) );