2025-12-16 19:40:16 +00:00
|
|
|
import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
2025-12-25 10:32:53 +00:00
|
|
|
import toast from 'react-hot-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';
|
2025-12-26 08:15:00 +00:00
|
|
|
import { refreshToken, isTokenExpiringSoon } from '../tokenRefresh';
|
2025-12-16 19:40:16 +00:00
|
|
|
import { env } from '@/config/env';
|
|
|
|
|
import { parseApiError } from '@/utils/apiErrorHandler';
|
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';
|
2025-12-25 12:22:15 +00:00
|
|
|
import { isTimeoutError, 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';
|
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;
|
|
|
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Flag pour éviter les refresh en boucle
|
|
|
|
|
let isRefreshing = false;
|
|
|
|
|
let failedQueue: Array<{
|
|
|
|
|
resolve: (value?: any) => void;
|
|
|
|
|
reject: (error?: any) => void;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
2025-12-26 09:50:30 +00:00
|
|
|
// Cache pour éviter les refresh proactifs multiples
|
|
|
|
|
let lastProactiveRefreshTime = 0;
|
|
|
|
|
const PROACTIVE_REFRESH_COOLDOWN_MS = 5000; // 5 secondes entre refresh proactifs
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sensitiveKeys = ['password', 'token', 'access_token', 'refresh_token', 'secret', 'authorization', 'x-csrf-token'];
|
|
|
|
|
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)
|
|
|
|
|
const requestId = (config.headers as any)?.['X-Request-ID'] ||
|
|
|
|
|
(config.headers as any)?.['x-request-id'] ||
|
|
|
|
|
`req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
|
|
|
|
|
|
// Store in config for later use
|
|
|
|
|
(config as any)._requestId = requestId;
|
|
|
|
|
|
|
|
|
|
return requestId;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
// T0177: Fonction pour traiter la queue de requêtes en attente
|
|
|
|
|
const processQueue = (error: Error | null, token: string | null = 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 {
|
|
|
|
|
prom.resolve(token);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
// INT-AUTH-004: Vérifier l'expiration du token avant d'envoyer la requête
|
|
|
|
|
// Buffer de 60 secondes pour éviter les 401 inutiles
|
|
|
|
|
const PRE_REQUEST_REFRESH_BUFFER_MS = 60 * 1000; // 60 secondes
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
const token = TokenStorage.getAccessToken();
|
2025-12-26 08:15:00 +00:00
|
|
|
const isRefreshEndpoint = config.url?.includes('/auth/refresh');
|
|
|
|
|
const isCSRFRoute = config.url?.includes('/csrf-token');
|
|
|
|
|
|
|
|
|
|
// Ne pas vérifier l'expiration pour les endpoints de refresh et CSRF pour éviter les boucles
|
|
|
|
|
if (token && !isRefreshEndpoint && !isCSRFRoute) {
|
|
|
|
|
// Vérifier si le token expire bientôt (dans moins de 60s)
|
|
|
|
|
if (isTokenExpiringSoon(token, PRE_REQUEST_REFRESH_BUFFER_MS)) {
|
|
|
|
|
// Si un refresh est déjà en cours, attendre qu'il se termine
|
|
|
|
|
if (isRefreshing) {
|
|
|
|
|
logger.debug('[API] Token expiring soon but refresh already in progress, waiting...', {
|
|
|
|
|
url: config.url,
|
|
|
|
|
});
|
|
|
|
|
// Attendre que le refresh se termine (max 5s)
|
|
|
|
|
let waitCount = 0;
|
|
|
|
|
while (isRefreshing && waitCount < 50) {
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
|
waitCount++;
|
|
|
|
|
}
|
|
|
|
|
// Récupérer le nouveau token après le refresh
|
|
|
|
|
const newToken = TokenStorage.getAccessToken();
|
|
|
|
|
if (newToken && config.headers) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${newToken} `;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2025-12-26 09:50:30 +00:00
|
|
|
// Vérifier le cooldown pour éviter les refresh proactifs multiples
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const timeSinceLastRefresh = now - lastProactiveRefreshTime;
|
|
|
|
|
|
|
|
|
|
if (timeSinceLastRefresh < PROACTIVE_REFRESH_COOLDOWN_MS) {
|
|
|
|
|
// Trop tôt depuis le dernier refresh, utiliser le token actuel
|
|
|
|
|
logger.debug('[API] Skipping proactive refresh (cooldown)', {
|
2025-12-26 08:15:00 +00:00
|
|
|
url: config.url,
|
2025-12-26 09:50:30 +00:00
|
|
|
time_since_last_refresh_ms: timeSinceLastRefresh,
|
|
|
|
|
cooldown_ms: PROACTIVE_REFRESH_COOLDOWN_MS,
|
2025-12-26 08:15:00 +00:00
|
|
|
});
|
|
|
|
|
if (token && config.headers) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${token} `;
|
|
|
|
|
}
|
2025-12-26 09:50:30 +00:00
|
|
|
} else {
|
|
|
|
|
// Rafraîchir proactivement le token
|
|
|
|
|
try {
|
|
|
|
|
lastProactiveRefreshTime = now;
|
|
|
|
|
logger.debug('[API] Token expiring soon, refreshing proactively before request', {
|
|
|
|
|
url: config.url,
|
|
|
|
|
buffer_seconds: PRE_REQUEST_REFRESH_BUFFER_MS / 1000,
|
|
|
|
|
});
|
|
|
|
|
await refreshToken();
|
|
|
|
|
const newToken = TokenStorage.getAccessToken();
|
|
|
|
|
if (newToken && config.headers) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${newToken} `;
|
|
|
|
|
}
|
|
|
|
|
} catch (refreshError) {
|
|
|
|
|
// Si le refresh échoue, continuer avec le token actuel
|
|
|
|
|
// L'interceptor de réponse gérera l'erreur 401 si nécessaire
|
|
|
|
|
logger.warn('[API] Proactive token refresh failed, continuing with current token', {
|
|
|
|
|
url: config.url,
|
|
|
|
|
error: refreshError,
|
|
|
|
|
});
|
|
|
|
|
if (token && config.headers) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${token} `;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 08:15:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Token valide, utiliser normalement
|
|
|
|
|
if (config.headers) {
|
|
|
|
|
config.headers.Authorization = `Bearer ${token} `;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (token && config.headers) {
|
|
|
|
|
// Token présent mais endpoint de refresh/CSRF, utiliser sans vérification
|
2025-12-22 21:56:37 +00:00
|
|
|
config.headers.Authorization = `Bearer ${token} `;
|
2025-12-03 21:56:50 +00:00
|
|
|
}
|
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
|
|
|
|
2025-12-25 21:28:46 +00:00
|
|
|
// INT-AUTH-001: Ajouter le token CSRF pour les méthodes qui modifient l'état
|
2025-12-22 21:56:37 +00:00
|
|
|
const method = config.method?.toUpperCase();
|
|
|
|
|
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || '');
|
2025-12-26 08:15:00 +00:00
|
|
|
// isCSRFRoute déjà défini plus haut
|
2025-12-22 14:53:47 +00:00
|
|
|
|
2025-12-22 21:56:37 +00:00
|
|
|
if (isStateChanging && !isCSRFRoute && config.headers) {
|
|
|
|
|
const csrfToken = csrfService.getToken();
|
|
|
|
|
if (csrfToken) {
|
|
|
|
|
config.headers['X-CSRF-Token'] = csrfToken;
|
|
|
|
|
}
|
2025-12-25 21:28:46 +00:00
|
|
|
// Si pas de token, l'interceptor de réponse gérera le retry avec nouveau token
|
2025-12-22 21:56:37 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-25 10:14:03 +00:00
|
|
|
// Support AbortController: si un signal est fourni dans la config, l'utiliser
|
|
|
|
|
// Sinon, créer un nouveau AbortController si nécessaire
|
|
|
|
|
if (!config.signal && !config.cancelToken) {
|
|
|
|
|
// Si aucune annulation n'est configurée, on peut créer un AbortController optionnel
|
|
|
|
|
// Mais on ne le fait pas automatiquement pour éviter de créer des signaux inutiles
|
|
|
|
|
// Les utilisateurs peuvent passer un signal via config.signal
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 13:36:32 +00:00
|
|
|
// FE-TYPE-003: Validate request data if schema is provided
|
|
|
|
|
const requestSchema = (config as any)?._requestSchema as z.ZodSchema | undefined;
|
|
|
|
|
if (requestSchema && config.data !== undefined && config.data !== null) {
|
|
|
|
|
// Skip validation for FormData (file uploads)
|
|
|
|
|
if (!(config.data instanceof FormData)) {
|
|
|
|
|
const validation = safeValidateApiRequest(requestSchema, config.data);
|
|
|
|
|
if (!validation.success) {
|
|
|
|
|
logger.warn('[API] Request validation failed:', {
|
|
|
|
|
url: config.url,
|
|
|
|
|
errors: validation.error?.errors,
|
|
|
|
|
});
|
2025-12-27 00:50:39 +00:00
|
|
|
// FIX #18: Utiliser logger structuré au lieu de console.warn
|
|
|
|
|
logger.warn('[API Request Validation Error]', {
|
|
|
|
|
request_id: getRequestId(config),
|
|
|
|
|
url: config.url,
|
|
|
|
|
errors: validation.error?.errors,
|
|
|
|
|
}, validation.error);
|
2025-12-25 13:36:32 +00:00
|
|
|
// Throw error to prevent invalid request from being sent
|
|
|
|
|
throw new Error(`Request validation failed: ${validation.error?.errors.map(e => e.message).join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
// Use validated data
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
// Log request (only in development or if explicitly enabled)
|
|
|
|
|
if (import.meta.env.DEV || (config as any)?._enableLogging) {
|
|
|
|
|
const requestId = getRequestId(config);
|
|
|
|
|
const sanitizedHeaders = sanitizeForLogging({ ...config.headers });
|
|
|
|
|
const sanitizedData = sanitizeForLogging(config.data);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
config: error.config ? {
|
|
|
|
|
url: error.config.url,
|
|
|
|
|
method: error.config.method,
|
|
|
|
|
} : undefined,
|
|
|
|
|
});
|
|
|
|
|
}
|
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
|
|
|
);
|
|
|
|
|
|
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>) => {
|
2025-12-27 00:50:39 +00:00
|
|
|
// FIX #22: Extraire le request_id depuis les headers de réponse pour corrélation
|
|
|
|
|
const requestIdFromHeader = response.headers['x-request-id'] || response.headers['X-Request-ID'];
|
|
|
|
|
const requestId = requestIdFromHeader || (response.config as any)?._requestId;
|
|
|
|
|
|
|
|
|
|
// Mettre à jour le contexte global du logger avec le request_id
|
|
|
|
|
if (requestId) {
|
|
|
|
|
setLogContext({ request_id: requestId });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 10:18:27 +00:00
|
|
|
// Log successful response (only in development or if explicitly enabled)
|
|
|
|
|
const shouldLog = import.meta.env.DEV || (response.config as any)?._enableLogging;
|
|
|
|
|
|
|
|
|
|
if (shouldLog) {
|
|
|
|
|
const sanitizedData = sanitizeForLogging(response.data);
|
|
|
|
|
const sanitizedHeaders = sanitizeForLogging(response.headers);
|
|
|
|
|
|
|
|
|
|
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: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: "..." }
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
const isMutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '');
|
|
|
|
|
const shouldShowSuccessToast = isMutation &&
|
|
|
|
|
(response.config as any)?._showSuccessToast &&
|
|
|
|
|
typeof window !== 'undefined';
|
|
|
|
|
|
|
|
|
|
if (shouldShowSuccessToast) {
|
|
|
|
|
const successMessage = (response.config as any)?._successMessage ||
|
|
|
|
|
(response.data as any)?.message ||
|
|
|
|
|
getDefaultSuccessMessage(method || '');
|
|
|
|
|
|
|
|
|
|
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';
|
2025-12-25 12:29:43 +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
|
|
|
|
|
const unwrappedData = response.data.data !== undefined ? response.data.data : null;
|
|
|
|
|
|
2025-12-25 13:30:55 +00:00
|
|
|
// FE-TYPE-002: Validate response data if schema is provided
|
|
|
|
|
const responseSchema = (response.config as any)?._responseSchema as z.ZodSchema | undefined;
|
|
|
|
|
if (responseSchema && unwrappedData !== null) {
|
|
|
|
|
const validation = safeValidateApiResponse(responseSchema, unwrappedData);
|
|
|
|
|
if (!validation.success) {
|
|
|
|
|
logger.warn('[API] Response validation failed:', {
|
|
|
|
|
url: response.config.url,
|
|
|
|
|
errors: validation.error?.errors,
|
|
|
|
|
});
|
2025-12-27 00:50:39 +00:00
|
|
|
// FIX #18: Utiliser logger structuré au lieu de console.warn
|
|
|
|
|
logger.warn('[API Validation Error]', {
|
|
|
|
|
request_id: getRequestId(config),
|
|
|
|
|
url: config.url,
|
|
|
|
|
}, validation.error);
|
2025-12-25 13:30:55 +00:00
|
|
|
// Continue with unvalidated data (don't break the app)
|
|
|
|
|
// In production, you might want to throw or handle differently
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>;
|
|
|
|
|
}
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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>>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
}
|
2025-12-25 10:09:19 +00:00
|
|
|
|
|
|
|
|
// Si pas de format wrapper (format direct JSON), retourner la réponse telle quelle
|
|
|
|
|
// Exemples: { tracks: [...], pagination: {...} }, { user: {...}, token: {...} }
|
2025-12-25 13:30:55 +00:00
|
|
|
// FE-TYPE-002: Validate direct format responses if schema is provided
|
|
|
|
|
const responseSchema = (response.config as any)?._responseSchema as z.ZodSchema | undefined;
|
|
|
|
|
if (responseSchema && response.data) {
|
|
|
|
|
const validation = safeValidateApiResponse(responseSchema, response.data);
|
|
|
|
|
if (!validation.success) {
|
|
|
|
|
logger.warn('[API] Response validation failed:', {
|
|
|
|
|
url: response.config.url,
|
|
|
|
|
errors: validation.error?.errors,
|
|
|
|
|
});
|
2025-12-27 00:50:39 +00:00
|
|
|
// FIX #18: Utiliser logger structuré au lieu de console.warn
|
|
|
|
|
logger.warn('[API Validation Error]', {
|
|
|
|
|
request_id: (response.config as any)?._requestId,
|
|
|
|
|
url: response.config.url,
|
|
|
|
|
}, validation.error);
|
2025-12-25 13:30:55 +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;
|
|
|
|
|
if (import.meta.env.DEV || (error.config as any)?._enableLogging) {
|
|
|
|
|
logger.debug(`[API Request Cancelled] ${error.config?.method?.toUpperCase() || 'GET'} ${error.config?.url}`, {
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
});
|
|
|
|
|
}
|
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;
|
|
|
|
|
};
|
2025-12-25 10:18:27 +00:00
|
|
|
|
|
|
|
|
// Log error response (only in development or if explicitly enabled)
|
|
|
|
|
const requestId = (originalRequest as any)?._requestId;
|
|
|
|
|
const shouldLog = import.meta.env.DEV || (originalRequest as any)?._enableLogging;
|
|
|
|
|
|
|
|
|
|
if (shouldLog && error.response) {
|
|
|
|
|
const sanitizedErrorData = sanitizeForLogging(error.response.data);
|
|
|
|
|
const sanitizedHeaders = sanitizeForLogging(error.response.headers);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
} else if (shouldLog && error.request && !error.response) {
|
|
|
|
|
// Network error (no response received)
|
|
|
|
|
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-03 21:56:50 +00:00
|
|
|
|
2025-12-26 08:13:36 +00:00
|
|
|
// INT-AUTH-003: Détecter 401 et refresh automatiquement
|
2025-12-22 14:53:47 +00:00
|
|
|
// EXCLURE l'endpoint /auth/refresh pour éviter les boucles infinies
|
|
|
|
|
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
|
2025-12-22 21:56:37 +00:00
|
|
|
|
2025-12-26 08:13:36 +00:00
|
|
|
// INT-AUTH-003: Handle 401 on /auth/refresh endpoint - token expired/revoked, logout and redirect
|
|
|
|
|
if (error.response?.status === 401 && isRefreshEndpoint) {
|
|
|
|
|
logger.error('[API] 401 on /auth/refresh - refresh token expired or revoked, logging out', {
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
url: originalRequest?.url,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
import('@/features/auth/store/authStore').then(({ useAuthStore }) => {
|
|
|
|
|
const store = useAuthStore.getState();
|
|
|
|
|
store.logout().catch((err) => {
|
|
|
|
|
logger.error('[API] Failed to logout from store after refresh token 401', { error: err });
|
|
|
|
|
});
|
|
|
|
|
}).catch((err) => {
|
|
|
|
|
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é. Veuillez vous reconnecter.',
|
|
|
|
|
);
|
|
|
|
|
// Redirect to login
|
|
|
|
|
window.location.href = '/login';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 &&
|
|
|
|
|
!isRefreshEndpoint
|
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 });
|
|
|
|
|
})
|
|
|
|
|
.then((token) => {
|
2025-12-22 14:53:47 +00:00
|
|
|
if (originalRequest.headers && token) {
|
2025-12-22 21:56:37 +00:00
|
|
|
originalRequest.headers.Authorization = `Bearer ${token} `;
|
2025-12-03 21:56:50 +00:00
|
|
|
}
|
2025-12-26 08:13:36 +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) => {
|
2025-12-26 08:13:36 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
try {
|
2025-12-26 08:13:36 +00:00
|
|
|
// INT-AUTH-003: Refresh automatique du token
|
2025-12-03 21:56:50 +00:00
|
|
|
await refreshToken();
|
|
|
|
|
const newToken = TokenStorage.getAccessToken();
|
2025-12-13 02:34:34 +00:00
|
|
|
|
2025-12-22 14:53:47 +00:00
|
|
|
if (!newToken) {
|
|
|
|
|
throw new Error('Failed to get new access token after refresh');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 08:13:36 +00:00
|
|
|
logger.info('[API] Token refresh successful, retrying original request', {
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
url: originalRequest?.url,
|
|
|
|
|
queue_size: failedQueue.length,
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-22 14:53:47 +00:00
|
|
|
if (originalRequest.headers) {
|
2025-12-22 21:56:37 +00:00
|
|
|
originalRequest.headers.Authorization = `Bearer ${newToken} `;
|
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
|
|
|
|
|
// Toutes les requêtes en queue seront rejouées avec le nouveau token
|
2025-12-03 21:56:50 +00:00
|
|
|
processQueue(null, newToken);
|
|
|
|
|
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)
|
|
|
|
|
logger.error('[API] Token refresh failed, logging out', {
|
|
|
|
|
request_id: requestId,
|
|
|
|
|
error: refreshError,
|
|
|
|
|
queue_size: failedQueue.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Rejeter toutes les requêtes en queue
|
2025-12-03 21:56:50 +00:00
|
|
|
processQueue(refreshError as Error, null);
|
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
|
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
|
|
|
|
|
import('@/features/auth/store/authStore').then(({ useAuthStore }) => {
|
|
|
|
|
const store = useAuthStore.getState();
|
|
|
|
|
store.logout().catch((err) => {
|
|
|
|
|
logger.error('[API] Failed to logout from store after refresh failure', { error: err });
|
|
|
|
|
});
|
|
|
|
|
}).catch((err) => {
|
|
|
|
|
logger.error('[API] Failed to import auth store for logout', { error: err });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
const isCSRFError =
|
|
|
|
|
error.response?.status === 403 &&
|
|
|
|
|
originalRequest &&
|
|
|
|
|
!(originalRequest as any)?._csrfRetry &&
|
|
|
|
|
error.response?.data &&
|
|
|
|
|
typeof error.response.data === 'object' &&
|
|
|
|
|
(
|
|
|
|
|
(error.response.data as any)?.error?.message?.toLowerCase().includes('csrf') ||
|
|
|
|
|
(error.response.data as any)?.message?.toLowerCase().includes('csrf')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isCSRFError) {
|
|
|
|
|
const method = originalRequest.method?.toUpperCase();
|
|
|
|
|
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || '');
|
|
|
|
|
|
|
|
|
|
if (isStateChanging) {
|
|
|
|
|
(originalRequest as any)._csrfRetry = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Récupérer un nouveau token CSRF
|
|
|
|
|
const newCsrfToken = await csrfService.refreshToken();
|
|
|
|
|
|
|
|
|
|
if (originalRequest.headers && newCsrfToken) {
|
|
|
|
|
originalRequest.headers['X-CSRF-Token'] = newCsrfToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retry la requête avec le nouveau token
|
|
|
|
|
return apiClient(originalRequest);
|
|
|
|
|
} catch (csrfError) {
|
|
|
|
|
logger.error('[API] Failed to refresh CSRF token after CSRF error', { error: csrfError });
|
|
|
|
|
// 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;
|
2025-12-26 08:10:19 +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
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Show user-friendly error message
|
|
|
|
|
if (apiError.message) {
|
|
|
|
|
toast.error(apiError.message, {
|
|
|
|
|
duration: retryAfterSeconds * 1000, // Show for the retry-after duration
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.reject(apiError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const effectiveMaxRetries = maxRetries; // Use default max retries for other errors
|
2025-12-25 10:11:54 +00:00
|
|
|
|
|
|
|
|
// Check if error is retryable
|
2025-12-26 08:10:19 +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);
|
|
|
|
|
|
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)
|
|
|
|
|
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)
|
2025-12-25 10:11:54 +00:00
|
|
|
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';
|
|
|
|
|
|
2025-12-26 09:49:50 +00:00
|
|
|
// Log retry attempt
|
2025-12-22 22:13:49 +00:00
|
|
|
if (apiError.request_id) {
|
|
|
|
|
console.warn(
|
2025-12-26 08:10:19 +00:00
|
|
|
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${effectiveMaxRetries}) - Request ID: ${apiError.request_id}`,
|
2025-12-22 22:13:49 +00:00
|
|
|
{
|
2025-12-25 10:11:54 +00:00
|
|
|
status: status || 'N/A',
|
|
|
|
|
error_code: error.code || 'N/A',
|
|
|
|
|
retry_count: retryCount + 1,
|
2025-12-26 08:10:19 +00:00
|
|
|
max_retries: effectiveMaxRetries,
|
2025-12-25 10:11:54 +00:00
|
|
|
delay_ms: Math.round(delay),
|
2025-12-22 22:13:49 +00:00
|
|
|
request_id: apiError.request_id,
|
|
|
|
|
url: originalRequest?.url,
|
|
|
|
|
method: originalRequest?.method,
|
2025-12-25 10:11:54 +00:00
|
|
|
is_idempotent: isIdempotent,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn(
|
2025-12-26 08:10:19 +00:00
|
|
|
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${effectiveMaxRetries})`,
|
2025-12-25 10:11:54 +00:00
|
|
|
{
|
|
|
|
|
status: status || 'N/A',
|
|
|
|
|
error_code: error.code || 'N/A',
|
|
|
|
|
retry_count: retryCount + 1,
|
2025-12-26 08:10:19 +00:00
|
|
|
max_retries: effectiveMaxRetries,
|
2025-12-25 10:11:54 +00:00
|
|
|
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);
|
2025-12-25 10:11:54 +00:00
|
|
|
const errorType = status ? `HTTP ${status}` : error.code || 'Network Error';
|
2025-12-22 22:13:49 +00:00
|
|
|
|
|
|
|
|
// Log final error with request_id after all retries failed
|
|
|
|
|
if (apiError.request_id) {
|
|
|
|
|
console.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 {
|
|
|
|
|
console.error(
|
|
|
|
|
`[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
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
2025-12-25 10:32:53 +00:00
|
|
|
// FE-COMP-005: Show toast notification for API errors (unless disabled)
|
|
|
|
|
const shouldShowToast = !(originalRequest as any)?._disableToast &&
|
|
|
|
|
status !== 401 && // Don't show toast for 401 (handled by refresh)
|
|
|
|
|
status !== 404 && // Don't show toast for 404 (handled by router)
|
|
|
|
|
!axios.isCancel(error); // Don't show toast for cancelled requests
|
|
|
|
|
|
|
|
|
|
if (shouldShowToast && typeof window !== 'undefined') {
|
|
|
|
|
// Get user-friendly error message
|
|
|
|
|
let errorMessage = apiError.message;
|
|
|
|
|
|
|
|
|
|
// Customize message based on status code
|
|
|
|
|
if (status === 403) {
|
|
|
|
|
errorMessage = "Vous n'avez pas les permissions nécessaires pour effectuer cette action";
|
|
|
|
|
} else if (status === 422) {
|
|
|
|
|
errorMessage = apiError.details?.[0]?.message || apiError.message || "Erreur de validation";
|
|
|
|
|
} else if (status === 429) {
|
|
|
|
|
errorMessage = "Trop de requêtes. Veuillez patienter quelques instants";
|
|
|
|
|
} else if (status >= 500) {
|
|
|
|
|
errorMessage = "Une erreur serveur s'est produite. Veuillez réessayer plus tard";
|
|
|
|
|
} else if (!error.response) {
|
2025-12-25 12:22:15 +00:00
|
|
|
// Check if it's a timeout error
|
|
|
|
|
if (isTimeoutError(error)) {
|
|
|
|
|
errorMessage = getTimeoutMessage('normal');
|
|
|
|
|
} else {
|
|
|
|
|
errorMessage = "Erreur de connexion. Vérifiez votre connexion internet";
|
|
|
|
|
}
|
2025-12-25 12:24:19 +00:00
|
|
|
|
|
|
|
|
// FE-API-015: Queue request for offline replay if it's a network error
|
|
|
|
|
if (originalRequest && offlineQueue.shouldQueueRequest(originalRequest)) {
|
|
|
|
|
const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
|
|
|
|
|
if (isOffline || (!error.response && error.request)) {
|
|
|
|
|
// Determine priority based on request type
|
|
|
|
|
const method = originalRequest.method?.toUpperCase();
|
|
|
|
|
const priority = method === 'DELETE' ? 'low' : method === 'POST' ? 'high' : 'normal';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await offlineQueue.queueRequest(originalRequest, { priority });
|
|
|
|
|
// Show info toast that request was queued
|
|
|
|
|
toast.success('Requête mise en file d\'attente. Elle sera envoyée à la reconnexion.', {
|
|
|
|
|
duration: 4000,
|
|
|
|
|
});
|
|
|
|
|
} catch (queueError) {
|
|
|
|
|
logger.error('[API] Failed to queue request for offline replay', { error: queueError });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-25 10:32:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toast.error(errorMessage, {
|
2025-12-26 09:49:50 +00:00
|
|
|
duration: 5000, // Standard duration for errors
|
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
|
|
|
|
|
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
|
|
|
|
|
*
|
|
|
|
|
* @example
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const { request, abort } = createCancellableRequest((signal) =>
|
|
|
|
|
* apiClient.get('/api/v1/tracks', { signal })
|
|
|
|
|
* );
|
|
|
|
|
*
|
|
|
|
|
* // Later, to cancel:
|
|
|
|
|
* abort();
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
export function createCancellableRequest<T>(
|
|
|
|
|
requestFn: (signal: AbortSignal) => Promise<T>,
|
|
|
|
|
): { request: Promise<T>; abort: () => void } {
|
|
|
|
|
const abortController = new AbortController();
|
|
|
|
|
const signal = abortController.signal;
|
|
|
|
|
|
|
|
|
|
const request = requestFn(signal);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
request,
|
|
|
|
|
abort: () => {
|
|
|
|
|
abortController.abort();
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper function to create a request with timeout
|
|
|
|
|
* Automatically cancels the request if it exceeds the timeout duration
|
|
|
|
|
*
|
|
|
|
|
* @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;
|
|
|
|
|
|
|
|
|
|
// Set up timeout
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
abortController.abort();
|
|
|
|
|
}, timeoutMs);
|
|
|
|
|
|
|
|
|
|
const request = requestFn(signal)
|
|
|
|
|
.finally(() => {
|
|
|
|
|
// Clear timeout if request completes before timeout
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
request,
|
|
|
|
|
abort: () => {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
abortController.abort();
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
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
|
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)
|
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) {
|
|
|
|
|
const cachedResponse = responseCache.get({ ...config, method: 'GET', url });
|
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
post: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
|
|
|
|
|
return requestDeduplication.getOrCreateRequest(
|
|
|
|
|
{ ...config, method: 'POST', url, data },
|
|
|
|
|
() => apiClient.post<T>(url, data, config),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
put: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
|
|
|
|
|
return requestDeduplication.getOrCreateRequest(
|
|
|
|
|
{ ...config, method: 'PUT', url, data },
|
|
|
|
|
() => apiClient.put<T>(url, data, config),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
patch: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
|
|
|
|
|
return requestDeduplication.getOrCreateRequest(
|
|
|
|
|
{ ...config, method: 'PATCH', url, data },
|
|
|
|
|
() => apiClient.patch<T>(url, data, config),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
delete: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
|
|
|
|
|
return requestDeduplication.getOrCreateRequest(
|
|
|
|
|
{ ...config, method: 'DELETE', url },
|
|
|
|
|
() => apiClient.delete<T>(url, config),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|