import { AxiosError } from 'axios'; import { isTimeoutError, TIMEOUT_MESSAGES } from './timeoutHandler'; import { isOffline } from './offlineDetection'; import type { ApiError } from '@/schemas/apiSchemas'; /** * 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) // Action 3.5.1.1: Enhance network error detection - distinguish timeout vs connection refused vs offline 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(), }; } // Check for connection refused (server is down or unreachable) if ( axiosError.code === 'ECONNREFUSED' || axiosError.code === 'ERR_CONNECTION_REFUSED' ) { return { code: 0, message: 'Connection refused: The server is not responding. Please try again later.', timestamp: new Date().toISOString(), }; } // Action 3.5.1.3: Integrate offline detection with error handler // Check for network unreachable or no internet connection if ( axiosError.code === 'ENETUNREACH' || axiosError.code === 'ERR_NETWORK' || axiosError.code === 'ERR_INTERNET_DISCONNECTED' || isOffline() ) { return { code: 0, message: 'No internet connection: Please check your network settings and try again.', timestamp: new Date().toISOString(), }; } // Generic network error fallback return { code: 0, message: 'Network error: Unable to connect to server. Please check your connection and try again.', 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})`; } // Action 5.3.1.1: Always include request_id when requested (not just in development) if (includeRequestId && error.request_id) { message = `${message} [Request ID: ${error.request_id}]`; } return message; } /** * Error category types for error handling strategy */ export type ErrorCategory = | 'network' | 'validation' | 'authentication' | 'authorization' | 'not_found' | 'rate_limit' | 'server_error' | 'timeout' | 'unknown'; /** * Categorizes an ApiError into a specific error category * Used for determining appropriate error handling strategy (display, retry, redirect, etc.) * * @param error - ApiError to categorize * @returns ErrorCategory indicating the type of error * * @example * ```tsx * const category = getErrorCategory(apiError); * if (category === 'network') { * // Show offline indicator * } else if (category === 'authentication') { * // Redirect to login * } * ``` */ export function getErrorCategory( error: ApiError | Error | unknown, ): ErrorCategory { // Check for AxiosError patterns first (before ApiError) if (error && typeof error === 'object' && 'isAxiosError' in error) { const axiosError = error as AxiosError; // Network error (no response) if (!axiosError.response && axiosError.request) { return 'network'; } // Timeout if (axiosError.code === 'ECONNABORTED' || axiosError.code === 'ETIMEDOUT') { return 'timeout'; } } // Handle ApiError if (error && typeof error === 'object' && 'code' in error) { const apiError = error as ApiError; const code = typeof apiError.code === 'number' ? apiError.code : parseInt(String(apiError.code || 0), 10); // Network errors (code 0 typically indicates network issues) if (code === 0) { return 'network'; } // HTTP status code based categorization if (code >= 400 && code < 500) { // Client errors if (code === 401) { return 'authentication'; } if (code === 403) { return 'authorization'; } if (code === 404) { return 'not_found'; } if (code === 422) { // Validation errors typically have details array if ( apiError.details && Array.isArray(apiError.details) && apiError.details.length > 0 ) { return 'validation'; } return 'validation'; } if (code === 429) { return 'rate_limit'; } // Other 4xx errors return 'validation'; } if (code >= 500 && code < 600) { // Server errors if (code === 504 || code === 408) { return 'timeout'; } return 'server_error'; } } // Handle standard Error objects if (error instanceof Error) { const message = error.message.toLowerCase(); const name = error.name.toLowerCase(); // Network-related errors if ( message.includes('network') || message.includes('fetch') || message.includes('connection') || message.includes('offline') || name === 'networkerror' || name === 'typeerror' ) { return 'network'; } // Timeout errors if (message.includes('timeout') || name === 'timeouterror') { return 'timeout'; } // Abort errors if (message.includes('abort') || name === 'aborterror') { return 'network'; } } return 'unknown'; } /** * 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 ); }