2026-02-23 21:05:37 +00:00
/ * *
* Error response interceptor : CSRF retry , rate limit , retry logic , toast , offline queue
* /
import axios , {
type AxiosError ,
type AxiosInstance ,
type InternalAxiosRequestConfig ,
} from 'axios' ;
import toast from '@/utils/toast' ;
import { env } from '@/config/env' ;
import { parseApiError } from '@/utils/apiErrorHandler' ;
import { formatUserFriendlyError } from '@/utils/errorMessages' ;
import { csrfService } from '../../csrf' ;
import { logger , setLogContext } from '@/utils/logger' ;
import { offlineQueue } from '../../offlineQueue' ;
import type { ApiResponse } from '@/types/api' ;
import { useRateLimitStore } from '@/stores/rateLimit' ;
import {
sleep ,
DEFAULT_RETRY_CONFIG ,
isIdempotentMethod ,
isPartialNetworkFailure ,
isCompleteNetworkFailure ,
isRetryableError ,
getRetryDelay ,
} from '../retry' ;
import { sanitizeForLogging } from './utils' ;
import { handleAuthError , handleAuthRedirectOn401 } from './auth' ;
export function createErrorResponseHandler ( apiClient : AxiosInstance ) {
return async ( error : AxiosError < ApiResponse < unknown > > ) = > {
// Cancelled requests
if ( axios . isCancel ( error ) ) {
if (
( import . meta . env . DEV && env . DEBUG ) ||
( error . config as InternalAxiosRequestConfig & { _enableLogging? : boolean } ) ? . _enableLogging
) {
logger . debug (
` [API Request Cancelled] ${ error . config ? . method ? . toUpperCase ( ) || 'GET' } ${ error . config ? . url } ` ,
{
request_id : ( error . config as InternalAxiosRequestConfig & { _requestId? : string } ) ? . _requestId ,
} ,
) ;
}
return Promise . reject ( error ) ;
}
const originalRequest = error . config as InternalAxiosRequestConfig & {
_retry? : boolean ;
_retryCount? : number ;
_csrfRetry? : boolean ;
_disableToast? : boolean ;
_requestId? : string ;
_requestStartTime? : number ;
} ;
// Request ID from error response
let requestId = originalRequest ? . _requestId ;
if ( error . response ? . headers ) {
const requestIdFromHeader =
error . response . headers [ 'x-request-id' ] ||
error . response . headers [ 'X-Request-ID' ] ;
if ( requestIdFromHeader ) {
requestId = requestIdFromHeader ;
setLogContext ( { request_id : requestId } ) ;
}
// Rate limit headers from error
const rateLimitLimit =
error . response . headers [ 'x-ratelimit-limit' ] ||
error . response . headers [ 'X-RateLimit-Limit' ] ;
const rateLimitRemaining =
error . response . headers [ 'x-ratelimit-remaining' ] ||
error . response . headers [ 'X-RateLimit-Remaining' ] ;
const rateLimitReset =
error . response . headers [ 'x-ratelimit-reset' ] ||
error . response . headers [ 'X-RateLimit-Reset' ] ;
const retryAfter =
error . response . headers [ 'retry-after' ] ||
error . response . headers [ 'Retry-After' ] ;
if (
rateLimitLimit ||
rateLimitRemaining ||
rateLimitReset ||
retryAfter
) {
useRateLimitStore . getState ( ) . updateRateLimit ( {
limit : rateLimitLimit ,
remaining : rateLimitRemaining ,
reset : rateLimitReset ,
retryAfter ,
} ) ;
}
}
// Debug error logging
const shouldLogError =
( import . meta . env . DEV && env . DEBUG ) || originalRequest ? . _enableLogging ;
if ( shouldLogError && error . response ) {
const isHtml =
(
error . response . headers ? . [ 'content-type' ] as string
) ? . toLowerCase ? . ( ) . includes ( 'text/html' ) ||
( typeof error . response . data === 'string' &&
( error . response . data as string )
. trim ( )
. toLowerCase ( )
. startsWith ( '<!doctype' ) ) ;
const sanitizedErrorData = isHtml
? '[HTML response omitted - wrong server on port 8080?]'
: sanitizeForLogging ( error . response . data ) ;
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 : sanitizeForLogging ( error . response . headers ) ,
data : sanitizedErrorData ,
duration : originalRequest?._requestStartTime
? Date . now ( ) - originalRequest . _requestStartTime
: undefined ,
} ,
) ;
} else if ( shouldLogError && error . request && ! error . response ) {
logger . error (
` [API Network Error] ${ originalRequest ? . method ? . toUpperCase ( ) || 'GET' } ${ originalRequest ? . url } ` ,
{
request_id : requestId ,
message : error.message ,
code : error.code ,
duration : originalRequest?._requestStartTime
? Date . now ( ) - originalRequest . _requestStartTime
: undefined ,
} ,
) ;
}
// --- CSRF retry on 403 ---
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 . _csrfRetry &&
! isCSRFRoute &&
! isAuthRoute
) {
const errorData = error . response ? . data as { error? : string ; message? : string } ;
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 . _csrfRetry = true ;
logger . info (
'[API] CSRF token invalid (403), refreshing and retrying' ,
{ request_id : requestId , url : originalRequest?.url , method : originalRequest?.method } ,
) ;
try {
const newToken = await csrfService . ensureToken ( ) ;
if ( originalRequest . headers ) {
originalRequest . headers [ 'X-CSRF-Token' ] = newToken ;
}
return apiClient . request ( originalRequest ) ;
} catch ( csrfError ) {
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 ,
} ) ;
}
}
}
}
// --- Auth 401 handling ---
const authResult = handleAuthError ( error , originalRequest , apiClient , requestId ) ;
if ( authResult !== null ) return authResult ;
feat: frontend improvements — UI polish, player bar, auth flow, i18n
- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:35:44 +00:00
// (Duplicate CSRF handler removed — already handled above)
2026-02-23 21:05:37 +00:00
// --- Rate limit (429) ---
const status = error . response ? . status ;
const retryCount = originalRequest ? . _retryCount || 0 ;
const maxRetries = DEFAULT_RETRY_CONFIG . maxRetries ;
if ( status === 429 ) {
const apiError = parseApiError ( error ) ;
const retryAfter =
error . response ? . headers [ 'retry-after' ] ||
error . response ? . headers [ 'Retry-After' ] ;
const retryAfterSeconds = retryAfter ? parseInt ( retryAfter , 10 ) : 60 ;
logger . warn ( '[API] Rate limit exceeded, not retrying' , {
url : originalRequest?.url ,
retry_after : retryAfterSeconds ,
request_id : apiError.request_id ,
} ) ;
if ( apiError . message ) {
toast . error ( apiError . message , {
feat: frontend improvements — UI polish, player bar, auth flow, i18n
- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:35:44 +00:00
duration : 6000 ,
id : 'rate-limit-toast' ,
2026-02-23 21:05:37 +00:00
} ) ;
}
return Promise . reject ( apiError ) ;
}
// --- Marketplace 500 special case ---
const isMarketplaceProducts = originalRequest ? . url ? . includes (
'/marketplace/products' ,
) ;
if ( status === 500 && isMarketplaceProducts ) {
const apiError = parseApiError ( error ) ;
( apiError as { httpStatus? : number } ) . httpStatus = status ;
return Promise . reject ( apiError ) ;
}
// --- Retryable errors ---
if (
isRetryableError ( error , DEFAULT_RETRY_CONFIG ) &&
originalRequest &&
retryCount < maxRetries
) {
const method = originalRequest . method ? . toUpperCase ( ) ;
const isIdempotent = isIdempotentMethod ( method ) ;
if (
! isIdempotent &&
status &&
status !== 500 &&
status !== 502 &&
status !== 503 &&
status !== 504
) {
return Promise . reject ( parseApiError ( error ) ) ;
}
originalRequest . _retryCount = retryCount + 1 ;
const delay = getRetryDelay (
error ,
retryCount ,
DEFAULT_RETRY_CONFIG . baseDelay ,
DEFAULT_RETRY_CONFIG . maxDelay ,
) ;
if ( retryCount === 0 ) {
const apiError = parseApiError ( error ) ;
const errorType = status
? ` HTTP ${ status } `
: error . code || 'Network Error' ;
logger . warn (
` [API Retry] ${ errorType } error, retrying (1/ ${ maxRetries } ) ` ,
{
status : status || 'N/A' ,
error_code : error.code || 'N/A' ,
retry_count : 1 ,
max_retries : maxRetries ,
delay_ms : Math.round ( delay ) ,
request_id : apiError.request_id ,
url : originalRequest?.url ,
method : originalRequest?.method ,
is_idempotent : isIdempotent ,
} ,
) ;
}
return sleep ( delay ) . then ( ( ) = > apiClient ( originalRequest ) ) ;
}
// Max retries exceeded
if ( retryCount >= maxRetries ) {
const apiError = parseApiError ( error ) ;
const errorType = status
? ` HTTP ${ status } `
: error . code || 'Network Error' ;
logger . error (
` [API Error] ${ errorType } error after ${ maxRetries } retries ` ,
{
code : apiError.code ,
message : apiError.message ,
request_id : apiError.request_id ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ,
) ;
return Promise . reject ( apiError ) ;
}
// Parse final error
const apiError = parseApiError ( error ) ;
// Auth redirect on 401 (fallback)
feat: frontend improvements — UI polish, player bar, auth flow, i18n
- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:35:44 +00:00
// Skip login/register: their 401 should be shown as form errors, not cause a redirect
2026-02-23 21:05:37 +00:00
const isRefreshEndpoint = originalRequest ? . url ? . includes ( '/auth/refresh' ) ;
const isLogoutEndpoint = originalRequest ? . url ? . includes ( '/auth/logout' ) ;
const isAuthMeEndpoint = originalRequest ? . url ? . includes ( '/auth/me' ) ;
feat: frontend improvements — UI polish, player bar, auth flow, i18n
- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:35:44 +00:00
const isLoginEndpoint = originalRequest ? . url ? . includes ( '/auth/login' ) ;
const isRegisterEndpoint = originalRequest ? . url ? . includes ( '/auth/register' ) ;
if ( ! isLoginEndpoint && ! isRegisterEndpoint ) {
handleAuthRedirectOn401 (
apiError ,
status ,
isRefreshEndpoint ,
isLogoutEndpoint ,
isAuthMeEndpoint ,
) ;
}
2026-02-23 21:05:37 +00:00
// --- Error toast ---
const isWrongServerError =
apiError . message ? . includes ( 'HTML page instead of JSON' ) ? ? false ;
const urlForToast = originalRequest ? . url ? ? '' ;
const isWebhooks5xxForToast =
status && status >= 500 && urlForToast . includes ( '/webhooks' ) ;
const shouldShowToast =
! originalRequest ? . _disableToast &&
status !== 401 &&
status !== 404 &&
! axios . isCancel ( error ) &&
! isWrongServerError &&
! isWebhooks5xxForToast ;
const isNetworkError = ! error . response ;
if ( isNetworkError ) {
const { recordNetworkError } = await import (
'@/utils/networkErrorTracker'
) ;
recordNetworkError ( apiError ) ;
}
feat: frontend improvements — UI polish, player bar, auth flow, i18n
- Header, Sidebar, Toast, Dropdown, EmptyState component refinements
- Auth flow: LoginPage, RegisterPage, AuthInput, AuthLayout improvements
- Player bar: glass effect, progress, track info, controls enhancements
- Dashboard, Discover, Search pages updates
- PlaylistCard, TrackCard component improvements
- Auth store and API interceptors hardening
- i18n: updated en/es/fr locale files
- CSS additions in index.css
- Package.json and vite config updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:35:44 +00:00
const toastId = isNetworkError
? 'network-error-toast'
: status && status >= 500
? 'server-error-toast'
: status === 403
? 'forbidden-error-toast'
: undefined ;
2026-02-23 21:05:37 +00:00
if ( shouldShowToast && typeof window !== 'undefined' ) {
const url = originalRequest ? . url || '' ;
let context :
| 'auth'
| 'track'
| 'playlist'
| 'upload'
| 'conversation'
| 'search'
| undefined ;
if ( url . includes ( '/auth/' ) ) context = 'auth' ;
else if ( url . includes ( '/tracks' ) || url . includes ( '/track/' ) )
context = 'track' ;
else if ( url . includes ( '/playlists' ) || url . includes ( '/playlist/' ) )
context = 'playlist' ;
else if ( url . includes ( '/upload' ) ) context = 'upload' ;
else if ( url . includes ( '/conversations' ) || url . includes ( '/chat' ) )
context = 'conversation' ;
else if ( url . includes ( '/search' ) ) context = 'search' ;
const includeDetails = status === 422 ;
const errorMessage = formatUserFriendlyError (
apiError ,
context ,
includeDetails ,
) ;
// Offline queue
if (
! error . response &&
originalRequest &&
offlineQueue . shouldQueueRequest ( originalRequest )
) {
const isOffline =
typeof navigator !== 'undefined' && ! navigator . onLine ;
if ( isOffline || ( ! error . response && error . request ) ) {
const method = originalRequest . method ? . toUpperCase ( ) ;
const priority =
method === 'DELETE'
? 'low'
: method === 'POST'
? 'high'
: 'normal' ;
try {
await offlineQueue . queueRequest ( originalRequest , { priority } ) ;
toast . success (
"Requête mise en file d'attente. Elle sera envoyée à la reconnexion." ,
{ duration : 4000 , id : 'offline-queue-toast' } ,
) ;
} catch ( queueError ) {
logger . error (
'[API] Failed to queue request for offline replay' ,
{ error : queueError } ,
) ;
}
}
}
// Enhanced network error messages
let enhancedMessage = errorMessage ;
if ( isNetworkError ) {
if ( isPartialNetworkFailure ( error ) ) {
enhancedMessage = ` ${ errorMessage } ⚠️ Connexion intermittente détectée. Certaines requêtes réussissent, d'autres échouent. La connexion devrait se rétablir automatiquement. ` ;
} else if ( isCompleteNetworkFailure ( error ) ) {
enhancedMessage = ` ${ errorMessage } ❌ Aucune connexion réseau. Vérifiez votre connexion internet et réessayez. ` ;
} else {
enhancedMessage = ` ${ errorMessage } 💡 Vérifiez votre connexion internet. Si le problème persiste, le serveur pourrait être temporairement indisponible. ` ;
}
}
if (
( isPartialNetworkFailure ( error ) ||
isCompleteNetworkFailure ( error ) ) &&
isNetworkError
) {
logger . warn ( '[API] Network failure detected' , {
request_id : requestId ,
is_partial_failure : isPartialNetworkFailure ( error ) ,
is_complete_failure : isCompleteNetworkFailure ( error ) ,
url : originalRequest?.url ,
method : originalRequest?.method ,
error_code : error.code ,
error_message : error.message ,
} ) ;
}
toast . error ( enhancedMessage , {
duration : 8000 ,
id : toastId ,
} ) ;
}
// Final error log
const httpStatus = error . response ? . status ;
const url = originalRequest ? . url ? ? '' ;
const isWebhooks5xx =
httpStatus && httpStatus >= 500 && url . includes ( '/webhooks' ) ;
if ( ! isWebhooks5xx ) {
logger . error ( ` [API Error] ${ apiError . message } ` , {
request_id : apiError.request_id || requestId ,
code : apiError.code ,
message : apiError.message ,
timestamp : apiError.timestamp ,
details : apiError.details ,
context : apiError.context ,
url : originalRequest?.url ,
method : originalRequest?.method ,
} ) ;
}
return Promise . reject ( apiError ) ;
} ;
}