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 } 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'; import { useRateLimitStore } from '@/stores/rateLimit'; /** * 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; // Edge 2.3: Slow request detection threshold // Requests taking longer than this will be considered "slow" and can trigger loading indicators export const SLOW_REQUEST_THRESHOLD = 1000; // 1 second - threshold for showing loading indicators // 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, }); // P1.4: Refresh token loop protection // Flag pour éviter les refresh en boucle let isRefreshing = false; let refreshAttempts = 0; const MAX_REFRESH_ATTEMPTS = 3; let failedQueue: Array<{ resolve: (value?: any) => void; reject: (error?: any) => void; }> = []; // SECURITY: Action 5.1.1.3 - Removed proactive refresh cooldown logic // Tokens are in httpOnly cookies, can't check expiration from JS /** * 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; }; /** * Edge 2.1: Track network failure patterns to distinguish partial vs complete failures * Partial failures: Some requests succeed while others fail (intermittent connectivity) * Complete failures: All requests fail (no connection at all) */ class NetworkFailureTracker { private recentRequests: Array<{ success: boolean; timestamp: number }> = []; private readonly windowSize = 10; // Track last 10 requests private readonly windowMs = 30000; // 30 second window recordRequest(success: boolean): void { const now = Date.now(); this.recentRequests.push({ success, timestamp: now }); // Keep only requests within the time window this.recentRequests = this.recentRequests.filter( (req) => now - req.timestamp < this.windowMs, ); // Keep only the most recent requests (limit window size) if (this.recentRequests.length > this.windowSize) { this.recentRequests = this.recentRequests.slice(-this.windowSize); } } /** * Edge 2.1: Determine if this is a partial network failure * Partial failure = some requests succeed, some fail (intermittent connectivity) * Complete failure = all requests fail (no connection) */ isPartialFailure(): boolean { if (this.recentRequests.length === 0) { return false; // No data to determine } const successCount = this.recentRequests.filter((r) => r.success).length; const failureCount = this.recentRequests.filter((r) => !r.success).length; // Partial failure: both successes and failures in recent window return successCount > 0 && failureCount > 0; } /** * Edge 2.1: Determine if this is a complete network failure * Complete failure = all recent requests failed */ isCompleteFailure(): boolean { if (this.recentRequests.length === 0) { return false; // No data to determine } // Complete failure: all recent requests failed return this.recentRequests.every((r) => !r.success); } reset(): void { this.recentRequests = []; } } const networkFailureTracker = new NetworkFailureTracker(); /** * Edge 2.1: Check if an error represents a partial network failure * Partial failures include: * - HTTP 206 Partial Content * - Timeout after partial data transfer * - Connection drops mid-response * - Intermittent connectivity (some requests succeed, some fail) */ const isPartialNetworkFailure = (error: AxiosError): boolean => { // HTTP 206 Partial Content is a partial failure if (error.response?.status === 206) { return true; } // Timeout after partial data transfer (request started but didn't complete) if ( error.code === 'ECONNABORTED' && error.message?.toLowerCase().includes('timeout') && error.request ) { return true; } // Connection reset mid-response (partial data received) if (error.code === 'ECONNRESET' && error.response) { return true; } // Intermittent connectivity: some requests succeed, some fail if (networkFailureTracker.isPartialFailure()) { return true; } return false; }; /** * Edge 2.1: Check if an error represents a complete network failure * Complete failures include: * - All requests fail (no connection at all) * - Server completely unreachable * - No internet connection */ const isCompleteNetworkFailure = (error: AxiosError): boolean => { // No response and no request = complete failure if (!error.response && !error.request) { return true; } // Connection refused = server completely unreachable if ( error.code === 'ECONNREFUSED' || error.code === 'ERR_CONNECTION_REFUSED' ) { return true; } // Network unreachable = no internet connection if ( error.code === 'ENETUNREACH' || error.code === 'ERR_NETWORK' || error.code === 'ERR_INTERNET_DISCONNECTED' ) { return true; } // All recent requests failed = complete failure pattern if (networkFailureTracker.isCompleteFailure()) { return true; } return 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; } // Don't retry when API returned HTML instead of JSON (wrong server on port 8080) if ( error.code === 'ERR_BAD_RESPONSE' || error.message?.includes('HTML page instead of JSON') ) { return false; } // Check if retry is disabled for this request if ((error.config as any)?._disableRetry) { return false; } // Edge 2.1: Partial failures are more retryable than complete failures // Partial failures suggest intermittent connectivity that might resolve if (isPartialNetworkFailure(error)) { // Retry partial failures more aggressively (if idempotent) return isIdempotentMethod(error.config?.method); } // 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 // SECURITY: Action 5.1.1.3 - processQueue no longer needs token parameter // Cookies are automatically sent with requests via withCredentials: true const processQueue = (error: Error | null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error); } else { prom.resolve(undefined); // No token needed, cookies are sent automatically } }); 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) => { // Edge 2.3: Track request start time for slow request detection const requestStartTime = Date.now(); (config as any)._requestStartTime = requestStartTime; (config as any)._isSlowRequest = false; // API Versioning: Add X-API-Version header to all requests if (config.headers) { config.headers['X-API-Version'] = env.API_VERSION; } // SECURITY: Action 5.1.1.3 - Tokens are in httpOnly cookies, not accessible from JS // Cookies are automatically sent with requests via withCredentials: true // No need to set Authorization header - backend reads from cookie // 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'); const isCSRFRoute = config.url?.includes('/csrf-token'); if ( isStateChanging && !isCSRFRoute && !isAuthRoute && 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; } } // Edge 2.2: Support AbortController for request cancellation // If a signal is provided in the config, axios will use it automatically // Users can pass a signal via config.signal to enable cancellation // Helper functions createCancellableRequest() and createRequestWithTimeout() // can be used to easily create cancellable requests if (!config.signal && !config.cancelToken) { // No cancellation configured - users can pass a signal via config.signal // or use the helper functions for automatic cancellation support } // 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 when VITE_DEBUG=true or explicitly enabled (reduces console noise) if ((import.meta.env.DEV && env.DEBUG) || (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); }, ); // Detect when the API returns HTML (wrong server on port 8080, e.g. another app or proxy) function isHtmlResponse(response: AxiosResponse): boolean { const ct = response.headers?.['content-type']; if (typeof ct === 'string' && ct.toLowerCase().includes('text/html')) { return true; } const data = response.data; if (typeof data === 'string') { const trimmed = data.trim().toLowerCase(); return trimmed.startsWith(' | any>) => { // Detect wrong server: API must return JSON, not HTML (e.g. another app on 8080) if (isHtmlResponse(response)) { const msg = 'The API returned an HTML page instead of JSON. Another application may be using port 8080. Stop any other server (e.g. phishing lab) and ensure the Veza backend is running.'; if (typeof window !== 'undefined') { const key = 'veza_wrong_server_shown'; if (!sessionStorage.getItem(key)) { sessionStorage.setItem(key, 'true'); toast(msg, { icon: '⚠️', duration: 12000 }); } } return Promise.reject( new AxiosError( msg, 'ERR_BAD_RESPONSE', response.config, response.request, response, ), ); } // Edge 2.1: Record successful request for partial failure detection networkFailureTracker.recordRequest(true); // Edge 2.3: Detect slow requests and mark them const requestStartTime = (response.config as any)?._requestStartTime; if (requestStartTime) { const requestDuration = Date.now() - requestStartTime; if (requestDuration > SLOW_REQUEST_THRESHOLD) { (response.config as any)._isSlowRequest = true; (response.config as any)._requestDuration = requestDuration; if ((import.meta.env.DEV && env.DEBUG) || (response.config as any)?._enableLogging) { logger.debug( `[API Slow Request] ${response.config?.method?.toUpperCase()} ${response.config?.url} took ${requestDuration}ms`, { duration: requestDuration, threshold: SLOW_REQUEST_THRESHOLD, }, ); } } } // 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 }); } // Action 5.4.1.1: Parse rate limit headers const rateLimitLimit = response.headers['x-ratelimit-limit'] || response.headers['X-RateLimit-Limit']; const rateLimitRemaining = response.headers['x-ratelimit-remaining'] || response.headers['X-RateLimit-Remaining']; const rateLimitReset = response.headers['x-ratelimit-reset'] || response.headers['X-RateLimit-Reset']; if (rateLimitLimit || rateLimitRemaining || rateLimitReset) { useRateLimitStore.getState().updateRateLimit({ limit: rateLimitLimit, remaining: rateLimitRemaining, reset: rateLimitReset, retryAfter: null, // Not set on successful responses }); } // Log successful response only when VITE_DEBUG=true or explicitly enabled (reduces console noise) const shouldLogResponse = (import.meta.env.DEV && env.DEBUG) || (response.config as any)?._enableLogging; if (shouldLogResponse) { 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 && env.DEBUG) || (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 }); } // Action 5.4.1.1: Parse rate limit headers from error response const rateLimitLimit = error.response.headers['x-ratelimit-limit'] || error.response.headers['X-RateLimit-Limit']; const rateLimitRemaining = error.response.headers['x-ratelimit-remaining'] || error.response.headers['X-RateLimit-Remaining']; const rateLimitReset = error.response.headers['x-ratelimit-reset'] || error.response.headers['X-RateLimit-Reset']; const retryAfter = error.response.headers['retry-after'] || error.response.headers['Retry-After']; if ( rateLimitLimit || rateLimitRemaining || rateLimitReset || retryAfter ) { useRateLimitStore.getState().updateRateLimit({ limit: rateLimitLimit, remaining: rateLimitRemaining, reset: rateLimitReset, retryAfter, }); } } // Log error response (only when VITE_DEBUG or explicitly enabled; avoid dumping HTML) const shouldLogError = (import.meta.env.DEV && env.DEBUG) || (originalRequest as any)?._enableLogging; if (shouldLogError && error.response) { const isHtml = (error.response.headers?.['content-type'] as string)?.toLowerCase?.().includes('text/html') || (typeof error.response.data === 'string' && (error.response.data as string).trim().toLowerCase().startsWith(' { const store = useAuthStore.getState(); // Utiliser logoutLocal() au lieu de logout() pour éviter les appels API // qui déclencheraient à nouveau le refresh store.logoutLocal(); }) .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)); } // INT-AUTH-003: Handle 401 on /auth/logout endpoint - token expired/invalid, just clear locally // FIX: Si le logout échoue avec 401, on ne doit pas essayer de rafraîchir le token // car on est déjà en train de se déconnecter if (error.response?.status === 401 && isLogoutEndpoint) { logger.warn( '[API] 401 on /auth/logout - token expired/invalid, clearing tokens locally', { request_id: requestId, url: originalRequest?.url, }, ); // Clear tokens locally TokenStorage.clearTokens(); csrfService.clearToken(); // Clear auth store state using logoutLocal to avoid API calls if (typeof window !== 'undefined') { import('@/features/auth/store/authStore') .then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logoutLocal(); }) .catch((err: unknown) => { logger.error('[API] Failed to import auth store for logout', { error: err, }); }); } // Don't try to refresh - just reject the error return Promise.reject(parseApiError(error)); } if ( error.response?.status === 401 && originalRequest && !originalRequest._retry && !isRefreshEndpoint && !isLogoutEndpoint && !isAuthMeEndpoint ) { // 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(() => { // SECURITY: Action 5.1.1.3 - No need to set Authorization header // Backend reads access token from httpOnly cookie automatically logger.debug( '[API] Replaying queued request after successful refresh', { request_id: requestId, url: originalRequest?.url, }, ); return apiClient(originalRequest); }) .catch((err) => { const errAny = err as { response?: { status?: number }; code?: number }; const errStatus = errAny?.response?.status ?? errAny?.code; const errUrl = originalRequest?.url ?? ''; const isWebhooks5xx = errStatus != null && errStatus >= 500 && errUrl.includes('/webhooks'); if (!isWebhooks5xx) { 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, }); // P1.4: Check if max refresh attempts reached if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) { logger.error( '[API] Max refresh attempts reached, logging out', { request_id: requestId, attempts: refreshAttempts, max_attempts: MAX_REFRESH_ATTEMPTS, }, ); // Reset counter refreshAttempts = 0; isRefreshing = false; // Clear tokens and logout TokenStorage.clearTokens(); csrfService.clearToken(); // Use logoutLocal to avoid API call loop if (typeof window !== 'undefined') { import('@/features/auth/store/authStore') .then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logoutLocal(); }) .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é après plusieurs tentatives. Veuillez vous reconnecter.', ); window.location.href = '/login'; } // Process queue with error processQueue(new Error('Max refresh attempts reached')); return Promise.reject(parseApiError(error)); } // Increment attempt counter refreshAttempts++; try { // INT-AUTH-003: Refresh automatique du token // SECURITY: Action 5.1.1.3 - Refresh uses cookies, no need to set Authorization header await refreshToken(); logger.info( '[API] Token refresh successful, retrying original request', { request_id: requestId, url: originalRequest?.url, queue_size: failedQueue.length, attempt: refreshAttempts, }, ); // P1.4: Reset counter on successful refresh refreshAttempts = 0; // SECURITY: Action 5.1.1.3 - No need to set Authorization header // Backend reads access token from httpOnly cookie automatically // Cookies are sent automatically via withCredentials: true // INT-AUTH-003: Traiter la queue et retry la requête originale // Toutes les requêtes en queue seront rejouées (cookies sont automatiquement envoyés) processQueue(null); 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', { attempt: refreshAttempts, max_attempts: MAX_REFRESH_ATTEMPTS, request_id: requestId, error: refreshError, queue_size: failedQueue.length, }); // Rejeter toutes les requêtes en queue processQueue(refreshError as Error); // Nettoyer les tokens TokenStorage.clearTokens(); // Clear CSRF token csrfService.clearToken(); // INT-AUTH-003: Clear auth store state and redirect to login // FIX: Utiliser logoutLocal() pour éviter les boucles infinies // (logout -> 401 -> refresh -> 400 -> logout -> ...) if (typeof window !== 'undefined') { // Import and use auth store to clear state import('@/features/auth/store/authStore') .then(({ useAuthStore }) => { const store = useAuthStore.getState(); // Utiliser logoutLocal() au lieu de logout() pour éviter les appels API // qui déclencheraient à nouveau le refresh store.logoutLocal(); }) .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) { const errMsg = csrfError instanceof Error ? csrfError.message : String(csrfError); if (!errMsg.includes('HTML page instead of JSON')) { logger.error('[API] Failed to refresh CSRF token after CSRF error', { message: errMsg, }); } // 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 // FIX: Pour les erreurs 500 sur /marketplace/products, désactiver complètement les retries // car cela peut être dû à une liste vide (problème backend) et les retries sont inutiles const isMarketplaceProducts = originalRequest?.url?.includes('/marketplace/products'); const is500OnMarketplace = status === 500 && isMarketplaceProducts; // Si c'est une erreur 500 sur marketplace/products, ne jamais retry if (is500OnMarketplace) { logger.warn('[API] 500 error on marketplace/products, not retrying (likely empty state)', { url: originalRequest?.url, retry_count: retryCount, request_id: parseApiError(error).request_id, }); // Préserver l'erreur Axios originale pour que MarketplaceHome puisse vérifier response.status // On ajoute le status HTTP à l'erreur parsée pour faciliter la détection const apiError = parseApiError(error); // Ajouter le status HTTP à l'erreur pour faciliter la détection côté composant (apiError as any).httpStatus = status; return Promise.reject(apiError); } // 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 only first retry to reduce console noise when backend is down const apiError = parseApiError(error); const errorType = status ? `HTTP ${status}` : error.code || 'Network Error'; if (retryCount === 0) { if (apiError.request_id) { logger.warn( `[API Retry] ${errorType} error, retrying (1/${effectiveMaxRetries}) - Request ID: ${apiError.request_id}`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: 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 (1/${effectiveMaxRetries})`, { status: status || 'N/A', error_code: error.code || 'N/A', retry_count: 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 // isAuthMeEndpoint déjà défini plus haut : on ne redirige pas pour /auth/me (401 = non connecté, pas de redirect) if ( status === 401 && !isRefreshEndpoint && !isLogoutEndpoint && !isAuthMeEndpoint && typeof window !== 'undefined' ) { const errorCategory = getErrorCategory(apiError); if (errorCategory === 'authentication') { TokenStorage.clearTokens(); csrfService.clearToken(); import('@/features/auth/store/authStore') .then(({ useAuthStore }) => { const store = useAuthStore.getState(); store.logoutLocal(); }) .catch((err: unknown) => { logger.error('[API] Failed to import auth store for logout', { error: err, }); }); sessionStorage.setItem( 'auth_error', 'Votre session a expiré. Veuillez vous reconnecter.', ); window.location.href = '/login'; } } // FE-COMP-005: Show toast notification for API errors (unless disabled) // Skip toast for "wrong server" (HTML instead of JSON): already shown in response interceptor const isWrongServerError = apiError.message?.includes('HTML page instead of JSON') ?? false; const urlForToast = originalRequest?.url ?? ''; const isWebhooks5xxForToast = status && status >= 500 && urlForToast.includes('/webhooks'); 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 !isWrongServerError && !isWebhooks5xxForToast; // 5xx on webhooks: table may be missing, show empty state instead // 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, }); } } } // Edge 2.1: Distinguish partial vs complete network failures const isPartialFailure = isPartialNetworkFailure(error); const isCompleteFailure = isCompleteNetworkFailure(error); // 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) { if (isPartialFailure) { // Partial failure: intermittent connectivity - some requests succeed, some fail enhancedMessage = `${errorMessage} ⚠️ Connexion intermittente détectée. Certaines requêtes réussissent, d'autres échouent. La connexion devrait se rétablir automatiquement.`; } else if (isCompleteFailure) { // Complete failure: no connection at all enhancedMessage = `${errorMessage} ❌ Aucune connexion réseau. Vérifiez votre connexion internet et réessayez.`; } else { // Generic network error enhancedMessage = `${errorMessage} 💡 Vérifiez votre connexion internet. Si le problème persiste, le serveur pourrait être temporairement indisponible.`; } } // Edge 2.1: Log partial vs complete failure for monitoring if (isPartialFailure || isCompleteFailure) { logger.warn('[API] Network failure detected', { request_id: requestId, is_partial_failure: isPartialFailure, is_complete_failure: isCompleteFailure, url: originalRequest?.url, method: originalRequest?.method, error_code: error.code, error_message: error.message, }); } 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 // 5xx sur /webhooks = erreur backend, ne pas logger (éviter bruit console) const httpStatus = error.response?.status; const url = originalRequest?.url ?? ''; const isWebhooks5xx = httpStatus && httpStatus >= 500 && url.includes('/webhooks'); if (!isWebhooks5xx) { 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(); * ``` */ /** * Edge 2.2: Handle request cancellation * Creates a cancellable request with AbortController support. * The request can be cancelled by calling the returned abort() function. * Cancelled requests are handled gracefully and don't trigger error toasts or retries. * * @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).catch((error) => { // Edge 2.2: Ensure cancelled requests are handled gracefully // If the request was cancelled, the error will be handled by the error interceptor // which checks axios.isCancel(error) and doesn't retry or show toasts if (axios.isCancel(error) || error.name === 'AbortError' || signal.aborted) { // Re-throw the cancellation error - it will be handled by the error interceptor throw error; } // Re-throw other errors throw error; }); return { request, abort: () => { // Edge 2.2: Abort the request if not already aborted if (!signal.aborted) { abortController.abort(); } }, }; } /** * Edge 2.2: Handle request cancellation with timeout * Creates a request with automatic timeout cancellation. * The request will be automatically cancelled if it exceeds the timeout duration. * Can also be manually cancelled by calling the returned abort() function. * * @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; // Edge 2.2: Set up timeout that automatically cancels the request const timeoutId = setTimeout(() => { if (!signal.aborted) { abortController.abort(); } }, timeoutMs); const request = requestFn(signal) .catch((error) => { // Edge 2.2: Ensure cancelled requests (timeout or manual) are handled gracefully // The error interceptor will handle cancellation errors properly if (axios.isCancel(error) || error.name === 'AbortError' || signal.aborted) { // Re-throw the cancellation error - it will be handled by the error interceptor throw error; } throw error; }) .finally(() => { // Clear timeout if request completes before timeout clearTimeout(timeoutId); }); return { request, abort: () => { clearTimeout(timeoutId); // Edge 2.2: Abort the request if not already aborted if (!signal.aborted) { 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), ); }, }; /** * Edge 2.3: Utility function to check if a request is slow * Can be used by components to show additional loading indicators for slow requests * * @param config - Axios request config (from response.config or request config) * @returns true if the request is considered slow, false otherwise * * @example * ```typescript * try { * const response = await apiClient.get('/api/v1/tracks'); * if (isSlowRequest(response.config)) { * // Show additional loading feedback * } * } catch (error) { * // Handle error * } * ``` */ export function isSlowRequest( config?: InternalAxiosRequestConfig, ): boolean { if (!config) return false; return (config as any)?._isSlowRequest === true; } /** * Edge 2.3: Utility function to get request duration * Returns the duration of a request in milliseconds * * @param config - Axios request config (from response.config) * @returns Request duration in milliseconds, or undefined if not available * * @example * ```typescript * const response = await apiClient.get('/api/v1/tracks'); * const duration = getRequestDuration(response.config); * if (duration && duration > 2000) { * console.log(`Request took ${duration}ms`); * } * ``` */ export function getRequestDuration( config?: InternalAxiosRequestConfig, ): number | undefined { if (!config) return undefined; return (config as any)?._requestDuration; }