433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
|
import { TokenStorage } from '../tokenStorage';
|
|
import { refreshToken } from '../tokenRefresh';
|
|
import { env } from '@/config/env';
|
|
import { parseApiError } from '@/utils/apiErrorHandler';
|
|
import { csrfService } from '../csrf';
|
|
import type { ApiResponse } from '@/types/api';
|
|
|
|
/**
|
|
* Client API avec interceptors pour refresh automatique des tokens,
|
|
* unwrapping du format backend { success, data, error },
|
|
* et retry automatique avec exponential backoff
|
|
* Aligné avec FRONTEND_INTEGRATION.md
|
|
*/
|
|
|
|
// Client API réutilisable
|
|
export const apiClient = axios.create({
|
|
baseURL: env.API_URL,
|
|
timeout: 10000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Flag pour éviter les refresh en boucle
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (value?: any) => void;
|
|
reject: (error?: any) => void;
|
|
}> = [];
|
|
|
|
/**
|
|
* Sleep utility function
|
|
*/
|
|
const sleep = (ms: number): Promise<void> => {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
};
|
|
|
|
|
|
/**
|
|
* Retry configuration
|
|
*/
|
|
interface RetryConfig {
|
|
maxRetries: number;
|
|
baseDelay: number;
|
|
maxDelay: number;
|
|
retryableStatusCodes: number[];
|
|
retryableNetworkErrors: string[];
|
|
}
|
|
|
|
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
maxRetries: 3,
|
|
baseDelay: 1000, // 1 second
|
|
maxDelay: 10000, // 10 seconds
|
|
retryableStatusCodes: [429, 500, 502, 503, 504], // Rate limit, server errors, gateway errors
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* Check if an error is retryable
|
|
*/
|
|
const isRetryableError = (error: AxiosError, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean => {
|
|
// Don't retry if request was cancelled
|
|
if (axios.isCancel(error)) {
|
|
return false;
|
|
}
|
|
|
|
// Check if retry is disabled for this request
|
|
if ((error.config as any)?._disableRetry) {
|
|
return false;
|
|
}
|
|
|
|
// Check status code
|
|
if (error.response?.status) {
|
|
return config.retryableStatusCodes.includes(error.response.status);
|
|
}
|
|
|
|
// Check network errors
|
|
if (error.code) {
|
|
return config.retryableNetworkErrors.includes(error.code);
|
|
}
|
|
|
|
// Check error message for network-related errors
|
|
if (error.message) {
|
|
const message = error.message.toLowerCase();
|
|
const networkErrorPatterns = ['network', 'timeout', 'connection', 'econn', 'etimedout', 'enotfound'];
|
|
return networkErrorPatterns.some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
// For errors without response (network errors), retry if it's an idempotent method
|
|
if (!error.response && error.request) {
|
|
return isIdempotentMethod(error.config?.method);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Get retry delay from Retry-After header or use exponential backoff with jitter
|
|
*/
|
|
const getRetryDelay = (
|
|
error: AxiosError,
|
|
attempt: number,
|
|
baseDelay: number = DEFAULT_RETRY_CONFIG.baseDelay,
|
|
maxDelay: number = DEFAULT_RETRY_CONFIG.maxDelay,
|
|
): number => {
|
|
// Check for Retry-After header (case-insensitive)
|
|
const retryAfterHeader =
|
|
error.response?.headers['retry-after'] ||
|
|
error.response?.headers['Retry-After'];
|
|
if (retryAfterHeader) {
|
|
const delay = parseInt(String(retryAfterHeader), 10);
|
|
if (!isNaN(delay) && delay > 0) {
|
|
return Math.min(delay * 1000, maxDelay); // Convert to milliseconds, cap at maxDelay
|
|
}
|
|
}
|
|
|
|
// Exponential backoff with jitter: baseDelay * 2^attempt + random(0, baseDelay)
|
|
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
const jitter = Math.random() * baseDelay; // Add jitter to avoid thundering herd
|
|
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
};
|
|
|
|
// T0177: Fonction pour traiter la queue de requêtes en attente
|
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
|
failedQueue.forEach((prom) => {
|
|
if (error) {
|
|
prom.reject(error);
|
|
} else {
|
|
prom.resolve(token);
|
|
}
|
|
});
|
|
|
|
failedQueue = [];
|
|
};
|
|
|
|
// T0177: Interceptor de requête pour ajouter le token d'accès
|
|
// CRITIQUE: Récupérer TOUJOURS le token frais depuis localStorage car Zustand peut ne pas être hydraté
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
const token = TokenStorage.getAccessToken();
|
|
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token} `;
|
|
}
|
|
|
|
// 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'];
|
|
}
|
|
|
|
// Ajouter le token CSRF pour les méthodes qui modifient l'état
|
|
const method = config.method?.toUpperCase();
|
|
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || '');
|
|
const isCSRFRoute = config.url?.includes('/csrf-token');
|
|
|
|
if (isStateChanging && !isCSRFRoute && config.headers) {
|
|
const csrfToken = csrfService.getToken();
|
|
if (csrfToken) {
|
|
config.headers['X-CSRF-Token'] = csrfToken;
|
|
}
|
|
}
|
|
|
|
return config;
|
|
},
|
|
(error) => {
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
// Interceptor de réponse pour unwrap le format backend et gérer les erreurs
|
|
apiClient.interceptors.response.use(
|
|
(response: AxiosResponse<ApiResponse<any> | any>) => {
|
|
// Backend peut retourner plusieurs formats :
|
|
// 1. Format standard avec wrapper: { success: true, data: {...} }
|
|
// 2. Format direct JSON: { tracks: [...], pagination: {...} } (ex: SearchTracks, ListTracks)
|
|
// 3. Format avec message: { success: true, data: {...}, message: "..." }
|
|
|
|
if (!response.data || typeof response.data !== 'object') {
|
|
// Si response.data n'est pas un objet, retourner tel quel
|
|
return response;
|
|
}
|
|
|
|
// Vérifier si c'est le format wrapper avec success
|
|
if ('success' in response.data) {
|
|
if (response.data.success === true) {
|
|
// Format wrapper standard: { success: true, data: {...} }
|
|
// On unwrap pour retourner directement data
|
|
// Si data est null/undefined, on retourne null au lieu de undefined
|
|
const unwrappedData = response.data.data !== undefined ? response.data.data : null;
|
|
|
|
return {
|
|
...response,
|
|
data: unwrappedData,
|
|
} as AxiosResponse<any>;
|
|
}
|
|
// Si success === false, l'erreur sera gérée par le catch
|
|
// Mais on devrait normalement ne jamais arriver ici car le backend
|
|
// retourne un status HTTP d'erreur dans ce cas
|
|
}
|
|
|
|
// Si pas de format wrapper (format direct JSON), retourner la réponse telle quelle
|
|
// Exemples: { tracks: [...], pagination: {...} }, { user: {...}, token: {...} }
|
|
return response;
|
|
},
|
|
async (error: AxiosError<ApiResponse<any>>) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
|
_retry?: boolean;
|
|
};
|
|
|
|
// Détecter 401 et refresh automatiquement
|
|
// EXCLURE l'endpoint /auth/refresh pour éviter les boucles infinies
|
|
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
|
|
|
|
if (
|
|
error.response?.status === 401 &&
|
|
originalRequest &&
|
|
!originalRequest._retry &&
|
|
!isRefreshEndpoint
|
|
) {
|
|
// Éviter les refresh multiples simultanés
|
|
if (isRefreshing) {
|
|
// Si un refresh est en cours, mettre la requête en queue
|
|
return new Promise((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
})
|
|
.then((token) => {
|
|
if (originalRequest.headers && token) {
|
|
originalRequest.headers.Authorization = `Bearer ${token} `;
|
|
}
|
|
return apiClient(originalRequest);
|
|
})
|
|
.catch((err) => {
|
|
return Promise.reject(err);
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
// Refresh automatique du token
|
|
await refreshToken();
|
|
const newToken = TokenStorage.getAccessToken();
|
|
|
|
if (!newToken) {
|
|
throw new Error('Failed to get new access token after refresh');
|
|
}
|
|
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${newToken} `;
|
|
}
|
|
|
|
// Traiter la queue et retry la requête originale
|
|
processQueue(null, newToken);
|
|
return apiClient(originalRequest);
|
|
} catch (refreshError) {
|
|
// Gérer cas refresh échoué
|
|
processQueue(refreshError as Error, null);
|
|
|
|
// Nettoyer les tokens
|
|
TokenStorage.clearTokens();
|
|
|
|
// Stocker un message d'erreur pour l'afficher après redirection
|
|
if (typeof window !== 'undefined') {
|
|
sessionStorage.setItem(
|
|
'auth_error',
|
|
'Your session has expired. Please log in again.',
|
|
);
|
|
// Rediriger vers login si refresh échoue (seulement dans le navigateur)
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
// Unified retry logic for all retryable errors
|
|
const status = error.response?.status;
|
|
const retryCount = (originalRequest as any)?._retryCount || 0;
|
|
const maxRetries = DEFAULT_RETRY_CONFIG.maxRetries;
|
|
|
|
// Check if error is retryable
|
|
if (isRetryableError(error, DEFAULT_RETRY_CONFIG) && originalRequest && retryCount < maxRetries) {
|
|
// For non-idempotent methods (POST, PUT, DELETE, PATCH), only retry on specific errors
|
|
const method = originalRequest.method?.toUpperCase();
|
|
const isIdempotent = isIdempotentMethod(method);
|
|
|
|
// For non-idempotent methods, only retry on network errors or 5xx errors (not 429)
|
|
if (!isIdempotent && status && status !== 500 && status !== 502 && status !== 503 && status !== 504) {
|
|
// Don't retry non-idempotent methods on client errors (except 5xx)
|
|
const apiError = parseApiError(error);
|
|
return Promise.reject(apiError);
|
|
}
|
|
|
|
// Mark that we're retrying this request
|
|
(originalRequest as any)._retryCount = retryCount + 1;
|
|
|
|
// Calculate delay (respect Retry-After header if present, otherwise exponential backoff with jitter)
|
|
const delay = getRetryDelay(error, retryCount, DEFAULT_RETRY_CONFIG.baseDelay, DEFAULT_RETRY_CONFIG.maxDelay);
|
|
|
|
// Log retry attempt with request_id if available
|
|
const apiError = parseApiError(error);
|
|
const errorType = status ? `HTTP ${status}` : error.code || 'Network Error';
|
|
|
|
if (apiError.request_id) {
|
|
console.warn(
|
|
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries}) - Request ID: ${apiError.request_id}`,
|
|
{
|
|
status: status || 'N/A',
|
|
error_code: error.code || 'N/A',
|
|
retry_count: retryCount + 1,
|
|
max_retries: maxRetries,
|
|
delay_ms: Math.round(delay),
|
|
request_id: apiError.request_id,
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
is_idempotent: isIdempotent,
|
|
},
|
|
);
|
|
} else {
|
|
console.warn(
|
|
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries})`,
|
|
{
|
|
status: status || 'N/A',
|
|
error_code: error.code || 'N/A',
|
|
retry_count: retryCount + 1,
|
|
max_retries: maxRetries,
|
|
delay_ms: Math.round(delay),
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
is_idempotent: isIdempotent,
|
|
},
|
|
);
|
|
}
|
|
|
|
// Wait before retrying
|
|
return sleep(delay).then(() => {
|
|
// Retry the request
|
|
return apiClient(originalRequest);
|
|
});
|
|
}
|
|
|
|
// If already retried maxRetries times or error is not retryable, reject immediately
|
|
if (retryCount >= maxRetries) {
|
|
const apiError = parseApiError(error);
|
|
const errorType = status ? `HTTP ${status}` : error.code || 'Network Error';
|
|
|
|
// Log final error with request_id after all retries failed
|
|
if (apiError.request_id) {
|
|
console.error(
|
|
`[API Error] ${errorType} error after ${maxRetries} retries - Request ID: ${apiError.request_id}`,
|
|
{
|
|
code: apiError.code,
|
|
message: apiError.message,
|
|
request_id: apiError.request_id,
|
|
timestamp: apiError.timestamp,
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
},
|
|
);
|
|
} else {
|
|
console.error(
|
|
`[API Error] ${errorType} error after ${maxRetries} retries`,
|
|
{
|
|
code: apiError.code,
|
|
message: apiError.message,
|
|
timestamp: apiError.timestamp,
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
},
|
|
);
|
|
}
|
|
|
|
return Promise.reject(apiError);
|
|
}
|
|
|
|
// Parser l'erreur en ApiError standardisé pour les autres codes
|
|
const apiError = parseApiError(error);
|
|
|
|
// Log error with request_id for correlation with backend logs
|
|
if (apiError.request_id) {
|
|
console.error(
|
|
`[API Error] ${apiError.message} (Code: ${apiError.code}, Request ID: ${apiError.request_id})`,
|
|
{
|
|
code: apiError.code,
|
|
message: apiError.message,
|
|
request_id: apiError.request_id,
|
|
timestamp: apiError.timestamp,
|
|
details: apiError.details,
|
|
context: apiError.context,
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
},
|
|
);
|
|
} else {
|
|
// Log without request_id if not available
|
|
console.error(
|
|
`[API Error] ${apiError.message} (Code: ${apiError.code})`,
|
|
{
|
|
code: apiError.code,
|
|
message: apiError.message,
|
|
timestamp: apiError.timestamp,
|
|
details: apiError.details,
|
|
url: originalRequest?.url,
|
|
method: originalRequest?.method,
|
|
},
|
|
);
|
|
}
|
|
|
|
return Promise.reject(apiError);
|
|
},
|
|
);
|