merge: integrer fix/messages-erreur-api
This commit is contained in:
commit
f418443d0c
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
50
apps/frontend/src/lib/api/errors.ts
Normal file
50
apps/frontend/src/lib/api/errors.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user