diff --git a/apps/web/src/services/api/client.ts b/apps/web/src/services/api/client.ts index 966eed9fa..2d08314df 100644 --- a/apps/web/src/services/api/client.ts +++ b/apps/web/src/services/api/client.ts @@ -60,13 +60,13 @@ class ValidationMetricsTracker { 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(); } @@ -85,7 +85,7 @@ class ValidationMetricsTracker { 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'); + .replace(/\/\d+/g, '/:id'); } catch { // Fallback: remove query params and return path const path = url.split('?')[0]; @@ -170,7 +170,7 @@ class ValidationAlerting { // 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; @@ -244,8 +244,11 @@ export const apiClient = axios.create({ 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; @@ -309,12 +312,12 @@ class NetworkFailureTracker { 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); @@ -726,9 +729,9 @@ apiClient.interceptors.request.use( error: error.message, config: error.config ? { - url: error.config.url, - method: error.config.method, - } + url: error.config.url, + method: error.config.method, + } : undefined, }); } @@ -827,15 +830,15 @@ apiClient.interceptors.response.use( 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'], @@ -930,17 +933,17 @@ apiClient.interceptors.response.use( 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 } @@ -948,7 +951,7 @@ apiClient.interceptors.response.use( 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); @@ -959,7 +962,7 @@ apiClient.interceptors.response.use( 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); @@ -972,14 +975,14 @@ apiClient.interceptors.response.use( 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, @@ -989,7 +992,7 @@ apiClient.interceptors.response.use( } } } - + // Recovery 2: Optional retry (only if explicitly enabled) if (retry && !(response.config as any)?._validationRetryAttempted) { (response.config as any)._validationRetryAttempted = true; @@ -1001,11 +1004,11 @@ apiClient.interceptors.response.use( 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) @@ -1023,7 +1026,7 @@ apiClient.interceptors.response.use( request_id: requestId, url: response.config.url, }); - + // Action 1.2.2.3: Track validation success metrics validationMetrics.recordSuccess(response.config.url); } @@ -1323,6 +1326,54 @@ apiClient.interceptors.response.use( 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 @@ -1334,9 +1385,13 @@ apiClient.interceptors.response.use( 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 @@ -1347,7 +1402,9 @@ apiClient.interceptors.response.use( 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', { + logger.error('[API] Token refresh failed', { + attempt: refreshAttempts, + max_attempts: MAX_REFRESH_ATTEMPTS, request_id: requestId, error: refreshError, queue_size: failedQueue.length, @@ -1479,7 +1536,7 @@ apiClient.interceptors.response.use( // 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)', {