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 */ /** * 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; const responseData = axiosError.response?.data; // 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 ); }; 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); } // 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(), }; } // 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 || {}; 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; const rateLimitRemaining = headers['x-ratelimit-remaining'] ? parseInt(String(headers['x-ratelimit-remaining']), 10) : undefined; const rateLimitReset = headers['x-ratelimit-reset'] ? parseInt(String(headers['x-ratelimit-reset']), 10) : undefined; const retryAfter = headers['retry-after'] ? parseInt(String(headers['retry-after']), 10) : (data?.error?.retry_after || 60); const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000) : undefined; const secondsUntilReset = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : retryAfter; return { code: 429, message: data?.error?.message || '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 ? [ { field: 'remaining', message: `${rateLimitRemaining} requête${rateLimitRemaining > 1 ? 's' : ''} restante${rateLimitRemaining > 1 ? 's' : ''}`, }, ] : []), ], retry_after: secondsUntilReset, }; } if (status === 503) { const data = responseData as { message?: string; details?: unknown } | null; return { code: 503, message: data?.message || 'Service temporairement indisponible. Veuillez réessayer dans quelques instants.', timestamp: new Date().toISOString(), details: normalizeDetails(data?.details), }; } if (status === 502) { const data = responseData as { message?: string; details?: unknown } | null; return { code: 502, message: data?.message || 'Erreur de communication avec le serveur. Veuillez réessayer plus tard.', timestamp: new Date().toISOString(), details: normalizeDetails(data?.details), }; } // Erreur HTTP sans format standardisé const data = responseData as { message?: string } | null; return { code: status || 0, message: 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é */ // 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 | undefined { if (typeof context === 'object' && context !== null && !Array.isArray(context)) { return context as Record; } return undefined; } /** * Normalise un objet d'erreur backend en ApiError standardisé */ function normalizeApiError(error: unknown): ApiError { const err = error as RawBackendError; return { 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 { if (!error.details || !Array.isArray(error.details)) { return {}; } const errors: Record = {}; 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 ); }