import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; import { TokenStorage } from '../tokenStorage'; import { refreshToken } from '../tokenRefresh'; import { env } from '@/config/env'; import { parseApiError } from '@/utils/apiErrorHandler'; import { csrfService } from '../csrf'; import type { ApiResponse } from '@/types/api'; /** * Client API avec interceptors pour refresh automatique des tokens * et unwrapping du format backend { success, data, error } * Aligné avec FRONTEND_INTEGRATION.md */ // Client API réutilisable export const apiClient = axios.create({ baseURL: env.API_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // Flag pour éviter les refresh en boucle let isRefreshing = false; let failedQueue: Array<{ resolve: (value?: any) => void; reject: (error?: any) => void; }> = []; /** * Sleep utility function */ const sleep = (ms: number): Promise => { return new Promise((resolve) => setTimeout(resolve, ms)); }; /** * Get retry delay from Retry-After header or use exponential backoff */ const getRetryDelay = ( error: AxiosError, attempt: number, baseDelay: number = 1000, ): number => { // Check for Retry-After header (case-insensitive) const retryAfterHeader = error.response?.headers['retry-after'] || error.response?.headers['Retry-After']; if (retryAfterHeader) { const delay = parseInt(String(retryAfterHeader), 10); if (!isNaN(delay) && delay > 0) { return delay * 1000; // Convert to milliseconds } } // Exponential backoff: baseDelay * 2^attempt return baseDelay * Math.pow(2, attempt); }; // T0177: Fonction pour traiter la queue de requêtes en attente const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error); } else { prom.resolve(token); } }); failedQueue = []; }; // T0177: Interceptor de requête pour ajouter le token d'accès // CRITIQUE: Récupérer TOUJOURS le token frais depuis localStorage car Zustand peut ne pas être hydraté apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = TokenStorage.getAccessToken(); if (token && config.headers) { config.headers.Authorization = `Bearer ${token} `; } // Pour FormData, laisser Axios gérer automatiquement le Content-Type avec boundary // Ne pas forcer application/json si c'est un FormData if (config.data instanceof FormData && config.headers) { // Supprimer Content-Type pour que Axios calcule automatiquement multipart/form-data avec boundary delete config.headers['Content-Type']; } // Ajouter le token CSRF pour les méthodes qui modifient l'état const method = config.method?.toUpperCase(); const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || ''); const isCSRFRoute = config.url?.includes('/csrf-token'); if (isStateChanging && !isCSRFRoute && config.headers) { const csrfToken = csrfService.getToken(); if (csrfToken) { config.headers['X-CSRF-Token'] = csrfToken; } } return config; }, (error) => { return Promise.reject(error); }, ); // Interceptor de réponse pour unwrap le format backend et gérer les erreurs apiClient.interceptors.response.use( (response: AxiosResponse>) => { // Backend retourne { success: true, data: {...} } // On unwrap pour retourner directement data if (response.data && typeof response.data === 'object' && 'success' in response.data) { if (response.data.success === true) { // Retourner directement data, pas le wrapper return { ...response, data: response.data.data, } as AxiosResponse; } // Si success === false, l'erreur sera gérée par le catch // Mais on devrait normalement ne jamais arriver ici car le backend // retourne un status HTTP d'erreur dans ce cas } // Si pas de format wrapper, retourner la réponse telle quelle return response; }, async (error: AxiosError>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // Détecter 401 et refresh automatiquement // EXCLURE l'endpoint /auth/refresh pour éviter les boucles infinies const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh'); if ( error.response?.status === 401 && originalRequest && !originalRequest._retry && !isRefreshEndpoint ) { // Éviter les refresh multiples simultanés if (isRefreshing) { // Si un refresh est en cours, mettre la requête en queue return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then((token) => { if (originalRequest.headers && token) { originalRequest.headers.Authorization = `Bearer ${token} `; } return apiClient(originalRequest); }) .catch((err) => { return Promise.reject(err); }); } originalRequest._retry = true; isRefreshing = true; try { // Refresh automatique du token await refreshToken(); const newToken = TokenStorage.getAccessToken(); if (!newToken) { throw new Error('Failed to get new access token after refresh'); } if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken} `; } // Traiter la queue et retry la requête originale processQueue(null, newToken); return apiClient(originalRequest); } catch (refreshError) { // Gérer cas refresh échoué processQueue(refreshError as Error, null); // Nettoyer les tokens TokenStorage.clearTokens(); // Stocker un message d'erreur pour l'afficher après redirection if (typeof window !== 'undefined') { sessionStorage.setItem( 'auth_error', 'Your session has expired. Please log in again.', ); // Rediriger vers login si refresh échoue (seulement dans le navigateur) window.location.href = '/login'; } return Promise.reject(refreshError); } finally { isRefreshing = false; } } // Gestion spécifique des erreurs 429, 503, 502 const status = error.response?.status; if (status === 429) { // Too Many Requests - Retry après delay const apiError = parseApiError(error); const retryAfter = apiError.retry_after || 5; // Default 5 secondes // Log error with request_id for correlation if (apiError.request_id) { console.warn( `[API Rate Limit] ${apiError.message} (Request ID: ${apiError.request_id}, Retry after: ${retryAfter}s)`, { code: apiError.code, request_id: apiError.request_id, retry_after: retryAfter, url: originalRequest?.url, method: originalRequest?.method, }, ); } // Si la requête n'a pas encore été retentée, attendre et réessayer if (originalRequest && !originalRequest._retry && retryAfter > 0) { originalRequest._retry = true; // Attendre le délai spécifié avant de réessayer await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); // Réessayer la requête une seule fois return apiClient(originalRequest); } // Si déjà retentée ou retry_after invalide, rejeter avec l'erreur return Promise.reject(apiError); } // Retry logic for 502/503 errors (transient errors) if (status === 502 || status === 503) { // Service Unavailable (503) or Bad Gateway (502) - Retry with exponential backoff // Check if this request has already been retried (to avoid infinite loops) const retryCount = (originalRequest as any)._retry502503Count || 0; const maxRetries = 3; if (originalRequest && retryCount < maxRetries) { // Mark that we're retrying this request (originalRequest as any)._retry502503Count = retryCount + 1; // Calculate delay (respect Retry-After header if present, otherwise exponential backoff) const delay = getRetryDelay(error, retryCount, 1000); // Log retry attempt with request_id if available const apiError = parseApiError(error); if (apiError.request_id) { console.warn( `[API Retry] ${status} error, retrying (${retryCount + 1}/${maxRetries}) - Request ID: ${apiError.request_id}`, { status, retry_count: retryCount + 1, max_retries: maxRetries, delay_ms: delay, request_id: apiError.request_id, url: originalRequest?.url, method: originalRequest?.method, }, ); } // Wait before retrying return sleep(delay).then(() => { // Retry the request return apiClient(originalRequest); }); } // If already retried maxRetries times, reject immediately const apiError = parseApiError(error); // Log final error with request_id after all retries failed if (apiError.request_id) { console.error( `[API Error] ${status} error after ${maxRetries} retries - Request ID: ${apiError.request_id}`, { code: apiError.code, message: apiError.message, request_id: apiError.request_id, timestamp: apiError.timestamp, url: originalRequest?.url, method: originalRequest?.method, }, ); } return Promise.reject(apiError); } // Parser l'erreur en ApiError standardisé pour les autres codes const apiError = parseApiError(error); // Log error with request_id for correlation with backend logs if (apiError.request_id) { console.error( `[API Error] ${apiError.message} (Code: ${apiError.code}, Request ID: ${apiError.request_id})`, { code: apiError.code, message: apiError.message, request_id: apiError.request_id, timestamp: apiError.timestamp, details: apiError.details, context: apiError.context, url: originalRequest?.url, method: originalRequest?.method, }, ); } else { // Log without request_id if not available console.error( `[API Error] ${apiError.message} (Code: ${apiError.code})`, { code: apiError.code, message: apiError.message, timestamp: apiError.timestamp, details: apiError.details, url: originalRequest?.url, method: originalRequest?.method, }, ); } return Promise.reject(apiError); }, );