import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse, } from 'axios'; // CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables import toast from '@/utils/toast'; import { z } from 'zod'; import { TokenStorage } from '../tokenStorage'; import { refreshToken, isTokenExpiringSoon } from '../tokenRefresh'; import { env } from '@/config/env'; import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler'; import { formatUserFriendlyError } from '@/utils/errorMessages'; import { csrfService } from '../csrf'; import { logger, setLogContext } from '@/utils/logger'; import { isTimeoutError as _isTimeoutError, getTimeoutMessage as _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'; /** * Action 1.2.2.3: Validation error metrics tracker * Tracks validation failure rate for monitoring */ interface ValidationMetrics { totalValidations: number; successfulValidations: number; failedValidations: number; failureRate: number; // percentage lastFailureTime?: string; lastSuccessTime?: string; failuresByEndpoint: Record; } class ValidationMetricsTracker { private metrics: ValidationMetrics = { totalValidations: 0, successfulValidations: 0, failedValidations: 0, failureRate: 0, failuresByEndpoint: {}, }; recordSuccess(_url?: string): void { this.metrics.totalValidations++; this.metrics.successfulValidations++; this.metrics.lastSuccessTime = new Date().toISOString(); this.updateFailureRate(); } recordFailure(url?: string): void { this.metrics.totalValidations++; this.metrics.failedValidations++; this.metrics.lastFailureTime = new Date().toISOString(); if (url) { const endpoint = this.normalizeEndpoint(url); this.metrics.failuresByEndpoint[endpoint] = (this.metrics.failuresByEndpoint[endpoint] || 0) + 1; } this.updateFailureRate(); } private updateFailureRate(): void { if (this.metrics.totalValidations > 0) { this.metrics.failureRate = (this.metrics.failedValidations / this.metrics.totalValidations) * 100; } } private normalizeEndpoint(url?: string): string { if (!url) return 'unknown'; // Normalize URL to endpoint pattern (e.g., /api/v1/tracks/123 -> /api/v1/tracks/:id) try { const urlObj = new URL(url, 'http://localhost'); const path = urlObj.pathname; // Replace UUIDs and numeric IDs with :id return path.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id') .replace(/\/\d+/g, '/:id'); } catch { // Fallback: remove query params and return path const path = url.split('?')[0]; return path || 'unknown'; } } getMetrics(): ValidationMetrics { return { ...this.metrics }; } reset(): void { this.metrics = { totalValidations: 0, successfulValidations: 0, failedValidations: 0, failureRate: 0, failuresByEndpoint: {}, }; } } // Singleton instance for validation metrics export const validationMetrics = new ValidationMetricsTracker(); /** * Action 1.2.2.4: Validation error alerting * Monitors validation metrics and alerts when thresholds are exceeded */ interface ValidationAlertConfig { failureRateThreshold: number; // Percentage (e.g., 5 = 5%) minValidationsForAlert: number; // Minimum validations before alerting checkInterval: number; // Milliseconds between checks } const DEFAULT_ALERT_CONFIG: ValidationAlertConfig = { failureRateThreshold: 5.0, // Alert if failure rate > 5% minValidationsForAlert: 10, // Need at least 10 validations before alerting checkInterval: 5 * 60 * 1000, // Check every 5 minutes }; class ValidationAlerting { private config: ValidationAlertConfig = DEFAULT_ALERT_CONFIG; private checkIntervalId: NodeJS.Timeout | null = null; private lastAlertTime: number = 0; private alertCooldown: number = 15 * 60 * 1000; // 15 minutes between alerts start(config?: Partial): void { if (this.checkIntervalId) { this.stop(); } this.config = { ...DEFAULT_ALERT_CONFIG, ...config }; if (typeof window !== 'undefined') { // Initial check after 1 minute setTimeout(() => this.checkMetrics(), 60 * 1000); // Periodic checks this.checkIntervalId = setInterval( () => this.checkMetrics(), this.config.checkInterval, ); } } stop(): void { if (this.checkIntervalId) { clearInterval(this.checkIntervalId); this.checkIntervalId = null; } } private checkMetrics(): void { const metrics = validationMetrics.getMetrics(); // Skip if not enough validations if (metrics.totalValidations < this.config.minValidationsForAlert) { return; } // Check failure rate threshold if (metrics.failureRate > this.config.failureRateThreshold) { const now = Date.now(); // Cooldown to avoid alert spam if (now - this.lastAlertTime < this.alertCooldown) { return; } this.lastAlertTime = now; // Log alert with structured context logger.error( '[API Validation Alert] High validation failure rate detected', { alert_type: 'high_validation_failure_rate', failure_rate: metrics.failureRate.toFixed(2), threshold: this.config.failureRateThreshold, total_validations: metrics.totalValidations, failed_validations: metrics.failedValidations, successful_validations: metrics.successfulValidations, last_failure_time: metrics.lastFailureTime, failures_by_endpoint: metrics.failuresByEndpoint, timestamp: new Date().toISOString(), }, ); // Also send to Sentry if configured (via logger.error) } } updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; } } // Singleton instance for validation alerting export const validationAlerting = new ValidationAlerting(); // Start alerting in production (can be disabled via env var) if (typeof window !== 'undefined' && import.meta.env.PROD) { const enableAlerting = import.meta.env.VITE_ENABLE_VALIDATION_ALERTING !== 'false'; if (enableAlerting) { validationAlerting.start(); } } /** * 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', }, // SECURITY: Activer withCredentials pour envoyer les cookies httpOnly automatiquement // Les cookies httpOnly sont set par le backend et envoyés automatiquement avec chaque requête withCredentials: true, }); // 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) => { // API Versioning: Add X-API-Version header to all requests if (config.headers) { config.headers['X-API-Version'] = env.API_VERSION; } // 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']; } // CRITIQUE FIX #25: Ajouter le token CSRF pour toutes les requêtes mutantes (POST, PUT, DELETE, PATCH) // Le token CSRF est requis pour toutes les requêtes qui modifient l'état côté serveur const method = config.method?.toUpperCase(); const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes( method || '', ); // isCSRFRoute déjà défini plus haut const isAuthRoute = config.url?.includes('/auth/login') || config.url?.includes('/auth/register'); if ( isStateChanging && !isCSRFRoute && !isAuthRoute && token && config.headers ) { // CRITIQUE FIX #25: S'assurer que le token CSRF est toujours présent pour les requêtes mutantes // Si le token n'est pas disponible, en récupérer un nouveau avant d'envoyer la requête let csrfToken = csrfService.getToken(); if (!csrfToken) { // Si pas de token, essayer d'en récupérer un nouveau de manière synchrone si possible // Sinon, l'interceptor de réponse gérera le retry avec nouveau token après une erreur 403 try { csrfToken = await csrfService.ensureToken(); } catch (error) { // Si la récupération échoue, continuer sans token - l'interceptor de réponse gérera le retry logger.warn( '[API] Failed to fetch CSRF token before request, will retry on 403', { url: config.url, method: config.method, }, ); } } if (csrfToken && config.headers) { 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 } // FE-TYPE-003: Validate request data if schema is provided // Action 1.2.1.5: Ensure all requests are validated when 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) - they are validated separately if (!(config.data instanceof FormData)) { const validation = safeValidateApiRequest(requestSchema, config.data); if (!validation.success) { const requestId = getRequestId(config); // Log validation error with structured logging logger.warn( '[API Request Validation Error]', { request_id: requestId, url: config.url, method: config.method?.toUpperCase(), errors: validation.error?.errors.map((e) => ({ path: e.path.join('.'), message: e.message, code: e.code, })), }, validation.error, ); // Throw error to prevent invalid request from being sent // This ensures data integrity before sending to backend const errorMessages = validation.error?.errors .map((e) => `${e.path.join('.')}: ${e.message}`) .join(', ') || 'Request validation failed'; throw new Error(`Request validation failed: ${errorMessages}`); } // Use validated data (Zod may transform/coerce values) 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, }, ); } // API Versioning: Check for deprecation warning const deprecatedHeader = response.headers['x-api-deprecated'] || response.headers['X-API-Deprecated']; if (deprecatedHeader === 'true') { // Show deprecation warning (only once per session to avoid spam) const deprecationKey = 'api_deprecation_warning_shown'; if (typeof window !== 'undefined' && !sessionStorage.getItem(deprecationKey)) { const sunsetDate = response.headers['sunset'] || response.headers['Sunset']; const message = sunsetDate ? `This API version is deprecated and will be removed on ${sunsetDate}. Please update to the latest version.` : 'This API version is deprecated. Please update to the latest version.'; // Use toast with warning icon (react-hot-toast doesn't have toast.warning) toast(message, { icon: '⚠️', duration: 10000, // Show for 10 seconds }); sessionStorage.setItem(deprecationKey, 'true'); logger.warn('[API] Deprecated API version detected', { url: response.config.url, version: response.headers['x-api-version'] || response.headers['X-API-Version'], sunset_date: sunsetDate, }); } } // 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 // Action 1.2.2.1: Enhanced response validation for all responses with schemas const responseSchema = (response.config as any)?._responseSchema as | z.ZodSchema | undefined; if (responseSchema && unwrappedData !== null) { const validation = safeValidateApiResponse( responseSchema, unwrappedData, ); if (!validation.success) { const requestId = getRequestId(response.config); // Action 1.2.2.2: Production error logging for validation failures // Enhanced logging with structured error details for production monitoring const validationErrorContext = { request_id: requestId, url: response.config.url, method: response.config.method?.toUpperCase(), status: response.status, error_type: 'api_response_validation_failed', validation_errors: validation.error?.errors.map((e) => ({ path: e.path.join('.'), message: e.message, code: e.code, received: e.code === 'invalid_type' ? e.received : undefined, expected: e.code === 'invalid_type' ? e.expected : undefined, })), response_data_preview: JSON.stringify(unwrappedData).substring(0, 200), schema_provided: !!responseSchema, timestamp: new Date().toISOString(), }; // Log to structured logger (sends to backend endpoint and Sentry in production) logger.error( '[API Response Validation Failed]', validationErrorContext, validation.error, ); // Action 1.2.2.3: Track validation failure metrics validationMetrics.recordFailure(response.config.url); // Action 1.2.2.5: Validation error recovery mechanism const recoveryConfig = (response.config as any)?._validationRecovery as | { useCache?: boolean; retry?: boolean; notifyUser?: boolean } | undefined; const useCache = recoveryConfig?.useCache !== false; // Default: true const retry = recoveryConfig?.retry === true; // Default: false (safety) const notifyUser = recoveryConfig?.notifyUser !== false; // Default: true // Recovery 1: Try cached response (for GET requests only) if (useCache && method === 'GET') { const cachedResponse = responseCache.get(response.config); if (cachedResponse) { // Cached response may be in wrapped or direct format // For wrapped format, unwrap it first let cachedData = cachedResponse.data; if (cachedData && typeof cachedData === 'object' && 'success' in cachedData && cachedData.success === true) { cachedData = (cachedData as any).data !== undefined ? (cachedData as any).data : null; } // Validate cached response before using it if (cachedData !== null) { const cachedValidation = safeValidateApiResponse(responseSchema, cachedData); if (cachedValidation.success) { logger.warn( '[API Validation Recovery] Using cached response due to validation failure', { request_id: requestId, url: response.config.url, recovery_type: 'cache_fallback', }, ); if (notifyUser && typeof window !== 'undefined') { toast('Data may be outdated. Please refresh if issues persist.', { icon: '⚠️', duration: 5000, }); } // Return cached response with unwrapped data (matching current format) return { ...cachedResponse, data: cachedData, } as AxiosResponse; } } } } // Recovery 2: Optional retry (only if explicitly enabled) if (retry && !(response.config as any)?._validationRetryAttempted) { (response.config as any)._validationRetryAttempted = true; logger.warn( '[API Validation Recovery] Retrying request due to validation failure', { request_id: requestId, url: response.config.url, recovery_type: 'retry', }, ); // Retry the request (will go through the same validation again) return apiClient.request(response.config); } // Continue with unvalidated data (graceful degradation) // In production, this allows the app to continue functioning even if // the backend response doesn't match the expected schema (e.g., during migrations) // Validation errors are logged for monitoring and alerting if (notifyUser && typeof window !== 'undefined') { toast('Some data may be incomplete. Please refresh if issues persist.', { icon: '⚠️', duration: 5000, }); } } else { // Log successful validation in debug mode const requestId = getRequestId(response.config); logger.debug('[API Response Validation Success]', { request_id: requestId, url: response.config.url, }); // Action 1.2.2.3: Track validation success metrics validationMetrics.recordSuccess(response.config.url); } } 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); } } // Action 1.3.2.2: Removed dual-format handling - backend now always returns wrapped format // If we receive a response without 'success' field, log a warning (should not happen) if (response.data && typeof response.data === 'object' && !('success' in response.data)) { const requestId = getRequestId(response.config); logger.warn( '[API] Received non-wrapped response format (unexpected)', { request_id: requestId, url: response.config.url, method: response.config.method?.toUpperCase(), status: response.status, response_preview: JSON.stringify(response.data).substring(0, 200), timestamp: new Date().toISOString(), }, ); // Continue with the response as-is (graceful degradation) // This should not happen if backend is properly deployed } 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: unknown) => { logger.error( '[API] Failed to logout from store after refresh token 401', { error: err }, ); }); }) .catch((err: unknown) => { 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: unknown) => { logger.error( '[API] Failed to logout from store after refresh failure', { error: err }, ); }); }) .catch((err: unknown) => { 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) { logger.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 { logger.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) { logger.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 { logger.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); // Action 3.2.1.4: Auth errors redirect to login // Handle 401 errors that didn't trigger refresh (e.g., no originalRequest, already retried, etc.) if (status === 401 && !isRefreshEndpoint && typeof window !== 'undefined') { const errorCategory = getErrorCategory(apiError); if (errorCategory === 'authentication') { // Clear tokens TokenStorage.clearTokens(); csrfService.clearToken(); // Clear auth store state import('@/features/auth/store/authStore') .then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logout().catch((err: unknown) => { logger.error('[API] Failed to logout from store after 401', { error: err, }); }); }) .catch((err: unknown) => { 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'; } } // 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/redirect) status !== 404 && // Don't show toast for 404 (handled by router) !axios.isCancel(error); // Don't show toast for cancelled requests // FIX: Implement toast throttling for network errors to prevent spam const isNetworkError = !error.response; // Track network errors for offline indicator if (isNetworkError) { const { recordNetworkError } = await import( '@/utils/networkErrorTracker' ); recordNetworkError(apiError); } const toastId = isNetworkError ? 'network-error-toast' : undefined; if (shouldShowToast && typeof window !== 'undefined') { const url = originalRequest?.url || ''; let context: | 'auth' | 'track' | 'playlist' | 'upload' | 'conversation' | 'search' | undefined; if (url.includes('/auth/')) { context = 'auth'; } else if (url.includes('/tracks') || url.includes('/track/')) { context = 'track'; } else if (url.includes('/playlists') || url.includes('/playlist/')) { context = 'playlist'; } else if (url.includes('/upload')) { context = 'upload'; } else if (url.includes('/conversations') || url.includes('/chat')) { context = 'conversation'; } else if (url.includes('/search')) { context = 'search'; } const includeDetails = status === 422; const errorMessage = formatUserFriendlyError( apiError, context, includeDetails, ); // FE-API-015: Queue request for offline replay if it's a network error if ( !error.response && 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, id: 'offline-queue-toast', // Prevent duplicate queue toasts }, ); } catch (queueError) { logger.error('[API] Failed to queue request for offline replay', { error: queueError, }); } } } // Use a fixed ID for network errors to prevent stacking // For network errors, show a more helpful message with suggestions let enhancedMessage = errorMessage; if (isNetworkError) { enhancedMessage = `${errorMessage} 💡 Vérifiez votre connexion internet. Si le problème persiste, le serveur pourrait être temporairement indisponible.`; } toast.error(enhancedMessage, { duration: 8000, // Longer duration for network errors to read suggestions id: toastId, // Use fixed ID if it's a network error }); } // 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), ); }, };