merge: integrer fix/messages-erreur-api

This commit is contained in:
David 2026-09-07 21:42:36 +02:00
commit f418443d0c
10 changed files with 450 additions and 17 deletions

View File

@ -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<string, string> = {}, 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);
});
});

View File

@ -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<Record<string, unknown>>) {}
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 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<string, string> }).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;
}

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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<Record<string, unknown>>;
app.useGlobalFilters(
new UnhandledExceptionFilter(i18nService),
new DomainExceptionFilter(i18nService),
new I18nValidationExceptionFilter({ detailedErrors: false })
);

View File

@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { Link, useRouter } from '@/i18n/navigation';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { apiErrorMessage, apiErrorReference } from '@/lib/api/errors';
import { Eye, EyeOff } from 'lucide-react';
import { register } from '@/lib/api';
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
@ -98,6 +99,8 @@ function RegisterPageContent() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// Reference d'incident renvoyee par l'API, a transmettre au support.
const [errorReference, setErrorReference] = useState<string | undefined>();
const [invitationToken, setInvitationToken] = useState<string | null>(null);
const [invitation, setInvitation] = useState<InvitationResponse | null>(null);
@ -137,6 +140,7 @@ function RegisterPageContent() {
const handleStep1 = (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrorReference(undefined);
const err = validateStep1();
if (err) {
setError(err);
@ -162,6 +166,7 @@ function RegisterPageContent() {
const handleStep2 = (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrorReference(undefined);
const err = validateStep2();
if (err) {
setError(err);
@ -173,6 +178,7 @@ function RegisterPageContent() {
const handleFinalSubmit = async () => {
setIsLoading(true);
setError('');
setErrorReference(undefined);
try {
await register({
@ -197,8 +203,19 @@ function RegisterPageContent() {
}),
});
router.push('/dashboard');
} catch (err: any) {
setError(err.message || t('errors.generic'));
} catch (err: unknown) {
// « Internal server error » et « Failed to fetch » ne veulent rien dire
// pour la personne qui cree son compte : on affiche ce qui s'est passe et
// ce qu'elle peut faire.
setError(
apiErrorMessage(err, {
network: t('errors.network'),
serviceUnavailable: t('errors.serviceUnavailable'),
unexpected: t('errors.unexpected'),
fallback: t('errors.generic'),
})
);
setErrorReference(apiErrorReference(err));
} finally {
setIsLoading(false);
}
@ -457,7 +474,14 @@ function RegisterPageContent() {
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="min-w-0">
<p className="text-body-sm text-red-800">{error}</p>
{errorReference && (
<p className="mt-1 text-body-xs text-red-700">
{t('errors.reference', { reference: errorReference })}
</p>
)}
</div>
</div>
)}
@ -759,6 +783,7 @@ function RegisterPageContent() {
onClick={() => {
setStep(1);
setError('');
setErrorReference(undefined);
}}
disabled={isLoading}
className="btn-secondary flex-1 text-lg disabled:opacity-50"

View File

@ -4611,7 +4611,11 @@
},
"errors": {
"emailTaken": "This email address is already in use",
"generic": "Error while creating the account"
"generic": "Error while creating the account",
"network": "The server could not be reached. Check your connection and try again.",
"serviceUnavailable": "The service is temporarily unavailable. Try again in a moment; if the problem persists, email support@xpeditis.com.",
"unexpected": "Something went wrong on our side. Your account was not created. Try again, and if it happens again, send the reference below to support.",
"reference": "Reference to give support: {reference}"
},
"sidePanel": {
"titleInvitation": "Join your team",

View File

@ -4611,7 +4611,11 @@
},
"errors": {
"emailTaken": "Cette adresse email est déjà utilisée",
"generic": "Erreur lors de la création du compte"
"generic": "Erreur lors de la création du compte",
"network": "Impossible de joindre le serveur. Vérifiez votre connexion, puis réessayez.",
"serviceUnavailable": "Service momentanément indisponible. Réessayez dans quelques instants ; si le problème persiste, écrivez à support@xpeditis.com.",
"unexpected": "Une erreur inattendue s’est produite de notre côté. Votre compte n’a pas été créé. Réessayez, et si cela se reproduit, transmettez la référence ci-dessous au support.",
"reference": "Référence à communiquer au support : {reference}"
},
"sidePanel": {
"titleInvitation": "Rejoignez votre équipe",

View File

@ -107,6 +107,11 @@ export function createMultipartHeaders(_includeAuth = true): HeadersInit {
* API Error
*/
export class ApiError extends Error {
/** Code machine renvoyé par l'API, ou `network_error` si elle est injoignable. */
public readonly code?: string;
/** Référence d'incident à transmettre au support. */
public readonly reference?: string;
constructor(
message: string,
public statusCode: number,
@ -114,6 +119,8 @@ export class ApiError extends Error {
) {
super(message);
this.name = 'ApiError';
this.code = response?.code;
this.reference = response?.reference;
}
}
@ -127,13 +134,20 @@ export async function apiRequest<T>(
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
let response: Response;
try {
response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
},
});
} catch {
// Serveur injoignable, DNS, coupure réseau : `fetch` rejette avec un
// « Failed to fetch » qui n'a rien à faire sous les yeux d'un utilisateur.
throw new ApiError('', 0, { code: 'network_error' });
}
// Handle 401 Unauthorized - token expired
// Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors

View File

@ -0,0 +1,50 @@
import { ApiError } from './client';
/**
* Message d'erreur destiné à un écran.
*
* Une page ne doit jamais afficher « Internal server error », ni « Failed to
* fetch », ni une trace : ces textes ne disent pas ce qui s'est passé, ne
* disent pas quoi faire, et n'existent dans aucune langue.
*
* L'ordre de préférence :
*
* 1. Le **code** renvoyé par l'API, traduit ici — c'est le cas des pannes
* (`service_unavailable`, `unexpected_error`) et de la perte de réseau.
* 2. Le **message** du serveur, déjà traduit et rédigé pour l'utilisateur :
* « Cette adresse email est déjà utilisée », les erreurs de validation…
* 3. Un repli fourni par l'appelant.
*/
export interface ApiErrorLabels {
network: string;
serviceUnavailable: string;
unexpected: string;
fallback: string;
}
export function apiErrorMessage(error: unknown, labels: ApiErrorLabels): string {
if (error instanceof ApiError) {
switch (error.code) {
case 'network_error':
return labels.network;
case 'service_unavailable':
return labels.serviceUnavailable;
case 'unexpected_error':
return labels.unexpected;
}
// Une liste, c'est la validation champ par champ : la première suffit à
// corriger, les suivantes sont déjà signalées sous les champs.
const message = error.response?.message;
if (Array.isArray(message) && message.length) return String(message[0]);
if (typeof message === 'string' && message) return message;
if (error.message) return error.message;
}
return labels.fallback;
}
/** Référence d'incident à montrer sous le message, quand le serveur en donne une. */
export function apiErrorReference(error: unknown): string | undefined {
return error instanceof ApiError ? error.reference : undefined;
}