veza/apps/web/src/utils/apiErrorHandler.ts

373 lines
11 KiB
TypeScript
Raw Normal View History

import { AxiosError } from 'axios';
import { isTimeoutError, TIMEOUT_MESSAGES } from './timeoutHandler';
import type { ApiError } from '@/types/api';
/**
* Helper de gestion d'erreurs API
* Transforme les erreurs Axios brutes en objets ApiError standardisés
* selon le format défini dans FRONTEND_INTEGRATION.md
*/
2026-01-07 18:39:21 +00:00
/**
* Parse une erreur Axios en ApiError standardisé
* @param error - Erreur Axios ou Error
* @returns ApiError formaté selon le contrat backend
*/
// Structure interne de l'erreur backend
interface BackendErrorDetail {
code?: number | string;
message?: string;
details?: unknown;
retry_after?: number;
}
// Structure de réponse standardisée { success: false, error: ... }
interface StandardErrorResponse {
success: boolean;
error: BackendErrorDetail;
}
// Structure { error: ... } (Middleware différent)
interface NestedErrorResponse {
error: BackendErrorDetail;
}
// Structure directe { code: ..., message: ... }
interface DirectErrorResponse {
code: number | string;
message: string;
details?: unknown;
}
/**
* Parse une erreur Axios en ApiError standardisé
* @param error - Erreur Axios ou Error
* @returns ApiError formaté selon le contrat backend
*/
export function parseApiError(error: unknown): ApiError {
// Si c'est déjà une ApiError, la retourner telle quelle
if (isApiError(error)) {
return error;
}
// Si c'est une erreur Axios
if (isAxiosError(error)) {
const axiosError = error as AxiosError;
2026-01-07 18:39:21 +00:00
const responseData = axiosError.response?.data;
2026-01-07 18:39:21 +00:00
// Type guard helpers locaux
const isStandardError = (data: unknown): data is StandardErrorResponse => {
return (
typeof data === 'object' &&
data !== null &&
'success' in data &&
(data as any).success === false &&
'error' in data
);
};
2026-01-07 18:39:21 +00:00
const isNestedError = (data: unknown): data is NestedErrorResponse => {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as any).error === 'object'
);
};
const isDirectError = (data: unknown): data is DirectErrorResponse => {
return (
typeof data === 'object' &&
data !== null &&
'code' in data &&
'message' in data
);
};
if (responseData) {
// 1. Format standardisé { success: false, error: {...} }
if (isStandardError(responseData)) {
return normalizeApiError(responseData.error);
2025-12-22 21:00:50 +00:00
}
2026-01-07 18:39:21 +00:00
// 2. Format { error: {...} } sans success property
if (isNestedError(responseData)) {
const backendError = responseData.error;
if (backendError && ('code' in backendError || 'message' in backendError)) {
return normalizeApiError(backendError);
}
}
// 3. Format direct { code: ..., message: ... }
if (isDirectError(responseData)) {
return normalizeApiError(responseData);
}
}
// Erreur réseau (pas de réponse)
if (axiosError.request && !axiosError.response) {
// Check if it's a timeout error
if (isTimeoutError(axiosError)) {
return {
code: 0,
message: TIMEOUT_MESSAGES.timeout,
timestamp: new Date().toISOString(),
};
}
return {
code: 0,
message: 'Network error: Unable to connect to server',
timestamp: new Date().toISOString(),
};
}
2025-12-22 21:00:50 +00:00
// Gestion spécifique des codes HTTP d'erreur
const status = axiosError.response?.status;
if (status === 429) {
// Too Many Requests - Rate limiting
const headers = axiosError.response?.headers || {};
2026-01-07 18:39:21 +00:00
const data = responseData as { error?: { message?: string; retry_after?: number } } | null;
const rateLimitLimit = headers['x-ratelimit-limit']
? parseInt(String(headers['x-ratelimit-limit']), 10)
: undefined;
2026-01-07 18:39:21 +00:00
const rateLimitRemaining = headers['x-ratelimit-remaining']
? parseInt(String(headers['x-ratelimit-remaining']), 10)
: undefined;
2026-01-07 18:39:21 +00:00
const rateLimitReset = headers['x-ratelimit-reset']
? parseInt(String(headers['x-ratelimit-reset']), 10)
: undefined;
2026-01-07 18:39:21 +00:00
const retryAfter = headers['retry-after']
? parseInt(String(headers['retry-after']), 10)
: (data?.error?.retry_after || 60);
2026-01-07 18:39:21 +00:00
const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000) : undefined;
2026-01-07 18:39:21 +00:00
const secondsUntilReset = resetTime
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
: retryAfter;
2026-01-07 18:39:21 +00:00
2025-12-22 21:00:50 +00:00
return {
code: 429,
message:
data?.error?.message ||
2025-12-22 21:00:50 +00:00
'Trop de requêtes. Veuillez patienter avant de réessayer.',
timestamp: new Date().toISOString(),
details: [
{
field: 'rate_limit',
message: `Limite de ${rateLimitLimit || 'N/A'} requêtes atteinte. Réessayez dans ${secondsUntilReset} seconde${secondsUntilReset > 1 ? 's' : ''}.`,
},
...(rateLimitRemaining !== undefined
? [
2026-01-07 18:39:21 +00:00
{
field: 'remaining',
message: `${rateLimitRemaining} requête${rateLimitRemaining > 1 ? 's' : ''} restante${rateLimitRemaining > 1 ? 's' : ''}`,
},
]
: []),
],
retry_after: secondsUntilReset,
2025-12-22 21:00:50 +00:00
};
}
if (status === 503) {
2026-01-07 18:39:21 +00:00
const data = responseData as { message?: string; details?: unknown } | null;
2025-12-22 21:00:50 +00:00
return {
code: 503,
message:
2026-01-07 18:39:21 +00:00
data?.message ||
2025-12-22 21:00:50 +00:00
'Service temporairement indisponible. Veuillez réessayer dans quelques instants.',
timestamp: new Date().toISOString(),
2026-01-07 18:39:21 +00:00
details: normalizeDetails(data?.details),
2025-12-22 21:00:50 +00:00
};
}
if (status === 502) {
2026-01-07 18:39:21 +00:00
const data = responseData as { message?: string; details?: unknown } | null;
2025-12-22 21:00:50 +00:00
return {
code: 502,
message:
2026-01-07 18:39:21 +00:00
data?.message ||
2025-12-22 21:00:50 +00:00
'Erreur de communication avec le serveur. Veuillez réessayer plus tard.',
timestamp: new Date().toISOString(),
2026-01-07 18:39:21 +00:00
details: normalizeDetails(data?.details),
2025-12-22 21:00:50 +00:00
};
}
// Erreur HTTP sans format standardisé
2026-01-07 18:39:21 +00:00
const data = responseData as { message?: string } | null;
return {
2025-12-22 21:00:50 +00:00
code: status || 0,
message:
2026-01-07 18:39:21 +00:00
data?.message ||
axiosError.message ||
'An unexpected error occurred',
timestamp: new Date().toISOString(),
};
}
// Erreur JavaScript standard
if (error instanceof Error) {
return {
code: 0,
message: error.message || 'An unexpected error occurred',
timestamp: new Date().toISOString(),
};
}
// Erreur inconnue
return {
code: 0,
message: 'An unexpected error occurred',
timestamp: new Date().toISOString(),
};
}
/**
* Normalise un objet d'erreur backend en ApiError standardisé
*/
2026-01-07 18:39:21 +00:00
// Interface interne pour normalizeApiError dont on ne connait pas la structure exacte avant runtime
interface RawBackendError {
code?: unknown;
message?: unknown;
details?: unknown;
request_id?: unknown;
timestamp?: unknown;
context?: unknown;
}
interface ErrorDetail {
field: string;
message: string;
value?: string;
}
/**
* Normalise les détails d'une erreur
*/
function normalizeDetails(details: unknown): ErrorDetail[] | undefined {
if (!Array.isArray(details)) {
return undefined;
}
// Filtrer pour ne garder que les objets valides qui ressemblent à des ErrorDetail
const validDetails = details.filter((item): item is ErrorDetail => {
return (
typeof item === 'object' &&
item !== null &&
'field' in item &&
'message' in item &&
typeof (item as any).field === 'string' &&
typeof (item as any).message === 'string'
);
});
return validDetails.length > 0 ? validDetails : undefined;
}
/**
* Normalise le contexte d'une erreur
*/
function normalizeContext(context: unknown): Record<string, any> | undefined {
if (typeof context === 'object' && context !== null && !Array.isArray(context)) {
return context as Record<string, any>;
}
return undefined;
}
/**
* Normalise un objet d'erreur backend en ApiError standardisé
*/
function normalizeApiError(error: unknown): ApiError {
const err = error as RawBackendError;
return {
2026-01-07 18:39:21 +00:00
code: typeof err.code === 'number' ? err.code : parseInt(String(err.code || 0), 10),
message: typeof err.message === 'string' ? err.message : 'An error occurred',
details: normalizeDetails(err.details),
request_id: typeof err.request_id === 'string' ? err.request_id : undefined,
timestamp: typeof err.timestamp === 'string' ? err.timestamp : new Date().toISOString(),
context: normalizeContext(err.context),
};
}
/**
* Formate un message d'erreur pour l'affichage dans l'UI
* @param error - ApiError
* @param includeRequestId - Si true, inclut le request_id dans le message (pour debugging)
* @returns Message formaté pour l'utilisateur
*/
export function formatErrorMessage(
error: ApiError,
includeRequestId: boolean = false,
): string {
let message = error.message;
// Si l'erreur a des détails de validation, les inclure
if (error.details && Array.isArray(error.details) && error.details.length > 0) {
const detailsMessages = error.details
.map((detail) => `${detail.field}: ${detail.message}`)
.join(', ');
message = `${error.message} (${detailsMessages})`;
}
// Optionnellement inclure le request_id pour le debugging (en mode développement)
if (includeRequestId && error.request_id) {
const isDev = import.meta.env.DEV;
if (isDev) {
message = `${message} [Request ID: ${error.request_id}]`;
}
}
return message;
}
/**
* Extrait les erreurs de validation par champ
* @param error - ApiError
* @returns Record avec les erreurs par champ (field -> message)
*/
export function getValidationErrors(
error: ApiError,
): Record<string, string> {
if (!error.details || !Array.isArray(error.details)) {
return {};
}
const errors: Record<string, string> = {};
for (const detail of error.details) {
if (detail.field && detail.message) {
errors[detail.field] = detail.message;
}
}
return errors;
}
/**
* Vérifie si une erreur est une ApiError
*/
function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
typeof (error as any).code === 'number' &&
typeof (error as any).message === 'string'
);
}
/**
* Vérifie si une erreur est une AxiosError
*/
function isAxiosError(error: unknown): error is AxiosError {
return (
typeof error === 'object' &&
error !== null &&
'isAxiosError' in error &&
(error as any).isAxiosError === true
);
}