2026-01-13 18:47:57 +00:00
import axios , {
AxiosError ,
InternalAxiosRequestConfig ,
AxiosResponse ,
} from 'axios' ;
2026-01-15 15:56:21 +00:00
// CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables
import toast from '@/utils/toast' ;
2025-12-25 13:30:55 +00:00
import { z } from 'zod' ;
2025-12-03 21:56:50 +00:00
import { TokenStorage } from '../tokenStorage' ;
2026-01-16 00:02:03 +00:00
import { refreshToken } from '../tokenRefresh' ;
2025-12-16 19:40:16 +00:00
import { env } from '@/config/env' ;
2026-01-11 16:29:55 +00:00
import { parseApiError , getErrorCategory } from '@/utils/apiErrorHandler' ;
2026-01-07 09:33:52 +00:00
import { formatUserFriendlyError } from '@/utils/errorMessages' ;
2025-12-22 21:56:37 +00:00
import { csrfService } from '../csrf' ;
2025-12-27 00:50:39 +00:00
import { logger , setLogContext } from '@/utils/logger' ;
2026-01-13 18:47:57 +00:00
import {
isTimeoutError as _isTimeoutError ,
getTimeoutMessage as _getTimeoutMessage ,
} from '@/utils/timeoutHandler' ;
2025-12-25 12:24:19 +00:00
import { offlineQueue } from '../offlineQueue' ;
2025-12-25 12:26:27 +00:00
import { requestDeduplication } from '../requestDeduplication' ;
2025-12-25 12:29:43 +00:00
import { responseCache } from '../responseCache' ;
2025-12-25 12:45:49 +00:00
import { invalidateStateAfterMutation } from '@/utils/stateInvalidation' ;
2025-12-25 13:30:55 +00:00
import { safeValidateApiResponse } from '@/schemas/apiSchemas' ;
2025-12-25 13:36:32 +00:00
import { safeValidateApiRequest } from '@/schemas/apiRequestSchemas' ;
2025-12-22 21:56:37 +00:00
import type { ApiResponse } from '@/types/api' ;
2026-01-15 18:54:49 +00:00
import { useRateLimitStore } from '@/stores/rateLimit' ;
2025-12-03 21:56:50 +00:00
2026-01-15 16:21:41 +00:00
/ * *
* 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 < string , number > ;
}
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 ( ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:21:41 +00:00
if ( url ) {
const endpoint = this . normalizeEndpoint ( url ) ;
this . metrics . failuresByEndpoint [ endpoint ] =
( this . metrics . failuresByEndpoint [ endpoint ] || 0 ) + 1 ;
}
2026-01-29 22:16:37 +00:00
2026-01-15 16:21:41 +00:00
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' )
2026-01-29 22:16:37 +00:00
. replace ( /\/\d+/g , '/:id' ) ;
2026-01-15 16:21:41 +00:00
} 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 ( ) ;
2026-01-15 16:23:01 +00:00
/ * *
* 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 < ValidationAlertConfig > ) : 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 ( ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:23:01 +00:00
// 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 < ValidationAlertConfig > ) : 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 ( ) ;
}
}
2025-12-03 21:56:50 +00:00
/ * *
2025-12-25 10:11:54 +00:00
* Client API avec interceptors pour refresh automatique des tokens ,
* unwrapping du format backend { success , data , error } ,
* et retry automatique avec exponential backoff
2025-12-16 19:40:16 +00:00
* Aligné avec FRONTEND_INTEGRATION . md
2025-12-03 21:56:50 +00:00
* /
2025-12-25 21:42:56 +00:00
// 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 ;
2026-01-16 11:51:14 +00:00
// 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
2025-12-03 21:56:50 +00:00
// Client API réutilisable
export const apiClient = axios . create ( {
2025-12-16 19:40:16 +00:00
baseURL : env.API_URL ,
2025-12-25 21:42:56 +00:00
timeout : API_TIMEOUTS.DEFAULT ,
2025-12-03 21:56:50 +00:00
headers : {
'Content-Type' : 'application/json' ,
} ,
2026-01-07 09:33:52 +00:00
// 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 ,
2025-12-03 21:56:50 +00:00
} ) ;
2026-01-29 22:16:37 +00:00
// P1.4: Refresh token loop protection
2025-12-03 21:56:50 +00:00
// Flag pour éviter les refresh en boucle
let isRefreshing = false ;
2026-01-29 22:16:37 +00:00
let refreshAttempts = 0 ;
const MAX_REFRESH_ATTEMPTS = 3 ;
2025-12-03 21:56:50 +00:00
let failedQueue : Array < {
resolve : ( value? : any ) = > void ;
reject : ( error? : any ) = > void ;
} > = [ ] ;
2026-01-16 00:02:03 +00:00
// SECURITY: Action 5.1.1.3 - Removed proactive refresh cooldown logic
// Tokens are in httpOnly cookies, can't check expiration from JS
2025-12-26 09:50:30 +00:00
2025-12-22 22:10:52 +00:00
/ * *
* Sleep utility function
* /
const sleep = ( ms : number ) : Promise < void > = > {
return new Promise ( ( resolve ) = > setTimeout ( resolve , ms ) ) ;
} ;
/ * *
2025-12-25 10:11:54 +00:00
* 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
2025-12-26 09:49:50 +00:00
retryableStatusCodes : [ 500 , 502 , 503 , 504 ] , // Server errors, gateway errors (429 excluded - don't retry rate limits)
2025-12-25 10:11:54 +00:00
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 ;
} ;
2026-01-16 12:08:14 +00:00
/ * *
* 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 } ) ;
2026-01-29 22:16:37 +00:00
2026-01-16 12:08:14 +00:00
// Keep only requests within the time window
this . recentRequests = this . recentRequests . filter (
( req ) = > now - req . timestamp < this . windowMs ,
) ;
2026-01-29 22:16:37 +00:00
2026-01-16 12:08:14 +00:00
// 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 ;
} ;
2025-12-25 10:11:54 +00:00
/ * *
* Check if an error is retryable
* /
2026-01-13 18:47:57 +00:00
const isRetryableError = (
error : AxiosError ,
config : RetryConfig = DEFAULT_RETRY_CONFIG ,
) : boolean = > {
2025-12-25 10:11:54 +00:00
// Don't retry if request was cancelled
if ( axios . isCancel ( error ) ) {
return false ;
}
2026-02-10 18:29:10 +00:00
// 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 ;
}
2025-12-25 10:11:54 +00:00
// Check if retry is disabled for this request
if ( ( error . config as any ) ? . _disableRetry ) {
return false ;
}
2026-01-16 12:08:14 +00:00
// 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 ) ;
}
2025-12-25 10:11:54 +00:00
// 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 ( ) ;
2026-01-13 18:47:57 +00:00
const networkErrorPatterns = [
'network' ,
'timeout' ,
'connection' ,
'econn' ,
'etimedout' ,
'enotfound' ,
] ;
2025-12-25 10:11:54 +00:00
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
2025-12-22 22:10:52 +00:00
* /
const getRetryDelay = (
error : AxiosError ,
attempt : number ,
2025-12-25 10:11:54 +00:00
baseDelay : number = DEFAULT_RETRY_CONFIG . baseDelay ,
maxDelay : number = DEFAULT_RETRY_CONFIG . maxDelay ,
2025-12-22 22:10:52 +00:00
) : 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 ) {
2025-12-25 10:11:54 +00:00
return Math . min ( delay * 1000 , maxDelay ) ; // Convert to milliseconds, cap at maxDelay
2025-12-22 22:10:52 +00:00
}
}
2025-12-25 10:11:54 +00:00
// 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 ) ;
2025-12-22 22:10:52 +00:00
} ;
2025-12-25 10:18:27 +00:00
/ * *
* Sanitize sensitive data from request / response for logging
* /
const sanitizeForLogging = ( data : any ) : any = > {
if ( ! data || typeof data !== 'object' ) {
return data ;
}
2026-01-13 18:47:57 +00:00
const sensitiveKeys = [
'password' ,
'token' ,
'access_token' ,
'refresh_token' ,
'secret' ,
'authorization' ,
'x-csrf-token' ,
] ;
2025-12-25 10:18:27 +00:00
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)
2026-01-13 18:47:57 +00:00
const requestId =
( config . headers as any ) ? . [ 'X-Request-ID' ] ||
2026-01-07 10:15:48 +00:00
( config . headers as any ) ? . [ 'x-request-id' ] ||
` req_ ${ Date . now ( ) } _ ${ Math . random ( ) . toString ( 36 ) . substr ( 2 , 9 ) } ` ;
2025-12-25 10:18:27 +00:00
// Store in config for later use
( config as any ) . _requestId = requestId ;
2026-01-07 10:15:48 +00:00
2025-12-25 10:18:27 +00:00
return requestId ;
} ;
2025-12-03 21:56:50 +00:00
// T0177: Fonction pour traiter la queue de requêtes en attente
2026-01-16 00:02:03 +00:00
// 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 ) = > {
2025-12-13 02:34:34 +00:00
failedQueue . forEach ( ( prom ) = > {
2025-12-03 21:56:50 +00:00
if ( error ) {
prom . reject ( error ) ;
} else {
2026-01-16 00:02:03 +00:00
prom . resolve ( undefined ) ; // No token needed, cookies are sent automatically
2025-12-03 21:56:50 +00:00
}
} ) ;
failedQueue = [ ] ;
} ;
// T0177: Interceptor de requête pour ajouter le token d'accès
2025-12-22 14:53:47 +00:00
// CRITIQUE: Récupérer TOUJOURS le token frais depuis localStorage car Zustand peut ne pas être hydraté
2025-12-03 21:56:50 +00:00
apiClient . interceptors . request . use (
2025-12-26 08:15:00 +00:00
async ( config : InternalAxiosRequestConfig ) = > {
2026-01-16 11:51:14 +00:00
// 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 ;
2026-01-11 15:33:12 +00:00
// API Versioning: Add X-API-Version header to all requests
if ( config . headers ) {
config . headers [ 'X-API-Version' ] = env . API_VERSION ;
}
2026-01-16 00:02:03 +00:00
// 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
2025-12-22 21:56:37 +00:00
2025-12-22 14:53:47 +00:00
// 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' ] ;
}
2025-12-22 21:56:37 +00:00
2026-01-07 09:33:52 +00:00
// 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
2025-12-22 21:56:37 +00:00
const method = config . method ? . toUpperCase ( ) ;
2026-01-13 18:47:57 +00:00
const isStateChanging = [ 'POST' , 'PUT' , 'DELETE' , 'PATCH' ] . includes (
method || '' ,
) ;
2025-12-26 08:15:00 +00:00
// isCSRFRoute déjà défini plus haut
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
const isAuthRoute =
config . url ? . includes ( '/auth/login' ) ||
config . url ? . includes ( '/auth/register' ) ;
2026-01-16 00:02:03 +00:00
const isCSRFRoute = config . url ? . includes ( '/csrf-token' ) ;
feat: Visual masterpiece - true light mode & premium UI
🎨 **True Light/Dark Mode**
- Implemented proper light mode with inverted color scheme
- Smooth theme transitions (0.3s ease)
- Light mode colors: white backgrounds, dark text, vibrant accents
- System theme detection with proper class application
🌈 **Enhanced Theme System**
- 4 color themes work in both light and dark modes
- Cyber (cyan/magenta), Ocean (blue/teal), Forest (green/lime), Sunset (orange/purple)
- Theme-specific glassmorphism effects
- Proper contrast in light mode
✨ **Premium Animations**
- Float, glow-pulse, slide-in, scale-in, rotate-in animations
- Smooth page transitions
- Hover effects with depth (lift, glow, scale)
- Micro-interactions on all interactive elements
🎯 **Visual Polish**
- Enhanced glassmorphism for light/dark modes
- Custom scrollbar with theme colors
- Beautiful text selection
- Focus indicators for accessibility
- Premium utility classes
🔧 **Technical Improvements**
- Updated UIStore to properly apply light/dark classes
- Added data-theme attribute for CSS targeting
- Smooth scroll behavior
- Optimized transitions
The app is now a visual masterpiece with perfect light/dark mode support!
2026-01-11 01:32:21 +00:00
2026-01-13 18:47:57 +00:00
if (
isStateChanging &&
! isCSRFRoute &&
! isAuthRoute &&
config . headers
) {
2026-01-07 09:33:52 +00:00
// 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
2026-01-13 18:47:57 +00:00
logger . warn (
'[API] Failed to fetch CSRF token before request, will retry on 403' ,
{
url : config.url ,
method : config.method ,
} ,
) ;
2026-01-07 09:33:52 +00:00
}
}
2026-01-07 10:15:48 +00:00
2026-01-07 09:33:52 +00:00
if ( csrfToken && config . headers ) {
2025-12-22 21:56:37 +00:00
config . headers [ 'X-CSRF-Token' ] = csrfToken ;
}
}
2026-01-16 11:49:40 +00:00
// 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
2025-12-25 10:14:03 +00:00
if ( ! config . signal && ! config . cancelToken ) {
2026-01-16 11:49:40 +00:00
// No cancellation configured - users can pass a signal via config.signal
// or use the helper functions for automatic cancellation support
2025-12-25 10:14:03 +00:00
}
2025-12-25 13:36:32 +00:00
// FE-TYPE-003: Validate request data if schema is provided
2026-01-11 15:39:51 +00:00
// Action 1.2.1.5: Ensure all requests are validated when schema is provided
2026-01-13 18:47:57 +00:00
const requestSchema = ( config as any ) ? . _requestSchema as
| z . ZodSchema
| undefined ;
2025-12-25 13:36:32 +00:00
if ( requestSchema && config . data !== undefined && config . data !== null ) {
2026-01-11 15:39:51 +00:00
// Skip validation for FormData (file uploads) - they are validated separately
2025-12-25 13:36:32 +00:00
if ( ! ( config . data instanceof FormData ) ) {
const validation = safeValidateApiRequest ( requestSchema , config . data ) ;
if ( ! validation . success ) {
2026-01-11 15:39:51 +00:00
const requestId = getRequestId ( config ) ;
// Log validation error with structured logging
2026-01-13 18:47:57 +00:00
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 ,
) ;
2025-12-25 13:36:32 +00:00
// Throw error to prevent invalid request from being sent
2026-01-11 15:39:51 +00:00
// This ensures data integrity before sending to backend
2026-01-13 18:47:57 +00:00
const errorMessages =
validation . error ? . errors
. map ( ( e ) = > ` ${ e . path . join ( '.' ) } : ${ e . message } ` )
. join ( ', ' ) || 'Request validation failed' ;
2026-01-11 15:39:51 +00:00
throw new Error ( ` Request validation failed: ${ errorMessages } ` ) ;
2025-12-25 13:36:32 +00:00
}
2026-01-11 15:39:51 +00:00
// Use validated data (Zod may transform/coerce values)
2025-12-25 13:36:32 +00:00
config . data = validation . data ;
}
}
2025-12-25 10:18:27 +00:00
// Store request start time for duration calculation
( config as any ) . _requestStartTime = Date . now ( ) ;
2026-02-10 12:52:23 +00:00
// Log request only when VITE_DEBUG=true or explicitly enabled (reduces console noise)
if ( ( import . meta . env . DEV && env . DEBUG ) || ( config as any ) ? . _enableLogging ) {
2025-12-25 10:18:27 +00:00
const requestId = getRequestId ( config ) ;
const sanitizedHeaders = sanitizeForLogging ( { . . . config . headers } ) ;
const sanitizedData = sanitizeForLogging ( config . data ) ;
2026-01-07 10:15:48 +00:00
2025-12-25 10:18:27 +00:00
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 ,
} ) ;
}
2025-12-03 21:56:50 +00:00
return config ;
} ,
( error ) = > {
2025-12-25 10:18:27 +00:00
// Log request error
if ( import . meta . env . DEV ) {
logger . error ( '[API Request Error]' , {
error : error.message ,
2026-01-13 18:47:57 +00:00
config : error.config
? {
2026-01-29 22:16:37 +00:00
url : error.config.url ,
method : error.config.method ,
}
2026-01-13 18:47:57 +00:00
: undefined ,
2025-12-25 10:18:27 +00:00
} ) ;
}
2025-12-03 21:56:50 +00:00
return Promise . reject ( error ) ;
2025-12-13 02:34:34 +00:00
} ,
2025-12-03 21:56:50 +00:00
) ;
2026-02-10 12:52:23 +00:00
// 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 ( '<!doctype' ) || trimmed . startsWith ( '<html' ) ;
}
return false ;
}
2025-12-16 19:40:16 +00:00
// Interceptor de réponse pour unwrap le format backend et gérer les erreurs
2025-12-03 21:56:50 +00:00
apiClient . interceptors . response . use (
2025-12-25 10:09:19 +00:00
( response : AxiosResponse < ApiResponse < any > | any > ) = > {
2026-02-10 12:52:23 +00:00
// 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 ,
) ,
) ;
}
2026-01-16 12:08:14 +00:00
// Edge 2.1: Record successful request for partial failure detection
networkFailureTracker . recordRequest ( true ) ;
2026-01-16 11:51:14 +00:00
// 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 ;
2026-02-10 12:52:23 +00:00
if ( ( import . meta . env . DEV && env . DEBUG ) || ( response . config as any ) ? . _enableLogging ) {
2026-01-16 11:51:14 +00:00
logger . debug (
` [API Slow Request] ${ response . config ? . method ? . toUpperCase ( ) } ${ response . config ? . url } took ${ requestDuration } ms ` ,
{
duration : requestDuration ,
threshold : SLOW_REQUEST_THRESHOLD ,
} ,
) ;
}
}
}
2025-12-27 00:50:39 +00:00
// FIX #22: Extraire le request_id depuis les headers de réponse pour corrélation
2026-01-13 18:47:57 +00:00
const requestIdFromHeader =
response . headers [ 'x-request-id' ] || response . headers [ 'X-Request-ID' ] ;
const requestId =
requestIdFromHeader || ( response . config as any ) ? . _requestId ;
2026-01-07 10:15:48 +00:00
2025-12-27 00:50:39 +00:00
// Mettre à jour le contexte global du logger avec le request_id
if ( requestId ) {
setLogContext ( { request_id : requestId } ) ;
}
2026-01-07 10:15:48 +00:00
2026-01-15 18:54:49 +00:00
// 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
} ) ;
}
2026-02-10 12:52:23 +00:00
// 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 ;
2026-01-07 10:15:48 +00:00
2026-02-10 12:52:23 +00:00
if ( shouldLogResponse ) {
2025-12-25 10:18:27 +00:00
const sanitizedData = sanitizeForLogging ( response . data ) ;
const sanitizedHeaders = sanitizeForLogging ( response . headers ) ;
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
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 ,
} ,
) ;
2025-12-25 10:18:27 +00:00
}
2026-01-15 15:56:21 +00:00
// 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.' ;
2026-01-29 22:16:37 +00:00
2026-01-15 15:56:21 +00:00
// Use toast with warning icon (react-hot-toast doesn't have toast.warning)
toast ( message , {
icon : '⚠️' ,
duration : 10000 , // Show for 10 seconds
} ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 15:56:21 +00:00
sessionStorage . setItem ( deprecationKey , 'true' ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 15:56:21 +00:00
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 ,
} ) ;
}
}
2025-12-25 10:09:19 +00:00
// 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: "..." }
2026-01-07 10:15:48 +00:00
2025-12-25 10:09:19 +00:00
if ( ! response . data || typeof response . data !== 'object' ) {
// Si response.data n'est pas un objet, retourner tel quel
return response ;
}
2025-12-25 10:32:53 +00:00
// FE-COMP-005: Show success toast for mutation operations if enabled
const method = response . config . method ? . toUpperCase ( ) ;
2026-01-13 18:47:57 +00:00
const isMutation = [ 'POST' , 'PUT' , 'PATCH' , 'DELETE' ] . includes (
method || '' ,
) ;
const shouldShowSuccessToast =
isMutation &&
2026-01-07 10:15:48 +00:00
( response . config as any ) ? . _showSuccessToast &&
typeof window !== 'undefined' ;
2025-12-25 10:32:53 +00:00
if ( shouldShowSuccessToast ) {
2026-01-13 18:47:57 +00:00
const successMessage =
( response . config as any ) ? . _successMessage ||
2026-01-07 10:15:48 +00:00
( response . data as any ) ? . message ||
getDefaultSuccessMessage ( method || '' ) ;
2025-12-25 10:32:53 +00:00
if ( successMessage ) {
toast . success ( successMessage ) ;
}
}
2025-12-25 12:29:43 +00:00
// 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
2025-12-25 12:45:49 +00:00
// FE-STATE-004: Invalidate state after mutations
2025-12-25 12:29:43 +00:00
if ( isMutation ) {
const url = response . config . url || '' ;
2025-12-25 12:45:49 +00:00
const method = response . config . method || 'POST' ;
2026-01-07 10:15:48 +00:00
2025-12-25 12:45:49 +00:00
// Use centralized invalidation system
invalidateStateAfterMutation ( url , method ) ;
2025-12-25 12:29:43 +00:00
}
2025-12-25 21:40:59 +00:00
// INT-API-002: Vérifier si c'est le format wrapper avec success
2025-12-25 10:09:19 +00:00
if ( 'success' in response . data ) {
2025-12-16 19:40:16 +00:00
if ( response . data . success === true ) {
2025-12-25 10:09:19 +00:00
// Format wrapper standard: { success: true, data: {...} }
// On unwrap pour retourner directement data
// Si data est null/undefined, on retourne null au lieu de undefined
2026-01-13 18:47:57 +00:00
const unwrappedData =
response . data . data !== undefined ? response.data.data : null ;
2026-01-07 10:15:48 +00:00
2025-12-25 13:30:55 +00:00
// FE-TYPE-002: Validate response data if schema is provided
2026-01-15 16:18:02 +00:00
// Action 1.2.2.1: Enhanced response validation for all responses with schemas
2026-01-13 18:47:57 +00:00
const responseSchema = ( response . config as any ) ? . _responseSchema as
| z . ZodSchema
| undefined ;
2025-12-25 13:30:55 +00:00
if ( responseSchema && unwrappedData !== null ) {
2026-01-13 18:47:57 +00:00
const validation = safeValidateApiResponse (
responseSchema ,
unwrappedData ,
) ;
2025-12-25 13:30:55 +00:00
if ( ! validation . success ) {
2026-01-15 16:18:02 +00:00
const requestId = getRequestId ( response . config ) ;
2026-01-15 16:19:17 +00:00
// 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 ( ) ,
} ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:19:17 +00:00
// Log to structured logger (sends to backend endpoint and Sentry in production)
2026-01-15 16:18:02 +00:00
logger . error (
'[API Response Validation Failed]' ,
2026-01-15 16:19:17 +00:00
validationErrorContext ,
2026-01-13 18:47:57 +00:00
validation . error ,
) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:21:41 +00:00
// Action 1.2.2.3: Track validation failure metrics
validationMetrics . recordFailure ( response . config . url ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// 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
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// 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 ;
}
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// 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' ,
} ,
) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
if ( notifyUser && typeof window !== 'undefined' ) {
toast ( 'Data may be outdated. Please refresh if issues persist.' , {
icon : '⚠️' ,
duration : 5000 ,
} ) ;
}
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// Return cached response with unwrapped data (matching current format)
return {
. . . cachedResponse ,
data : cachedData ,
} as AxiosResponse < any > ;
}
}
}
}
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// 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' ,
} ,
) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:25:44 +00:00
// Retry the request (will go through the same validation again)
return apiClient . request ( response . config ) ;
}
2026-01-29 22:16:37 +00:00
2026-01-15 16:18:02 +00:00
// 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)
2026-01-15 16:19:17 +00:00
// Validation errors are logged for monitoring and alerting
2026-01-15 16:25:44 +00:00
if ( notifyUser && typeof window !== 'undefined' ) {
toast ( 'Some data may be incomplete. Please refresh if issues persist.' , {
icon : '⚠️' ,
duration : 5000 ,
} ) ;
}
2026-01-15 16:18:02 +00:00
} 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 ,
} ) ;
2026-01-29 22:16:37 +00:00
2026-01-15 16:21:41 +00:00
// Action 1.2.2.3: Track validation success metrics
validationMetrics . recordSuccess ( response . config . url ) ;
2025-12-25 13:30:55 +00:00
}
}
2026-01-07 10:15:48 +00:00
2025-12-16 19:40:16 +00:00
return {
. . . response ,
2025-12-25 10:09:19 +00:00
data : unwrappedData ,
2025-12-16 19:40:16 +00:00
} as AxiosResponse < any > ;
}
2026-01-07 10:15:48 +00:00
2025-12-25 21:40:59 +00:00
// 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 ,
} ) ;
2026-01-07 10:15:48 +00:00
2025-12-25 21:40:59 +00:00
// 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 < ApiResponse < any > > (
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 < ApiResponse < any > > ,
) ;
2026-01-07 10:15:48 +00:00
2025-12-25 21:40:59 +00:00
// Rejeter pour que l'interceptor d'erreur gère cette erreur
// parseApiError détectera automatiquement le format { success: false, error: {...} }
return Promise . reject ( axiosError ) ;
}
2025-12-16 19:40:16 +00:00
}
2026-01-07 10:15:48 +00:00
2026-01-15 16:33:28 +00:00
// 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)' ,
{
2026-01-15 16:19:17 +00:00
request_id : requestId ,
url : response.config.url ,
method : response.config.method?.toUpperCase ( ) ,
status : response.status ,
2026-01-15 16:33:28 +00:00
response_preview : JSON.stringify ( response . data ) . substring ( 0 , 200 ) ,
2026-01-15 16:19:17 +00:00
timestamp : new Date ( ) . toISOString ( ) ,
2026-01-15 16:33:28 +00:00
} ,
) ;
// Continue with the response as-is (graceful degradation)
// This should not happen if backend is properly deployed
2025-12-25 13:30:55 +00:00
}
2026-01-07 10:15:48 +00:00
2025-12-03 21:56:50 +00:00
return response ;
} ,
2025-12-16 19:40:16 +00:00
async ( error : AxiosError < ApiResponse < any > > ) = > {
2025-12-25 10:14:03 +00:00
// Don't retry or process cancelled requests
if ( axios . isCancel ( error ) ) {
2025-12-25 10:18:27 +00:00
const requestId = ( error . config as any ) ? . _requestId ;
2026-02-10 12:52:23 +00:00
if ( ( import . meta . env . DEV && env . DEBUG ) || ( error . config as any ) ? . _enableLogging ) {
2026-01-13 18:47:57 +00:00
logger . debug (
` [API Request Cancelled] ${ error . config ? . method ? . toUpperCase ( ) || 'GET' } ${ error . config ? . url } ` ,
{
request_id : requestId ,
} ,
) ;
2025-12-25 10:18:27 +00:00
}
2025-12-25 10:14:03 +00:00
return Promise . reject ( error ) ;
}
2025-12-03 21:56:50 +00:00
const originalRequest = error . config as InternalAxiosRequestConfig & {
_retry? : boolean ;
} ;
2026-01-07 10:15:48 +00:00
2025-12-27 00:53:49 +00:00
// 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 ) {
2026-01-13 18:47:57 +00:00
const requestIdFromHeader =
error . response . headers [ 'x-request-id' ] ||
2026-01-07 10:15:48 +00:00
error . response . headers [ 'X-Request-ID' ] ;
2025-12-27 00:53:49 +00:00
if ( requestIdFromHeader ) {
requestId = requestIdFromHeader ;
// Mettre à jour le contexte global du logger avec le request_id
setLogContext ( { request_id : requestId } ) ;
}
2026-01-15 18:54:49 +00:00
// 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 ,
2026-01-16 11:19:06 +00:00
retryAfter ,
2026-01-15 18:54:49 +00:00
} ) ;
}
2025-12-27 00:53:49 +00:00
}
2026-01-07 10:15:48 +00:00
2026-02-10 12:52:23 +00:00
// 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 ( '<!doctype' ) ) ;
const sanitizedErrorData = isHtml
? '[HTML response omitted - wrong server on port 8080?]'
: sanitizeForLogging ( error . response . data ) ;
2025-12-25 10:18:27 +00:00
const sanitizedHeaders = sanitizeForLogging ( error . response . headers ) ;
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
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 ,
} ,
) ;
2026-02-10 12:52:23 +00:00
} else if ( shouldLogError && error . request && ! error . response ) {
2025-12-25 10:18:27 +00:00
// Network error (no response received)
2026-01-13 18:47:57 +00:00
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 ,
} ,
) ;
2025-12-25 10:18:27 +00:00
}
2025-12-03 21:56:50 +00:00
2026-01-29 22:18:08 +00:00
// P1.3: Handle 403 CSRF token invalid - refresh token and retry once
// This handles cases where the CSRF token has expired or is invalid
const isCSRFRoute = originalRequest ? . url ? . includes ( '/csrf-token' ) ;
const isAuthRoute =
originalRequest ? . url ? . includes ( '/auth/login' ) ||
originalRequest ? . url ? . includes ( '/auth/register' ) ;
if (
error . response ? . status === 403 &&
originalRequest &&
! ( originalRequest as any ) . _csrfRetry &&
! isCSRFRoute &&
! isAuthRoute
) {
// Check if this is likely a CSRF error
const errorData = error . response ? . data as any ;
const errorMessage = ( typeof errorData ? . error === 'string' ? errorData.error : errorData?.message || '' ) . toLowerCase ( ) ;
const isCSRFError =
errorMessage . includes ( 'csrf' ) ||
errorMessage . includes ( 'token' ) ||
errorMessage . includes ( 'forbidden' ) ;
if ( isCSRFError ) {
( originalRequest as any ) . _csrfRetry = true ;
logger . info ( '[API] CSRF token invalid (403), refreshing and retrying' , {
request_id : requestId ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ) ;
try {
// Refresh CSRF token
const newToken = await csrfService . ensureToken ( ) ;
// Update request headers with new token
if ( originalRequest . headers ) {
originalRequest . headers [ 'X-CSRF-Token' ] = newToken ;
}
logger . debug ( '[API] CSRF token refreshed, retrying request' , {
request_id : requestId ,
url : originalRequest?.url ,
} ) ;
// Retry the request with new CSRF token
return apiClient . request ( originalRequest ) ;
} catch ( csrfError ) {
2026-02-10 18:29:10 +00:00
const errMsg = csrfError instanceof Error ? csrfError.message : String ( csrfError ) ;
if ( ! errMsg . includes ( 'HTML page instead of JSON' ) ) {
logger . error ( '[API] CSRF token refresh failed' , {
request_id : requestId ,
url : originalRequest?.url ,
message : errMsg ,
} ) ;
}
2026-01-29 22:18:08 +00:00
// Fall through to reject with original error
}
}
}
2025-12-26 08:13:36 +00:00
// INT-AUTH-003: Détecter 401 et refresh automatiquement
2026-02-07 19:36:48 +00:00
// EXCLURE /auth/refresh et /auth/logout pour éviter les boucles.
// EXCLURE /auth/me : 401 = non connecté ; ne pas tenter de refresh ni rediriger (sinon boucle rechargement).
2025-12-22 14:53:47 +00:00
const isRefreshEndpoint = originalRequest ? . url ? . includes ( '/auth/refresh' ) ;
2026-01-18 11:30:56 +00:00
const isLogoutEndpoint = originalRequest ? . url ? . includes ( '/auth/logout' ) ;
2026-02-07 19:36:48 +00:00
const isAuthMeEndpoint = originalRequest ? . url ? . includes ( '/auth/me' ) ;
2025-12-22 21:56:37 +00:00
2026-01-18 11:30:56 +00:00
// INT-AUTH-003: Handle 401 and 400 on /auth/refresh endpoint - token expired/revoked/invalid, logout and redirect
// FIX: Gérer aussi les erreurs 400 (Bad Request) qui indiquent un refresh token invalide
if (
( error . response ? . status === 401 || error . response ? . status === 400 ) &&
isRefreshEndpoint
) {
2026-01-13 18:47:57 +00:00
logger . error (
2026-01-18 11:30:56 +00:00
` [API] ${ error . response ? . status } on /auth/refresh - refresh token expired/revoked/invalid, logging out ` ,
2026-01-13 18:47:57 +00:00
{
request_id : requestId ,
url : originalRequest?.url ,
2026-01-18 11:30:56 +00:00
status : error.response?.status ,
2026-01-13 18:47:57 +00:00
} ,
) ;
2025-12-26 08:13:36 +00:00
// 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
2026-01-13 18:47:57 +00:00
import ( '@/features/auth/store/authStore' )
. then ( ( { useAuthStore } ) = > {
const store = useAuthStore . getState ( ) ;
2026-01-18 11:30:56 +00:00
// Utiliser logoutLocal() au lieu de logout() pour éviter les appels API
// qui déclencheraient à nouveau le refresh
store . logoutLocal ( ) ;
2026-01-13 18:47:57 +00:00
} )
. catch ( ( err : unknown ) = > {
logger . error ( '[API] Failed to import auth store for logout' , {
error : err ,
} ) ;
2025-12-26 08:13:36 +00:00
} ) ;
// 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 ) ) ;
}
2026-01-18 11:30:56 +00:00
// 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 ) ) ;
}
2025-12-13 02:34:34 +00:00
if (
error . response ? . status === 401 &&
originalRequest &&
2025-12-22 14:53:47 +00:00
! originalRequest . _retry &&
2026-01-18 11:30:56 +00:00
! isRefreshEndpoint &&
2026-02-07 19:36:48 +00:00
! isLogoutEndpoint &&
! isAuthMeEndpoint
2025-12-13 02:34:34 +00:00
) {
2025-12-26 08:13:36 +00:00
// INT-AUTH-003: Éviter les refresh multiples simultanés
2025-12-03 21:56:50 +00:00
if ( isRefreshing ) {
// Si un refresh est en cours, mettre la requête en queue
2025-12-26 08:13:36 +00:00
logger . debug ( '[API] Refresh already in progress, queuing request' , {
request_id : requestId ,
url : originalRequest?.url ,
queue_size : failedQueue.length ,
} ) ;
2025-12-03 21:56:50 +00:00
return new Promise ( ( resolve , reject ) = > {
failedQueue . push ( { resolve , reject } ) ;
} )
2026-01-16 00:02:03 +00:00
. then ( ( ) = > {
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
// Backend reads access token from httpOnly cookie automatically
2026-01-13 18:47:57 +00:00
logger . debug (
'[API] Replaying queued request after successful refresh' ,
{
request_id : requestId ,
url : originalRequest?.url ,
} ,
) ;
2025-12-03 21:56:50 +00:00
return apiClient ( originalRequest ) ;
} )
. catch ( ( err ) = > {
2026-02-10 18:51:20 +00:00
const errAny = err as { response ? : { status? : number } ; code? : number } ;
const errStatus = errAny ? . response ? . status ? ? errAny ? . code ;
2026-02-10 18:38:13 +00:00
const errUrl = originalRequest ? . url ? ? '' ;
2026-02-10 18:51:20 +00:00
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 ,
} ) ;
}
2025-12-03 21:56:50 +00:00
return Promise . reject ( err ) ;
} ) ;
}
originalRequest . _retry = true ;
isRefreshing = true ;
2025-12-26 08:13:36 +00:00
logger . info ( '[API] Starting token refresh due to 401' , {
request_id : requestId ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ) ;
2026-01-29 22:16:37 +00:00
// 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 ++ ;
2025-12-03 21:56:50 +00:00
try {
2025-12-26 08:13:36 +00:00
// INT-AUTH-003: Refresh automatique du token
2026-01-16 00:02:03 +00:00
// SECURITY: Action 5.1.1.3 - Refresh uses cookies, no need to set Authorization header
2025-12-03 21:56:50 +00:00
await refreshToken ( ) ;
2025-12-22 14:53:47 +00:00
2026-01-13 18:47:57 +00:00
logger . info (
'[API] Token refresh successful, retrying original request' ,
{
request_id : requestId ,
url : originalRequest?.url ,
queue_size : failedQueue.length ,
2026-01-29 22:16:37 +00:00
attempt : refreshAttempts ,
2026-01-13 18:47:57 +00:00
} ,
) ;
2025-12-26 08:13:36 +00:00
2026-01-29 22:16:37 +00:00
// P1.4: Reset counter on successful refresh
refreshAttempts = 0 ;
2026-01-16 00:02:03 +00:00
// 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
2025-12-03 21:56:50 +00:00
2025-12-26 08:13:36 +00:00
// INT-AUTH-003: Traiter la queue et retry la requête originale
2026-01-16 00:02:03 +00:00
// Toutes les requêtes en queue seront rejouées (cookies sont automatiquement envoyés)
processQueue ( null ) ;
2025-12-03 21:56:50 +00:00
return apiClient ( originalRequest ) ;
} catch ( refreshError ) {
2025-12-26 08:13:36 +00:00
// INT-AUTH-003: Gérer cas refresh échoué (expiration, révocation, erreur réseau)
2026-01-29 22:16:37 +00:00
logger . error ( '[API] Token refresh failed' , {
attempt : refreshAttempts ,
max_attempts : MAX_REFRESH_ATTEMPTS ,
2025-12-26 08:13:36 +00:00
request_id : requestId ,
error : refreshError ,
queue_size : failedQueue.length ,
} ) ;
// Rejeter toutes les requêtes en queue
2026-01-16 00:02:03 +00:00
processQueue ( refreshError as Error ) ;
2025-12-13 02:34:34 +00:00
2025-12-16 19:40:16 +00:00
// Nettoyer les tokens
2025-12-03 21:56:50 +00:00
TokenStorage . clearTokens ( ) ;
2025-12-13 02:34:34 +00:00
2025-12-26 08:13:36 +00:00
// Clear CSRF token
csrfService . clearToken ( ) ;
// INT-AUTH-003: Clear auth store state and redirect to login
2026-01-18 11:30:56 +00:00
// FIX: Utiliser logoutLocal() pour éviter les boucles infinies
// (logout -> 401 -> refresh -> 400 -> logout -> ...)
2025-12-03 21:56:50 +00:00
if ( typeof window !== 'undefined' ) {
2025-12-26 08:13:36 +00:00
// Import and use auth store to clear state
2026-01-13 18:47:57 +00:00
import ( '@/features/auth/store/authStore' )
. then ( ( { useAuthStore } ) = > {
const store = useAuthStore . getState ( ) ;
2026-01-18 11:30:56 +00:00
// Utiliser logoutLocal() au lieu de logout() pour éviter les appels API
// qui déclencheraient à nouveau le refresh
store . logoutLocal ( ) ;
2026-01-13 18:47:57 +00:00
} )
. catch ( ( err : unknown ) = > {
logger . error ( '[API] Failed to import auth store for logout' , {
error : err ,
} ) ;
2025-12-26 08:13:36 +00:00
} ) ;
// Stocker un message d'erreur pour l'afficher après redirection
2025-12-22 14:53:47 +00:00
sessionStorage . setItem (
'auth_error' ,
2025-12-26 08:13:36 +00:00
'Votre session a expiré. Veuillez vous reconnecter.' ,
2025-12-22 14:53:47 +00:00
) ;
// Rediriger vers login si refresh échoue (seulement dans le navigateur)
2025-12-03 21:56:50 +00:00
window . location . href = '/login' ;
}
2025-12-13 02:34:34 +00:00
2025-12-03 21:56:50 +00:00
return Promise . reject ( refreshError ) ;
} finally {
isRefreshing = false ;
2025-12-26 08:13:36 +00:00
logger . debug ( '[API] Token refresh process completed' , {
request_id : requestId ,
is_refreshing : false ,
} ) ;
2025-12-03 21:56:50 +00:00
}
}
2025-12-25 21:28:46 +00:00
// INT-AUTH-001: Détecter erreurs CSRF (403 avec message CSRF) et retry avec nouveau token
2026-01-07 10:15:48 +00:00
const isCSRFError =
2025-12-25 21:28:46 +00:00
error . response ? . status === 403 &&
originalRequest &&
! ( originalRequest as any ) ? . _csrfRetry &&
error . response ? . data &&
typeof error . response . data === 'object' &&
2026-01-13 18:47:57 +00:00
( ( error . response . data as any ) ? . error ? . message
? . toLowerCase ( )
. includes ( 'csrf' ) ||
( error . response . data as any ) ? . message ? . toLowerCase ( ) . includes ( 'csrf' ) ) ;
2025-12-25 21:28:46 +00:00
if ( isCSRFError ) {
const method = originalRequest . method ? . toUpperCase ( ) ;
2026-01-13 18:47:57 +00:00
const isStateChanging = [ 'POST' , 'PUT' , 'DELETE' , 'PATCH' ] . includes (
method || '' ,
) ;
2026-01-07 10:15:48 +00:00
2025-12-25 21:28:46 +00:00
if ( isStateChanging ) {
( originalRequest as any ) . _csrfRetry = true ;
2026-01-07 10:15:48 +00:00
2025-12-25 21:28:46 +00:00
try {
// Récupérer un nouveau token CSRF
const newCsrfToken = await csrfService . refreshToken ( ) ;
2026-01-07 10:15:48 +00:00
2025-12-25 21:28:46 +00:00
if ( originalRequest . headers && newCsrfToken ) {
originalRequest . headers [ 'X-CSRF-Token' ] = newCsrfToken ;
}
2026-01-07 10:15:48 +00:00
2025-12-25 21:28:46 +00:00
// Retry la requête avec le nouveau token
return apiClient ( originalRequest ) ;
} catch ( csrfError ) {
2026-02-10 18:29:10 +00:00
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 ,
} ) ;
}
2025-12-25 21:28:46 +00:00
// Si on ne peut pas récupérer le token, rejeter l'erreur originale
const apiError = parseApiError ( error ) ;
return Promise . reject ( apiError ) ;
}
}
}
2025-12-26 08:10:19 +00:00
// INT-API-005: Unified retry logic for all retryable errors
2025-12-22 14:53:47 +00:00
const status = error . response ? . status ;
2025-12-25 10:11:54 +00:00
const retryCount = ( originalRequest as any ) ? . _retryCount || 0 ;
const maxRetries = DEFAULT_RETRY_CONFIG . maxRetries ;
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
// INT-API-005: For 429 rate limit errors, don't retry - respect the rate limit
2025-12-26 08:10:19 +00:00
const isRateLimitError = status === 429 ;
2025-12-26 09:49:50 +00:00
// 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
2026-01-13 18:47:57 +00:00
const retryAfter =
error . response ? . headers [ 'retry-after' ] ||
error . response ? . headers [ 'Retry-After' ] ;
2025-12-26 09:49:50 +00:00
const retryAfterSeconds = retryAfter ? parseInt ( retryAfter , 10 ) : 60 ;
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
logger . warn ( '[API] Rate limit exceeded, not retrying' , {
url : originalRequest?.url ,
retry_after : retryAfterSeconds ,
request_id : apiError.request_id ,
} ) ;
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
// Show user-friendly error message
if ( apiError . message ) {
toast . error ( apiError . message , {
duration : retryAfterSeconds * 1000 , // Show for the retry-after duration
} ) ;
}
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
return Promise . reject ( apiError ) ;
}
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
const effectiveMaxRetries = maxRetries ; // Use default max retries for other errors
2025-12-25 10:11:54 +00:00
2026-01-18 11:30:56 +00:00
// 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 ;
2026-01-29 22:16:37 +00:00
2026-01-18 11:30:56 +00:00
// 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 ) ;
}
2025-12-25 10:11:54 +00:00
// Check if error is retryable
2026-01-13 18:47:57 +00:00
if (
isRetryableError ( error , DEFAULT_RETRY_CONFIG ) &&
originalRequest &&
retryCount < effectiveMaxRetries
) {
2025-12-25 10:11:54 +00:00
// For non-idempotent methods (POST, PUT, DELETE, PATCH), only retry on specific errors
const method = originalRequest . method ? . toUpperCase ( ) ;
const isIdempotent = isIdempotentMethod ( method ) ;
2026-01-07 10:15:48 +00:00
2025-12-26 09:49:50 +00:00
// For non-idempotent methods, only retry on network errors or 5xx errors
// (429 rate limit errors are handled above and don't retry)
2026-01-13 18:47:57 +00:00
if (
! isIdempotent &&
status &&
status !== 500 &&
status !== 502 &&
status !== 503 &&
status !== 504
) {
2025-12-26 08:10:19 +00:00
// Don't retry non-idempotent methods on client errors (except 429 and 5xx)
2025-12-25 10:11:54 +00:00
const apiError = parseApiError ( error ) ;
return Promise . reject ( apiError ) ;
}
2025-12-22 14:53:47 +00:00
2025-12-25 10:11:54 +00:00
// Mark that we're retrying this request
( originalRequest as any ) . _retryCount = retryCount + 1 ;
2025-12-22 14:53:47 +00:00
2025-12-26 09:49:50 +00:00
// Calculate delay (exponential backoff with jitter)
2026-01-13 18:47:57 +00:00
const delay = getRetryDelay (
error ,
retryCount ,
DEFAULT_RETRY_CONFIG . baseDelay ,
DEFAULT_RETRY_CONFIG . maxDelay ,
) ;
2025-12-25 10:11:54 +00:00
2026-02-10 18:29:10 +00:00
// Log only first retry to reduce console noise when backend is down
2025-12-25 10:11:54 +00:00
const apiError = parseApiError ( error ) ;
2026-01-13 18:47:57 +00:00
const errorType = status
? ` HTTP ${ status } `
: error . code || 'Network Error' ;
2026-01-07 10:15:48 +00:00
2026-02-10 18:29:10 +00:00
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 ,
} ,
) ;
}
2025-12-22 22:13:49 +00:00
}
2025-12-25 10:11:54 +00:00
// Wait before retrying
return sleep ( delay ) . then ( ( ) = > {
// Retry the request
2025-12-22 14:53:47 +00:00
return apiClient ( originalRequest ) ;
2025-12-25 10:11:54 +00:00
} ) ;
2025-12-22 14:53:47 +00:00
}
2025-12-26 08:10:19 +00:00
// 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 ) {
2025-12-22 14:53:47 +00:00
const apiError = parseApiError ( error ) ;
2026-01-13 18:47:57 +00:00
const errorType = status
? ` HTTP ${ status } `
: error . code || 'Network Error' ;
2026-01-07 10:15:48 +00:00
2025-12-22 22:13:49 +00:00
// Log final error with request_id after all retries failed
if ( apiError . request_id ) {
2026-01-07 10:15:48 +00:00
logger . error (
2025-12-25 10:11:54 +00:00
` [API Error] ${ errorType } error after ${ maxRetries } retries - Request ID: ${ apiError . request_id } ` ,
2025-12-22 22:13:49 +00:00
{
code : apiError.code ,
message : apiError.message ,
request_id : apiError.request_id ,
timestamp : apiError.timestamp ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ,
) ;
2025-12-25 10:11:54 +00:00
} else {
2026-01-07 10:15:48 +00:00
logger . error (
2025-12-25 10:11:54 +00:00
` [API Error] ${ errorType } error after ${ maxRetries } retries ` ,
{
code : apiError.code ,
message : apiError.message ,
timestamp : apiError.timestamp ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ,
) ;
2025-12-22 22:13:49 +00:00
}
2026-01-07 10:15:48 +00:00
2025-12-22 14:53:47 +00:00
return Promise . reject ( apiError ) ;
}
// Parser l'erreur en ApiError standardisé pour les autres codes
2025-12-16 19:40:16 +00:00
const apiError = parseApiError ( error ) ;
2025-12-22 22:13:49 +00:00
2026-01-11 16:29:55 +00:00
// Action 3.2.1.4: Auth errors redirect to login
2026-02-07 19:36:48 +00:00
// 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'
) {
2026-01-11 16:29:55 +00:00
const errorCategory = getErrorCategory ( apiError ) ;
if ( errorCategory === 'authentication' ) {
TokenStorage . clearTokens ( ) ;
csrfService . clearToken ( ) ;
2026-01-13 18:47:57 +00:00
import ( '@/features/auth/store/authStore' )
. then ( ( { useAuthStore } ) = > {
const store = useAuthStore . getState ( ) ;
2026-01-18 11:30:56 +00:00
store . logoutLocal ( ) ;
2026-01-13 18:47:57 +00:00
} )
. catch ( ( err : unknown ) = > {
logger . error ( '[API] Failed to import auth store for logout' , {
error : err ,
} ) ;
2026-01-11 16:29:55 +00:00
} ) ;
sessionStorage . setItem (
'auth_error' ,
'Votre session a expiré. Veuillez vous reconnecter.' ,
) ;
window . location . href = '/login' ;
}
}
2025-12-25 10:32:53 +00:00
// FE-COMP-005: Show toast notification for API errors (unless disabled)
2026-02-10 12:52:23 +00:00
// 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 ;
2026-02-10 20:11:32 +00:00
const urlForToast = originalRequest ? . url ? ? '' ;
const isWebhooks5xxForToast = status && status >= 500 && urlForToast . includes ( '/webhooks' ) ;
2026-01-13 18:47:57 +00:00
const shouldShowToast =
! ( originalRequest as any ) ? . _disableToast &&
2026-01-11 16:29:55 +00:00
status !== 401 && // Don't show toast for 401 (handled by refresh/redirect)
2026-01-07 10:15:48 +00:00
status !== 404 && // Don't show toast for 404 (handled by router)
2026-02-10 12:52:23 +00:00
! axios . isCancel ( error ) && // Don't show toast for cancelled requests
2026-02-10 20:11:32 +00:00
! isWrongServerError &&
! isWebhooks5xxForToast ; // 5xx on webhooks: table may be missing, show empty state instead
2025-12-25 10:32:53 +00:00
feat: Visual masterpiece - true light mode & premium UI
🎨 **True Light/Dark Mode**
- Implemented proper light mode with inverted color scheme
- Smooth theme transitions (0.3s ease)
- Light mode colors: white backgrounds, dark text, vibrant accents
- System theme detection with proper class application
🌈 **Enhanced Theme System**
- 4 color themes work in both light and dark modes
- Cyber (cyan/magenta), Ocean (blue/teal), Forest (green/lime), Sunset (orange/purple)
- Theme-specific glassmorphism effects
- Proper contrast in light mode
✨ **Premium Animations**
- Float, glow-pulse, slide-in, scale-in, rotate-in animations
- Smooth page transitions
- Hover effects with depth (lift, glow, scale)
- Micro-interactions on all interactive elements
🎯 **Visual Polish**
- Enhanced glassmorphism for light/dark modes
- Custom scrollbar with theme colors
- Beautiful text selection
- Focus indicators for accessibility
- Premium utility classes
🔧 **Technical Improvements**
- Updated UIStore to properly apply light/dark classes
- Added data-theme attribute for CSS targeting
- Smooth scroll behavior
- Optimized transitions
The app is now a visual masterpiece with perfect light/dark mode support!
2026-01-11 01:32:21 +00:00
// FIX: Implement toast throttling for network errors to prevent spam
const isNetworkError = ! error . response ;
2026-01-11 16:16:49 +00:00
// Track network errors for offline indicator
if ( isNetworkError ) {
2026-01-13 18:47:57 +00:00
const { recordNetworkError } = await import (
'@/utils/networkErrorTracker'
) ;
2026-01-11 16:16:49 +00:00
recordNetworkError ( apiError ) ;
}
feat: Visual masterpiece - true light mode & premium UI
🎨 **True Light/Dark Mode**
- Implemented proper light mode with inverted color scheme
- Smooth theme transitions (0.3s ease)
- Light mode colors: white backgrounds, dark text, vibrant accents
- System theme detection with proper class application
🌈 **Enhanced Theme System**
- 4 color themes work in both light and dark modes
- Cyber (cyan/magenta), Ocean (blue/teal), Forest (green/lime), Sunset (orange/purple)
- Theme-specific glassmorphism effects
- Proper contrast in light mode
✨ **Premium Animations**
- Float, glow-pulse, slide-in, scale-in, rotate-in animations
- Smooth page transitions
- Hover effects with depth (lift, glow, scale)
- Micro-interactions on all interactive elements
🎯 **Visual Polish**
- Enhanced glassmorphism for light/dark modes
- Custom scrollbar with theme colors
- Beautiful text selection
- Focus indicators for accessibility
- Premium utility classes
🔧 **Technical Improvements**
- Updated UIStore to properly apply light/dark classes
- Added data-theme attribute for CSS targeting
- Smooth scroll behavior
- Optimized transitions
The app is now a visual masterpiece with perfect light/dark mode support!
2026-01-11 01:32:21 +00:00
const toastId = isNetworkError ? 'network-error-toast' : undefined ;
2025-12-25 10:32:53 +00:00
if ( shouldShowToast && typeof window !== 'undefined' ) {
2026-01-07 09:33:52 +00:00
const url = originalRequest ? . url || '' ;
2026-01-13 18:47:57 +00:00
let context :
| 'auth'
| 'track'
| 'playlist'
| 'upload'
| 'conversation'
| 'search'
| undefined ;
2026-01-07 09:33:52 +00:00
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 ;
2026-01-13 18:47:57 +00:00
const errorMessage = formatUserFriendlyError (
apiError ,
context ,
includeDetails ,
) ;
2026-01-07 10:15:48 +00:00
2026-01-07 09:33:52 +00:00
// FE-API-015: Queue request for offline replay if it's a network error
2026-01-13 18:47:57 +00:00
if (
! error . response &&
originalRequest &&
offlineQueue . shouldQueueRequest ( originalRequest )
) {
2026-01-07 09:33:52 +00:00
const isOffline = typeof navigator !== 'undefined' && ! navigator . onLine ;
if ( isOffline || ( ! error . response && error . request ) ) {
// Determine priority based on request type
const method = originalRequest . method ? . toUpperCase ( ) ;
2026-01-13 18:47:57 +00:00
const priority =
method === 'DELETE' ? 'low' : method === 'POST' ? 'high' : 'normal' ;
2026-01-07 10:15:48 +00:00
2026-01-07 09:33:52 +00:00
try {
await offlineQueue . queueRequest ( originalRequest , { priority } ) ;
// Show info toast that request was queued
2026-01-13 18:47:57 +00:00
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
} ,
) ;
2026-01-07 09:33:52 +00:00
} catch ( queueError ) {
2026-01-13 18:47:57 +00:00
logger . error ( '[API] Failed to queue request for offline replay' , {
error : queueError ,
} ) ;
2025-12-25 12:24:19 +00:00
}
}
2025-12-25 10:32:53 +00:00
}
2026-01-16 12:08:14 +00:00
// Edge 2.1: Distinguish partial vs complete network failures
const isPartialFailure = isPartialNetworkFailure ( error ) ;
const isCompleteFailure = isCompleteNetworkFailure ( error ) ;
feat: Visual masterpiece - true light mode & premium UI
🎨 **True Light/Dark Mode**
- Implemented proper light mode with inverted color scheme
- Smooth theme transitions (0.3s ease)
- Light mode colors: white backgrounds, dark text, vibrant accents
- System theme detection with proper class application
🌈 **Enhanced Theme System**
- 4 color themes work in both light and dark modes
- Cyber (cyan/magenta), Ocean (blue/teal), Forest (green/lime), Sunset (orange/purple)
- Theme-specific glassmorphism effects
- Proper contrast in light mode
✨ **Premium Animations**
- Float, glow-pulse, slide-in, scale-in, rotate-in animations
- Smooth page transitions
- Hover effects with depth (lift, glow, scale)
- Micro-interactions on all interactive elements
🎯 **Visual Polish**
- Enhanced glassmorphism for light/dark modes
- Custom scrollbar with theme colors
- Beautiful text selection
- Focus indicators for accessibility
- Premium utility classes
🔧 **Technical Improvements**
- Updated UIStore to properly apply light/dark classes
- Added data-theme attribute for CSS targeting
- Smooth scroll behavior
- Optimized transitions
The app is now a visual masterpiece with perfect light/dark mode support!
2026-01-11 01:32:21 +00:00
// Use a fixed ID for network errors to prevent stacking
2026-01-11 15:30:43 +00:00
// For network errors, show a more helpful message with suggestions
let enhancedMessage = errorMessage ;
if ( isNetworkError ) {
2026-01-16 12:08:14 +00:00
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 ,
} ) ;
2026-01-11 15:30:43 +00:00
}
toast . error ( enhancedMessage , {
duration : 8000 , // Longer duration for network errors to read suggestions
feat: Visual masterpiece - true light mode & premium UI
🎨 **True Light/Dark Mode**
- Implemented proper light mode with inverted color scheme
- Smooth theme transitions (0.3s ease)
- Light mode colors: white backgrounds, dark text, vibrant accents
- System theme detection with proper class application
🌈 **Enhanced Theme System**
- 4 color themes work in both light and dark modes
- Cyber (cyan/magenta), Ocean (blue/teal), Forest (green/lime), Sunset (orange/purple)
- Theme-specific glassmorphism effects
- Proper contrast in light mode
✨ **Premium Animations**
- Float, glow-pulse, slide-in, scale-in, rotate-in animations
- Smooth page transitions
- Hover effects with depth (lift, glow, scale)
- Micro-interactions on all interactive elements
🎯 **Visual Polish**
- Enhanced glassmorphism for light/dark modes
- Custom scrollbar with theme colors
- Beautiful text selection
- Focus indicators for accessibility
- Premium utility classes
🔧 **Technical Improvements**
- Updated UIStore to properly apply light/dark classes
- Added data-theme attribute for CSS targeting
- Smooth scroll behavior
- Optimized transitions
The app is now a visual masterpiece with perfect light/dark mode support!
2026-01-11 01:32:21 +00:00
id : toastId , // Use fixed ID if it's a network error
2025-12-25 10:32:53 +00:00
} ) ;
}
2025-12-27 00:50:39 +00:00
// FIX #18, #22: Utiliser logger structuré avec request_id pour corrélation
2026-02-10 18:51:20 +00:00
// 5xx sur /webhooks = erreur backend, ne pas logger (éviter bruit console)
2026-02-10 18:38:54 +00:00
const httpStatus = error . response ? . status ;
2026-02-10 18:38:13 +00:00
const url = originalRequest ? . url ? ? '' ;
2026-02-10 18:38:54 +00:00
const isWebhooks5xx = httpStatus && httpStatus >= 500 && url . includes ( '/webhooks' ) ;
2026-02-10 18:51:20 +00:00
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 ,
} ) ;
}
2025-12-22 22:13:49 +00:00
2025-12-16 19:40:16 +00:00
return Promise . reject ( apiError ) ;
2025-12-13 02:34:34 +00:00
} ,
2025-12-03 21:56:50 +00:00
) ;
2025-12-25 10:14:03 +00:00
2025-12-25 10:32:53 +00:00
/ * *
* 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 '' ;
}
}
2025-12-25 10:14:03 +00:00
/ * *
* Helper function to create a cancellable request
* Returns an object with the request promise and an abort function
2026-01-13 18:47:57 +00:00
*
2025-12-25 10:14:03 +00:00
* @example
* ` ` ` typescript
2026-01-13 18:47:57 +00:00
* const { request , abort } = createCancellableRequest ( ( signal ) = >
2025-12-25 10:14:03 +00:00
* apiClient . get ( '/api/v1/tracks' , { signal } )
* ) ;
2026-01-13 18:47:57 +00:00
*
2025-12-25 10:14:03 +00:00
* // Later, to cancel:
* abort ( ) ;
* ` ` `
* /
2026-01-16 11:49:40 +00:00
/ * *
* 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 ( ) ;
* ` ` `
* /
2025-12-25 10:14:03 +00:00
export function createCancellableRequest < T > (
requestFn : ( signal : AbortSignal ) = > Promise < T > ,
) : { request : Promise < T > ; abort : ( ) = > void } {
const abortController = new AbortController ( ) ;
const signal = abortController . signal ;
2026-01-16 11:49:40 +00:00
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 ;
} ) ;
2025-12-25 10:14:03 +00:00
return {
request ,
abort : ( ) = > {
2026-01-16 11:49:40 +00:00
// Edge 2.2: Abort the request if not already aborted
if ( ! signal . aborted ) {
abortController . abort ( ) ;
}
2025-12-25 10:14:03 +00:00
} ,
} ;
}
/ * *
2026-01-16 11:49:40 +00:00
* 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 .
2026-01-13 18:47:57 +00:00
*
2025-12-25 10:14:03 +00:00
* @example
* ` ` ` typescript
* const { request , abort } = createRequestWithTimeout (
* ( signal ) = > apiClient . get ( '/api/v1/tracks' , { signal } ) ,
* 5000 // 5 seconds timeout
* ) ;
* ` ` `
* /
export function createRequestWithTimeout < T > (
requestFn : ( signal : AbortSignal ) = > Promise < T > ,
timeoutMs : number ,
) : { request : Promise < T > ; abort : ( ) = > void } {
const abortController = new AbortController ( ) ;
const signal = abortController . signal ;
2026-01-16 11:49:40 +00:00
// Edge 2.2: Set up timeout that automatically cancels the request
2025-12-25 10:14:03 +00:00
const timeoutId = setTimeout ( ( ) = > {
2026-01-16 11:49:40 +00:00
if ( ! signal . aborted ) {
abortController . abort ( ) ;
}
2025-12-25 10:14:03 +00:00
} , timeoutMs ) ;
2026-01-16 11:49:40 +00:00
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 ) ;
} ) ;
2025-12-25 10:14:03 +00:00
return {
request ,
abort : ( ) = > {
clearTimeout ( timeoutId ) ;
2026-01-16 11:49:40 +00:00
// Edge 2.2: Abort the request if not already aborted
if ( ! signal . aborted ) {
abortController . abort ( ) ;
}
2025-12-25 10:14:03 +00:00
} ,
} ;
}
2025-12-25 12:26:27 +00:00
/ * *
* FE - API - 016 : Enhanced API client methods with automatic deduplication
2025-12-25 12:29:43 +00:00
* FE - API - 017 : Enhanced with response caching for GET requests
* These methods automatically deduplicate identical concurrent requests and cache GET responses
2026-01-13 18:47:57 +00:00
*
2025-12-25 12:26:27 +00:00
* @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)
2026-01-13 18:47:57 +00:00
*
2025-12-25 12:29:43 +00:00
* // Cached responses are returned immediately
* const response1 = await deduplicatedApiClient . get ( '/tracks' ) ;
* const response2 = await deduplicatedApiClient . get ( '/tracks' ) ; // Returns from cache
2025-12-25 12:26:27 +00:00
* ` ` `
* /
export const deduplicatedApiClient = {
get : < T = any > ( url : string , config? : InternalAxiosRequestConfig ) = > {
2025-12-25 12:29:43 +00:00
// FE-API-017: Check cache first
if ( ! ( config as any ) ? . _disableCache ) {
2026-01-13 18:47:57 +00:00
const cachedResponse = responseCache . get ( {
. . . config ,
method : 'GET' ,
url ,
} ) ;
2025-12-25 12:29:43 +00:00
if ( cachedResponse ) {
logger . debug ( ` [API] Using cached response for: ${ url } ` ) ;
return Promise . resolve ( cachedResponse as AxiosResponse < T > ) ;
}
}
2025-12-25 12:26:27 +00:00
return requestDeduplication . getOrCreateRequest (
{ . . . config , method : 'GET' , url } ,
( ) = > apiClient . get < T > ( url , config ) ,
) ;
} ,
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
post : < T = any > (
url : string ,
data? : any ,
config? : InternalAxiosRequestConfig ,
) = > {
2025-12-25 12:26:27 +00:00
return requestDeduplication . getOrCreateRequest (
{ . . . config , method : 'POST' , url , data } ,
( ) = > apiClient . post < T > ( url , data , config ) ,
) ;
} ,
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
put : < T = any > (
url : string ,
data? : any ,
config? : InternalAxiosRequestConfig ,
) = > {
2025-12-25 12:26:27 +00:00
return requestDeduplication . getOrCreateRequest (
{ . . . config , method : 'PUT' , url , data } ,
( ) = > apiClient . put < T > ( url , data , config ) ,
) ;
} ,
2026-01-07 10:15:48 +00:00
2026-01-13 18:47:57 +00:00
patch : < T = any > (
url : string ,
data? : any ,
config? : InternalAxiosRequestConfig ,
) = > {
2025-12-25 12:26:27 +00:00
return requestDeduplication . getOrCreateRequest (
{ . . . config , method : 'PATCH' , url , data } ,
( ) = > apiClient . patch < T > ( url , data , config ) ,
) ;
} ,
2026-01-07 10:15:48 +00:00
2025-12-25 12:26:27 +00:00
delete : < T = any > ( url : string , config? : InternalAxiosRequestConfig ) = > {
return requestDeduplication . getOrCreateRequest (
{ . . . config , method : 'DELETE' , url } ,
( ) = > apiClient . delete < T > ( url , config ) ,
) ;
} ,
} ;
2026-01-16 11:51:14 +00:00
/ * *
* 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 ;
}