import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; import toast from 'react-hot-toast'; import { z } from 'zod'; import { TokenStorage } from '../tokenStorage'; import { refreshToken, isTokenExpiringSoon } from '../tokenRefresh'; import { env } from '@/config/env'; import { parseApiError } from '@/utils/apiErrorHandler'; import { csrfService } from '../csrf'; import { logger, setLogContext } 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 { safeValidateApiResponse } from '@/schemas/apiSchemas'; import { safeValidateApiRequest } from '@/schemas/apiRequestSchemas'; 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 */ // INT-API-004: Timeout configurations per endpoint type export const API_TIMEOUTS = { DEFAULT: 10000, // 10 seconds - default timeout for normal requests UPLOAD: 300000, // 5 minutes - timeout for file uploads LONG_POLLING: 30000, // 30 seconds - timeout for long-polling requests } as const; // Client API réutilisable export const apiClient = axios.create({ baseURL: env.API_URL, timeout: API_TIMEOUTS.DEFAULT, 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; }> = []; // Cache pour éviter les refresh proactifs multiples let lastProactiveRefreshTime = 0; const PROACTIVE_REFRESH_COOLDOWN_MS = 5000; // 5 secondes entre refresh proactifs /** * 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: [500, 502, 503, 504], // Server errors, gateway errors (429 excluded - don't retry rate limits) 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( async (config: InternalAxiosRequestConfig) => { // INT-AUTH-004: Vérifier l'expiration du token avant d'envoyer la requête // Buffer de 60 secondes pour éviter les 401 inutiles const PRE_REQUEST_REFRESH_BUFFER_MS = 60 * 1000; // 60 secondes const token = TokenStorage.getAccessToken(); const isRefreshEndpoint = config.url?.includes('/auth/refresh'); const isCSRFRoute = config.url?.includes('/csrf-token'); // Ne pas vérifier l'expiration pour les endpoints de refresh et CSRF pour éviter les boucles if (token && !isRefreshEndpoint && !isCSRFRoute) { // Vérifier si le token expire bientôt (dans moins de 60s) if (isTokenExpiringSoon(token, PRE_REQUEST_REFRESH_BUFFER_MS)) { // Si un refresh est déjà en cours, attendre qu'il se termine if (isRefreshing) { logger.debug('[API] Token expiring soon but refresh already in progress, waiting...', { url: config.url, }); // Attendre que le refresh se termine (max 5s) let waitCount = 0; while (isRefreshing && waitCount < 50) { await new Promise(resolve => setTimeout(resolve, 100)); waitCount++; } // Récupérer le nouveau token après le refresh const newToken = TokenStorage.getAccessToken(); if (newToken && config.headers) { config.headers.Authorization = `Bearer ${newToken} `; } } else { // Vérifier le cooldown pour éviter les refresh proactifs multiples const now = Date.now(); const timeSinceLastRefresh = now - lastProactiveRefreshTime; if (timeSinceLastRefresh < PROACTIVE_REFRESH_COOLDOWN_MS) { // Trop tôt depuis le dernier refresh, utiliser le token actuel logger.debug('[API] Skipping proactive refresh (cooldown)', { url: config.url, time_since_last_refresh_ms: timeSinceLastRefresh, cooldown_ms: PROACTIVE_REFRESH_COOLDOWN_MS, }); if (token && config.headers) { config.headers.Authorization = `Bearer ${token} `; } } else { // Rafraîchir proactivement le token try { lastProactiveRefreshTime = now; logger.debug('[API] Token expiring soon, refreshing proactively before request', { url: config.url, buffer_seconds: PRE_REQUEST_REFRESH_BUFFER_MS / 1000, }); await refreshToken(); const newToken = TokenStorage.getAccessToken(); if (newToken && config.headers) { config.headers.Authorization = `Bearer ${newToken} `; } } catch (refreshError) { // Si le refresh échoue, continuer avec le token actuel // L'interceptor de réponse gérera l'erreur 401 si nécessaire logger.warn('[API] Proactive token refresh failed, continuing with current token', { url: config.url, error: refreshError, }); if (token && config.headers) { config.headers.Authorization = `Bearer ${token} `; } } } } } else { // Token valide, utiliser normalement if (config.headers) { config.headers.Authorization = `Bearer ${token} `; } } } else if (token && config.headers) { // Token présent mais endpoint de refresh/CSRF, utiliser sans vérification 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']; } // INT-AUTH-001: 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 || ''); // isCSRFRoute déjà défini plus haut if (isStateChanging && !isCSRFRoute && config.headers) { const csrfToken = csrfService.getToken(); if (csrfToken) { config.headers['X-CSRF-Token'] = csrfToken; } // Si pas de token, l'interceptor de réponse gérera le retry avec nouveau token } // 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 } // FE-TYPE-003: Validate request data if schema is provided const requestSchema = (config as any)?._requestSchema as z.ZodSchema | undefined; if (requestSchema && config.data !== undefined && config.data !== null) { // Skip validation for FormData (file uploads) if (!(config.data instanceof FormData)) { const validation = safeValidateApiRequest(requestSchema, config.data); if (!validation.success) { logger.warn('[API] Request validation failed:', { url: config.url, errors: validation.error?.errors, }); // FIX #18: Utiliser logger structuré au lieu de console.warn logger.warn('[API Request Validation Error]', { request_id: getRequestId(config), url: config.url, errors: validation.error?.errors, }, validation.error); // Throw error to prevent invalid request from being sent throw new Error(`Request validation failed: ${validation.error?.errors.map(e => e.message).join(', ')}`); } // Use validated data config.data = validation.data; } } // 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>) => { // FIX #22: Extraire le request_id depuis les headers de réponse pour corrélation const requestIdFromHeader = response.headers['x-request-id'] || response.headers['X-Request-ID']; const requestId = requestIdFromHeader || (response.config as any)?._requestId; // Mettre à jour le contexte global du logger avec le request_id if (requestId) { setLogContext({ request_id: requestId }); } // Log successful response (only in development or if explicitly enabled) 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); } // INT-API-002: 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; // FE-TYPE-002: Validate response data if schema is provided const responseSchema = (response.config as any)?._responseSchema as z.ZodSchema | undefined; if (responseSchema && unwrappedData !== null) { const validation = safeValidateApiResponse(responseSchema, unwrappedData); if (!validation.success) { logger.warn('[API] Response validation failed:', { url: response.config.url, errors: validation.error?.errors, }); // FIX #18: Utiliser logger structuré au lieu de console.warn logger.warn('[API Validation Error]', { request_id: getRequestId(response.config), url: response.config.url, }, validation.error); // Continue with unvalidated data (don't break the app) // In production, you might want to throw or handle differently } } return { ...response, data: unwrappedData, } as AxiosResponse; } // INT-API-002: Si success === false, traiter comme une erreur même si status est 200 // Le backend peut retourner { success: false, error: {...} } avec un status 200 dans certains cas if (response.data.success === false) { const errorData = response.data.error || response.data; logger.error('[API] Response with success=false:', { url: response.config.url, error: errorData, }); // Créer une erreur Axios pour que l'interceptor d'erreur la gère // Format attendu par parseApiError: { success: false, error: {...} } const axiosError = new AxiosError>( errorData?.message || 'Request failed', 'API_ERROR', response.config, response.request, { ...response, status: response.status || 400, // Utiliser le status de la réponse ou 400 par défaut statusText: response.statusText || 'Bad Request', data: { success: false, error: errorData, }, } as AxiosResponse>, ); // Rejeter pour que l'interceptor d'erreur gère cette erreur // parseApiError détectera automatiquement le format { success: false, error: {...} } return Promise.reject(axiosError); } } // Si pas de format wrapper (format direct JSON), retourner la réponse telle quelle // Exemples: { tracks: [...], pagination: {...} }, { user: {...}, token: {...} } // FE-TYPE-002: Validate direct format responses if schema is provided const responseSchema = (response.config as any)?._responseSchema as z.ZodSchema | undefined; if (responseSchema && response.data) { const validation = safeValidateApiResponse(responseSchema, response.data); if (!validation.success) { logger.warn('[API] Response validation failed:', { url: response.config.url, errors: validation.error?.errors, }); // FIX #18: Utiliser logger structuré au lieu de console.warn logger.warn('[API Validation Error]', { request_id: (response.config as any)?._requestId, url: response.config.url, }, validation.error); } } 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; }; // FIX #22: Extraire le request_id depuis les headers de réponse d'erreur pour corrélation let requestId = (originalRequest as any)?._requestId; if (error.response?.headers) { const requestIdFromHeader = error.response.headers['x-request-id'] || error.response.headers['X-Request-ID']; if (requestIdFromHeader) { requestId = requestIdFromHeader; // Mettre à jour le contexte global du logger avec le request_id setLogContext({ request_id: requestId }); } } // Log error response (only in development or if explicitly enabled) 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, }); } // INT-AUTH-003: Détecter 401 et refresh automatiquement // EXCLURE l'endpoint /auth/refresh pour éviter les boucles infinies const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh'); // INT-AUTH-003: Handle 401 on /auth/refresh endpoint - token expired/revoked, logout and redirect if (error.response?.status === 401 && isRefreshEndpoint) { logger.error('[API] 401 on /auth/refresh - refresh token expired or revoked, logging out', { request_id: requestId, url: originalRequest?.url, }); // Clear tokens TokenStorage.clearTokens(); // Clear CSRF token csrfService.clearToken(); // Clear auth store state if (typeof window !== 'undefined') { // Import and use auth store to clear state import('@/features/auth/store/authStore').then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logout().catch((err) => { logger.error('[API] Failed to logout from store after refresh token 401', { error: err }); }); }).catch((err) => { logger.error('[API] Failed to import auth store for logout', { error: err }); }); // Store error message for display after redirect sessionStorage.setItem( 'auth_error', 'Votre session a expiré. Veuillez vous reconnecter.', ); // Redirect to login window.location.href = '/login'; } return Promise.reject(parseApiError(error)); } if ( error.response?.status === 401 && originalRequest && !originalRequest._retry && !isRefreshEndpoint ) { // INT-AUTH-003: Éviter les refresh multiples simultanés if (isRefreshing) { // Si un refresh est en cours, mettre la requête en queue logger.debug('[API] Refresh already in progress, queuing request', { request_id: requestId, url: originalRequest?.url, queue_size: failedQueue.length, }); return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then((token) => { if (originalRequest.headers && token) { originalRequest.headers.Authorization = `Bearer ${token} `; } logger.debug('[API] Replaying queued request after successful refresh', { request_id: requestId, url: originalRequest?.url, }); return apiClient(originalRequest); }) .catch((err) => { logger.error('[API] Queued request failed after refresh', { request_id: requestId, url: originalRequest?.url, error: err, }); return Promise.reject(err); }); } originalRequest._retry = true; isRefreshing = true; logger.info('[API] Starting token refresh due to 401', { request_id: requestId, url: originalRequest?.url, method: originalRequest?.method, }); try { // INT-AUTH-003: Refresh automatique du token await refreshToken(); const newToken = TokenStorage.getAccessToken(); if (!newToken) { throw new Error('Failed to get new access token after refresh'); } logger.info('[API] Token refresh successful, retrying original request', { request_id: requestId, url: originalRequest?.url, queue_size: failedQueue.length, }); if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken} `; } // INT-AUTH-003: Traiter la queue et retry la requête originale // Toutes les requêtes en queue seront rejouées avec le nouveau token processQueue(null, newToken); return apiClient(originalRequest); } catch (refreshError) { // INT-AUTH-003: Gérer cas refresh échoué (expiration, révocation, erreur réseau) logger.error('[API] Token refresh failed, logging out', { request_id: requestId, error: refreshError, queue_size: failedQueue.length, }); // Rejeter toutes les requêtes en queue processQueue(refreshError as Error, null); // Nettoyer les tokens TokenStorage.clearTokens(); // Clear CSRF token csrfService.clearToken(); // INT-AUTH-003: Clear auth store state and redirect to login if (typeof window !== 'undefined') { // Import and use auth store to clear state import('@/features/auth/store/authStore').then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logout().catch((err) => { logger.error('[API] Failed to logout from store after refresh failure', { error: err }); }); }).catch((err) => { logger.error('[API] Failed to import auth store for logout', { error: err }); }); // Stocker un message d'erreur pour l'afficher après redirection sessionStorage.setItem( 'auth_error', 'Votre session a expiré. Veuillez vous reconnecter.', ); // Rediriger vers login si refresh échoue (seulement dans le navigateur) window.location.href = '/login'; } return Promise.reject(refreshError); } finally { isRefreshing = false; logger.debug('[API] Token refresh process completed', { request_id: requestId, is_refreshing: false, }); } } // INT-AUTH-001: Détecter erreurs CSRF (403 avec message CSRF) et retry avec nouveau token const isCSRFError = error.response?.status === 403 && originalRequest && !(originalRequest as any)?._csrfRetry && error.response?.data && typeof error.response.data === 'object' && ( (error.response.data as any)?.error?.message?.toLowerCase().includes('csrf') || (error.response.data as any)?.message?.toLowerCase().includes('csrf') ); if (isCSRFError) { const method = originalRequest.method?.toUpperCase(); const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || ''); if (isStateChanging) { (originalRequest as any)._csrfRetry = true; try { // Récupérer un nouveau token CSRF const newCsrfToken = await csrfService.refreshToken(); if (originalRequest.headers && newCsrfToken) { originalRequest.headers['X-CSRF-Token'] = newCsrfToken; } // Retry la requête avec le nouveau token return apiClient(originalRequest); } catch (csrfError) { logger.error('[API] Failed to refresh CSRF token after CSRF error', { error: csrfError }); // Si on ne peut pas récupérer le token, rejeter l'erreur originale const apiError = parseApiError(error); return Promise.reject(apiError); } } } // INT-API-005: 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; // INT-API-005: For 429 rate limit errors, don't retry - respect the rate limit const isRateLimitError = status === 429; // Don't retry 429 errors - respect the rate limit and show error immediately if (isRateLimitError) { const apiError = parseApiError(error); // Extract retry-after header if present const retryAfter = error.response?.headers['retry-after'] || error.response?.headers['Retry-After']; const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; logger.warn('[API] Rate limit exceeded, not retrying', { url: originalRequest?.url, retry_after: retryAfterSeconds, request_id: apiError.request_id, }); // Show user-friendly error message if (apiError.message) { toast.error(apiError.message, { duration: retryAfterSeconds * 1000, // Show for the retry-after duration }); } return Promise.reject(apiError); } const effectiveMaxRetries = maxRetries; // Use default max retries for other errors // Check if error is retryable if (isRetryableError(error, DEFAULT_RETRY_CONFIG) && originalRequest && retryCount < effectiveMaxRetries) { // 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 // (429 rate limit errors are handled above and don't retry) if (!isIdempotent && status && status !== 500 && status !== 502 && status !== 503 && status !== 504) { // Don't retry non-idempotent methods on client errors (except 429 and 5xx) const apiError = parseApiError(error); return Promise.reject(apiError); } // Mark that we're retrying this request (originalRequest as any)._retryCount = retryCount + 1; // Calculate delay (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'; // Log retry attempt if (apiError.request_id) { console.warn( `[API Retry] ${errorType} error, retrying (${retryCount + 1}/${effectiveMaxRetries}) - Request ID: ${apiError.request_id}`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: retryCount + 1, max_retries: effectiveMaxRetries, 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}/${effectiveMaxRetries})`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: retryCount + 1, max_retries: effectiveMaxRetries, 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); }); } // INT-API-005: If already retried effectiveMaxRetries times or error is not retryable, reject immediately // Reuse the same effectiveMaxRetries calculation from above if (retryCount >= effectiveMaxRetries) { 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 !== undefined) { 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: 5000, // Standard duration for errors }); } // FIX #18, #22: Utiliser logger structuré avec request_id pour corrélation logger.error( `[API Error] ${apiError.message}`, { request_id: apiError.request_id || requestId, code: apiError.code, message: apiError.message, timestamp: apiError.timestamp, details: apiError.details, context: apiError.context, 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), ); }, };