/** * Error response interceptor: CSRF retry, rate limit, retry logic, toast, offline queue */ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, } from 'axios'; import toast from '@/utils/toast'; import { env } from '@/config/env'; import { parseApiError } from '@/utils/apiErrorHandler'; import { formatUserFriendlyError } from '@/utils/errorMessages'; import { csrfService } from '../../csrf'; import { logger, setLogContext } from '@/utils/logger'; import { offlineQueue } from '../../offlineQueue'; import type { ApiResponse } from '@/types/api'; import { useRateLimitStore } from '@/stores/rateLimit'; import { sleep, DEFAULT_RETRY_CONFIG, isIdempotentMethod, isPartialNetworkFailure, isCompleteNetworkFailure, isRetryableError, getRetryDelay, } from '../retry'; import { sanitizeForLogging } from './utils'; import { handleAuthError, handleAuthRedirectOn401 } from './auth'; export function createErrorResponseHandler(apiClient: AxiosInstance) { return async (error: AxiosError>) => { // Cancelled requests if (axios.isCancel(error)) { if ( (import.meta.env.DEV && env.DEBUG) || (error.config as InternalAxiosRequestConfig & { _enableLogging?: boolean })?._enableLogging ) { logger.debug( `[API Request Cancelled] ${error.config?.method?.toUpperCase() || 'GET'} ${error.config?.url}`, { request_id: (error.config as InternalAxiosRequestConfig & { _requestId?: string })?._requestId, }, ); } return Promise.reject(error); } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; _retryCount?: number; _csrfRetry?: boolean; _disableToast?: boolean; _requestId?: string; _requestStartTime?: number; }; // Request ID from error response let requestId = originalRequest?._requestId; if (error.response?.headers) { const requestIdFromHeader = error.response.headers['x-request-id'] || error.response.headers['X-Request-ID']; if (requestIdFromHeader) { requestId = requestIdFromHeader; setLogContext({ request_id: requestId }); } // Rate limit headers from error 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, }); } } // Debug error logging const shouldLogError = (import.meta.env.DEV && env.DEBUG) || originalRequest?._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(' apiClient(originalRequest)); } // Max retries exceeded if (retryCount >= maxRetries) { const apiError = parseApiError(error); const errorType = status ? `HTTP ${status}` : error.code || 'Network Error'; logger.error( `[API Error] ${errorType} error after ${maxRetries} retries`, { code: apiError.code, message: apiError.message, request_id: apiError.request_id, url: originalRequest?.url, method: originalRequest?.method, }, ); return Promise.reject(apiError); } // Parse final error const apiError = parseApiError(error); // Auth redirect on 401 (fallback) // Skip login/register: their 401 should be shown as form errors, not cause a redirect const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh'); const isLogoutEndpoint = originalRequest?.url?.includes('/auth/logout'); const isAuthMeEndpoint = originalRequest?.url?.includes('/auth/me'); const isLoginEndpoint = originalRequest?.url?.includes('/auth/login'); const isRegisterEndpoint = originalRequest?.url?.includes('/auth/register'); if (!isLoginEndpoint && !isRegisterEndpoint) { handleAuthRedirectOn401( apiError, status, isRefreshEndpoint, isLogoutEndpoint, isAuthMeEndpoint, ); } // --- Error toast --- 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?._disableToast && status !== 401 && status !== 404 && !axios.isCancel(error) && !isWrongServerError && !isWebhooks5xxForToast; const isNetworkError = !error.response; if (isNetworkError) { const { recordNetworkError } = await import( '@/utils/networkErrorTracker' ); recordNetworkError(apiError); } const toastId = isNetworkError ? 'network-error-toast' : status && status >= 500 ? 'server-error-toast' : status === 403 ? 'forbidden-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, ); // Offline queue if ( !error.response && originalRequest && offlineQueue.shouldQueueRequest(originalRequest) ) { const isOffline = typeof navigator !== 'undefined' && !navigator.onLine; if (isOffline || (!error.response && error.request)) { const method = originalRequest.method?.toUpperCase(); const priority = method === 'DELETE' ? 'low' : method === 'POST' ? 'high' : 'normal'; try { await offlineQueue.queueRequest(originalRequest, { priority }); toast.success( "Requête mise en file d'attente. Elle sera envoyée à la reconnexion.", { duration: 4000, id: 'offline-queue-toast' }, ); } catch (queueError) { logger.error( '[API] Failed to queue request for offline replay', { error: queueError }, ); } } } // Enhanced network error messages let enhancedMessage = errorMessage; if (isNetworkError) { if (isPartialNetworkFailure(error)) { enhancedMessage = `${errorMessage} ⚠️ Connexion intermittente détectée. Certaines requêtes réussissent, d'autres échouent. La connexion devrait se rétablir automatiquement.`; } else if (isCompleteNetworkFailure(error)) { enhancedMessage = `${errorMessage} ❌ Aucune connexion réseau. Vérifiez votre connexion internet et réessayez.`; } else { enhancedMessage = `${errorMessage} 💡 Vérifiez votre connexion internet. Si le problème persiste, le serveur pourrait être temporairement indisponible.`; } } if ( (isPartialNetworkFailure(error) || isCompleteNetworkFailure(error)) && isNetworkError ) { logger.warn('[API] Network failure detected', { request_id: requestId, is_partial_failure: isPartialNetworkFailure(error), is_complete_failure: isCompleteNetworkFailure(error), url: originalRequest?.url, method: originalRequest?.method, error_code: error.code, error_message: error.message, }); } toast.error(enhancedMessage, { duration: 8000, id: toastId, }); } // Final error log 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); }; }