import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; import toast from 'react-hot-toast'; import { TokenStorage } from '../tokenStorage'; import { refreshToken } from '../tokenRefresh'; import { env } from '@/config/env'; import { parseApiError } from '@/utils/apiErrorHandler'; import { csrfService } from '../csrf'; import { logger } from '@/utils/logger'; import { isTimeoutError, getTimeoutMessage } from '@/utils/timeoutHandler'; import { offlineQueue } from '../offlineQueue'; import { requestDeduplication } from '../requestDeduplication'; import { responseCache } from '../responseCache'; import { invalidateStateAfterMutation } from '@/utils/stateInvalidation'; import type { ApiResponse } from '@/types/api'; /** * Client API avec interceptors pour refresh automatique des tokens, * unwrapping du format backend { success, data, error }, * et retry automatique avec exponential backoff * 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)); }; /** * Retry configuration */ interface RetryConfig { maxRetries: number; baseDelay: number; maxDelay: number; retryableStatusCodes: number[]; retryableNetworkErrors: string[]; } const DEFAULT_RETRY_CONFIG: RetryConfig = { maxRetries: 3, baseDelay: 1000, // 1 second maxDelay: 10000, // 10 seconds retryableStatusCodes: [429, 500, 502, 503, 504], // Rate limit, server errors, gateway errors retryableNetworkErrors: [ 'ECONNABORTED', // Timeout 'ETIMEDOUT', // Timeout 'ENOTFOUND', // DNS error 'ECONNREFUSED', // Connection refused 'ECONNRESET', // Connection reset 'EAI_AGAIN', // DNS lookup failed 'Network Error', // Generic network error ], }; /** * Check if a request method is idempotent (safe to retry) */ const isIdempotentMethod = (method?: string): boolean => { const idempotentMethods = ['GET', 'HEAD', 'OPTIONS']; return method ? idempotentMethods.includes(method.toUpperCase()) : false; }; /** * Check if an error is retryable */ const isRetryableError = (error: AxiosError, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean => { // Don't retry if request was cancelled if (axios.isCancel(error)) { return false; } // Check if retry is disabled for this request if ((error.config as any)?._disableRetry) { return false; } // Check status code if (error.response?.status) { return config.retryableStatusCodes.includes(error.response.status); } // Check network errors if (error.code) { return config.retryableNetworkErrors.includes(error.code); } // Check error message for network-related errors if (error.message) { const message = error.message.toLowerCase(); const networkErrorPatterns = ['network', 'timeout', 'connection', 'econn', 'etimedout', 'enotfound']; return networkErrorPatterns.some((pattern) => message.includes(pattern)); } // For errors without response (network errors), retry if it's an idempotent method if (!error.response && error.request) { return isIdempotentMethod(error.config?.method); } return false; }; /** * Get retry delay from Retry-After header or use exponential backoff with jitter */ const getRetryDelay = ( error: AxiosError, attempt: number, baseDelay: number = DEFAULT_RETRY_CONFIG.baseDelay, maxDelay: number = DEFAULT_RETRY_CONFIG.maxDelay, ): 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 Math.min(delay * 1000, maxDelay); // Convert to milliseconds, cap at maxDelay } } // Exponential backoff with jitter: baseDelay * 2^attempt + random(0, baseDelay) const exponentialDelay = baseDelay * Math.pow(2, attempt); const jitter = Math.random() * baseDelay; // Add jitter to avoid thundering herd return Math.min(exponentialDelay + jitter, maxDelay); }; /** * Sanitize sensitive data from request/response for logging */ const sanitizeForLogging = (data: any): any => { if (!data || typeof data !== 'object') { return data; } const sensitiveKeys = ['password', 'token', 'access_token', 'refresh_token', 'secret', 'authorization', 'x-csrf-token']; const sanitized = Array.isArray(data) ? [...data] : { ...data }; for (const key in sanitized) { const lowerKey = key.toLowerCase(); if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { sanitized[key] = '[REDACTED]'; } else if (typeof sanitized[key] === 'object' && sanitized[key] !== null) { sanitized[key] = sanitizeForLogging(sanitized[key]); } } return sanitized; }; /** * Get request ID from headers or generate one */ const getRequestId = (config: InternalAxiosRequestConfig): string => { // Try to get request_id from headers (if set by caller) const requestId = (config.headers as any)?.['X-Request-ID'] || (config.headers as any)?.['x-request-id'] || `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Store in config for later use (config as any)._requestId = requestId; return requestId; }; // 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; } } // Support AbortController: si un signal est fourni dans la config, l'utiliser // Sinon, créer un nouveau AbortController si nécessaire if (!config.signal && !config.cancelToken) { // Si aucune annulation n'est configurée, on peut créer un AbortController optionnel // Mais on ne le fait pas automatiquement pour éviter de créer des signaux inutiles // Les utilisateurs peuvent passer un signal via config.signal } // Store request start time for duration calculation (config as any)._requestStartTime = Date.now(); // Log request (only in development or if explicitly enabled) if (import.meta.env.DEV || (config as any)?._enableLogging) { const requestId = getRequestId(config); const sanitizedHeaders = sanitizeForLogging({ ...config.headers }); const sanitizedData = sanitizeForLogging(config.data); logger.debug(`[API Request] ${method || 'GET'} ${config.url}`, { request_id: requestId, method: method || 'GET', url: config.url, baseURL: config.baseURL, headers: sanitizedHeaders, params: config.params, data: sanitizedData, timeout: config.timeout, signal: config.signal ? 'AbortController' : undefined, }); } return config; }, (error) => { // Log request error if (import.meta.env.DEV) { logger.error('[API Request Error]', { error: error.message, config: error.config ? { url: error.config.url, method: error.config.method, } : undefined, }); } return Promise.reject(error); }, ); // Interceptor de réponse pour unwrap le format backend et gérer les erreurs apiClient.interceptors.response.use( (response: AxiosResponse | any>) => { // Log successful response (only in development or if explicitly enabled) const requestId = (response.config as any)?._requestId; const shouldLog = import.meta.env.DEV || (response.config as any)?._enableLogging; if (shouldLog) { const sanitizedData = sanitizeForLogging(response.data); const sanitizedHeaders = sanitizeForLogging(response.headers); logger.debug(`[API Response] ${response.config.method?.toUpperCase() || 'GET'} ${response.config.url} ${response.status}`, { request_id: requestId, status: response.status, statusText: response.statusText, headers: sanitizedHeaders, data: sanitizedData, duration: (response.config as any)?._requestStartTime ? Date.now() - (response.config as any)._requestStartTime : undefined, }); } // Backend peut retourner plusieurs formats : // 1. Format standard avec wrapper: { success: true, data: {...} } // 2. Format direct JSON: { tracks: [...], pagination: {...} } (ex: SearchTracks, ListTracks) // 3. Format avec message: { success: true, data: {...}, message: "..." } if (!response.data || typeof response.data !== 'object') { // Si response.data n'est pas un objet, retourner tel quel return response; } // FE-COMP-005: Show success toast for mutation operations if enabled const method = response.config.method?.toUpperCase(); const isMutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || ''); const shouldShowSuccessToast = isMutation && (response.config as any)?._showSuccessToast && typeof window !== 'undefined'; if (shouldShowSuccessToast) { const successMessage = (response.config as any)?._successMessage || (response.data as any)?.message || getDefaultSuccessMessage(method || ''); if (successMessage) { toast.success(successMessage); } } // FE-API-017: Cache GET responses if (method === 'GET' && !(response.config as any)?._disableCache) { responseCache.set(response.config, response); } // FE-API-017: Invalidate cache on mutations // FE-STATE-004: Invalidate state after mutations if (isMutation) { const url = response.config.url || ''; const method = response.config.method || 'POST'; // Use centralized invalidation system invalidateStateAfterMutation(url, method); } // Vérifier si c'est le format wrapper avec success if ('success' in response.data) { if (response.data.success === true) { // Format wrapper standard: { success: true, data: {...} } // On unwrap pour retourner directement data // Si data est null/undefined, on retourne null au lieu de undefined const unwrappedData = response.data.data !== undefined ? response.data.data : null; return { ...response, data: unwrappedData, } 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 (format direct JSON), retourner la réponse telle quelle // Exemples: { tracks: [...], pagination: {...} }, { user: {...}, token: {...} } return response; }, async (error: AxiosError>) => { // Don't retry or process cancelled requests if (axios.isCancel(error)) { const requestId = (error.config as any)?._requestId; if (import.meta.env.DEV || (error.config as any)?._enableLogging) { logger.debug(`[API Request Cancelled] ${error.config?.method?.toUpperCase() || 'GET'} ${error.config?.url}`, { request_id: requestId, }); } return Promise.reject(error); } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // Log error response (only in development or if explicitly enabled) const requestId = (originalRequest as any)?._requestId; const shouldLog = import.meta.env.DEV || (originalRequest as any)?._enableLogging; if (shouldLog && error.response) { const sanitizedErrorData = sanitizeForLogging(error.response.data); const sanitizedHeaders = sanitizeForLogging(error.response.headers); logger.error(`[API Error Response] ${originalRequest?.method?.toUpperCase() || 'GET'} ${originalRequest?.url} ${error.response.status}`, { request_id: requestId, status: error.response.status, statusText: error.response.statusText, headers: sanitizedHeaders, data: sanitizedErrorData, duration: (originalRequest as any)?._requestStartTime ? Date.now() - (originalRequest as any)._requestStartTime : undefined, }); } else if (shouldLog && error.request && !error.response) { // Network error (no response received) logger.error(`[API Network Error] ${originalRequest?.method?.toUpperCase() || 'GET'} ${originalRequest?.url}`, { request_id: requestId, message: error.message, code: error.code, duration: (originalRequest as any)?._requestStartTime ? Date.now() - (originalRequest as any)._requestStartTime : undefined, }); } // 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; } } // Unified retry logic for all retryable errors const status = error.response?.status; const retryCount = (originalRequest as any)?._retryCount || 0; const maxRetries = DEFAULT_RETRY_CONFIG.maxRetries; // Check if error is retryable if (isRetryableError(error, DEFAULT_RETRY_CONFIG) && originalRequest && retryCount < maxRetries) { // For non-idempotent methods (POST, PUT, DELETE, PATCH), only retry on specific errors const method = originalRequest.method?.toUpperCase(); const isIdempotent = isIdempotentMethod(method); // For non-idempotent methods, only retry on network errors or 5xx errors (not 429) if (!isIdempotent && status && status !== 500 && status !== 502 && status !== 503 && status !== 504) { // Don't retry non-idempotent methods on client errors (except 5xx) const apiError = parseApiError(error); return Promise.reject(apiError); } // Mark that we're retrying this request (originalRequest as any)._retryCount = retryCount + 1; // Calculate delay (respect Retry-After header if present, otherwise exponential backoff with jitter) const delay = getRetryDelay(error, retryCount, DEFAULT_RETRY_CONFIG.baseDelay, DEFAULT_RETRY_CONFIG.maxDelay); // Log retry attempt with request_id if available const apiError = parseApiError(error); const errorType = status ? `HTTP ${status}` : error.code || 'Network Error'; if (apiError.request_id) { console.warn( `[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries}) - Request ID: ${apiError.request_id}`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: retryCount + 1, max_retries: maxRetries, delay_ms: Math.round(delay), request_id: apiError.request_id, url: originalRequest?.url, method: originalRequest?.method, is_idempotent: isIdempotent, }, ); } else { console.warn( `[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries})`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: retryCount + 1, max_retries: maxRetries, delay_ms: Math.round(delay), url: originalRequest?.url, method: originalRequest?.method, is_idempotent: isIdempotent, }, ); } // Wait before retrying return sleep(delay).then(() => { // Retry the request return apiClient(originalRequest); }); } // If already retried maxRetries times or error is not retryable, reject immediately if (retryCount >= maxRetries) { const apiError = parseApiError(error); const errorType = status ? `HTTP ${status}` : error.code || 'Network Error'; // Log final error with request_id after all retries failed if (apiError.request_id) { console.error( `[API Error] ${errorType} 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, }, ); } else { console.error( `[API Error] ${errorType} error after ${maxRetries} retries`, { code: apiError.code, message: apiError.message, 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); // FE-COMP-005: Show toast notification for API errors (unless disabled) const shouldShowToast = !(originalRequest as any)?._disableToast && status !== 401 && // Don't show toast for 401 (handled by refresh) status !== 404 && // Don't show toast for 404 (handled by router) !axios.isCancel(error); // Don't show toast for cancelled requests if (shouldShowToast && typeof window !== 'undefined') { // Get user-friendly error message let errorMessage = apiError.message; // Customize message based on status code if (status === 403) { errorMessage = "Vous n'avez pas les permissions nécessaires pour effectuer cette action"; } else if (status === 422) { errorMessage = apiError.details?.[0]?.message || apiError.message || "Erreur de validation"; } else if (status === 429) { errorMessage = "Trop de requêtes. Veuillez patienter quelques instants"; } else if (status >= 500) { errorMessage = "Une erreur serveur s'est produite. Veuillez réessayer plus tard"; } else if (!error.response) { // Check if it's a timeout error if (isTimeoutError(error)) { errorMessage = getTimeoutMessage('normal'); } else { errorMessage = "Erreur de connexion. Vérifiez votre connexion internet"; } // FE-API-015: Queue request for offline replay if it's a network error if (originalRequest && offlineQueue.shouldQueueRequest(originalRequest)) { const isOffline = typeof navigator !== 'undefined' && !navigator.onLine; if (isOffline || (!error.response && error.request)) { // Determine priority based on request type const method = originalRequest.method?.toUpperCase(); const priority = method === 'DELETE' ? 'low' : method === 'POST' ? 'high' : 'normal'; try { await offlineQueue.queueRequest(originalRequest, { priority }); // Show info toast that request was queued toast.success('Requête mise en file d\'attente. Elle sera envoyée à la reconnexion.', { duration: 4000, }); } catch (queueError) { logger.error('[API] Failed to queue request for offline replay', { error: queueError }); } } } } toast.error(errorMessage, { duration: status === 429 ? 8000 : 5000, // Longer duration for rate limit errors }); } // 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); }, ); /** * FE-COMP-005: Get default success message based on HTTP method */ function getDefaultSuccessMessage(method: string): string { switch (method) { case 'POST': return 'Opération réussie'; case 'PUT': case 'PATCH': return 'Modification réussie'; case 'DELETE': return 'Suppression réussie'; default: return ''; } } /** * Helper function to create a cancellable request * Returns an object with the request promise and an abort function * * @example * ```typescript * const { request, abort } = createCancellableRequest((signal) => * apiClient.get('/api/v1/tracks', { signal }) * ); * * // Later, to cancel: * abort(); * ``` */ export function createCancellableRequest( requestFn: (signal: AbortSignal) => Promise, ): { request: Promise; abort: () => void } { const abortController = new AbortController(); const signal = abortController.signal; const request = requestFn(signal); return { request, abort: () => { abortController.abort(); }, }; } /** * Helper function to create a request with timeout * Automatically cancels the request if it exceeds the timeout duration * * @example * ```typescript * const { request, abort } = createRequestWithTimeout( * (signal) => apiClient.get('/api/v1/tracks', { signal }), * 5000 // 5 seconds timeout * ); * ``` */ export function createRequestWithTimeout( requestFn: (signal: AbortSignal) => Promise, timeoutMs: number, ): { request: Promise; abort: () => void } { const abortController = new AbortController(); const signal = abortController.signal; // Set up timeout const timeoutId = setTimeout(() => { abortController.abort(); }, timeoutMs); const request = requestFn(signal) .finally(() => { // Clear timeout if request completes before timeout clearTimeout(timeoutId); }); return { request, abort: () => { clearTimeout(timeoutId); abortController.abort(); }, }; } /** * FE-API-016: Enhanced API client methods with automatic deduplication * FE-API-017: Enhanced with response caching for GET requests * These methods automatically deduplicate identical concurrent requests and cache GET responses * * @example * ```typescript * // Multiple identical requests will share the same promise * const promise1 = deduplicatedApiClient.get('/tracks'); * const promise2 = deduplicatedApiClient.get('/tracks'); * // promise1 === promise2 (same promise instance) * * // Cached responses are returned immediately * const response1 = await deduplicatedApiClient.get('/tracks'); * const response2 = await deduplicatedApiClient.get('/tracks'); // Returns from cache * ``` */ export const deduplicatedApiClient = { get: (url: string, config?: InternalAxiosRequestConfig) => { // FE-API-017: Check cache first if (!(config as any)?._disableCache) { const cachedResponse = responseCache.get({ ...config, method: 'GET', url }); if (cachedResponse) { logger.debug(`[API] Using cached response for: ${url}`); return Promise.resolve(cachedResponse as AxiosResponse); } } return requestDeduplication.getOrCreateRequest( { ...config, method: 'GET', url }, () => apiClient.get(url, config), ); }, post: (url: string, data?: any, config?: InternalAxiosRequestConfig) => { return requestDeduplication.getOrCreateRequest( { ...config, method: 'POST', url, data }, () => apiClient.post(url, data, config), ); }, put: (url: string, data?: any, config?: InternalAxiosRequestConfig) => { return requestDeduplication.getOrCreateRequest( { ...config, method: 'PUT', url, data }, () => apiClient.put(url, data, config), ); }, patch: (url: string, data?: any, config?: InternalAxiosRequestConfig) => { return requestDeduplication.getOrCreateRequest( { ...config, method: 'PATCH', url, data }, () => apiClient.patch(url, data, config), ); }, delete: (url: string, config?: InternalAxiosRequestConfig) => { return requestDeduplication.getOrCreateRequest( { ...config, method: 'DELETE', url }, () => apiClient.delete(url, config), ); }, };