veza/apps/web/src/services/api/client.ts

336 lines
11 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
* et unwrapping du format backend { success, data, error }
* 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));
};
/**
* Get retry delay from Retry-After header or use exponential backoff
*/
const getRetryDelay = (
error: AxiosError,
attempt: number,
baseDelay: number = 1000,
): 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 delay * 1000; // Convert to milliseconds
}
}
// Exponential backoff: baseDelay * 2^attempt
return baseDelay * Math.pow(2, attempt);
};
// 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>>) => {
// Backend retourne { success: true, data: {...} }
// On unwrap pour retourner directement data
if (response.data && typeof response.data === 'object' && 'success' in response.data) {
if (response.data.success === true) {
// Retourner directement data, pas le wrapper
return {
...response,
data: response.data.data,
} 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, retourner la réponse telle quelle
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;
}
}
// Gestion spécifique des erreurs 429, 503, 502
const status = error.response?.status;
if (status === 429) {
// Too Many Requests - Retry après delay
const apiError = parseApiError(error);
const retryAfter = apiError.retry_after || 5; // Default 5 secondes
// Log error with request_id for correlation
if (apiError.request_id) {
console.warn(
`[API Rate Limit] ${apiError.message} (Request ID: ${apiError.request_id}, Retry after: ${retryAfter}s)`,
{
code: apiError.code,
request_id: apiError.request_id,
retry_after: retryAfter,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
}
// Si la requête n'a pas encore été retentée, attendre et réessayer
if (originalRequest && !originalRequest._retry && retryAfter > 0) {
originalRequest._retry = true;
// Attendre le délai spécifié avant de réessayer
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
// Réessayer la requête une seule fois
return apiClient(originalRequest);
}
// Si déjà retentée ou retry_after invalide, rejeter avec l'erreur
return Promise.reject(apiError);
}
// Retry logic for 502/503 errors (transient errors)
if (status === 502 || status === 503) {
// Service Unavailable (503) or Bad Gateway (502) - Retry with exponential backoff
// Check if this request has already been retried (to avoid infinite loops)
const retryCount = (originalRequest as any)._retry502503Count || 0;
const maxRetries = 3;
if (originalRequest && retryCount < maxRetries) {
// Mark that we're retrying this request
(originalRequest as any)._retry502503Count = retryCount + 1;
// Calculate delay (respect Retry-After header if present, otherwise exponential backoff)
const delay = getRetryDelay(error, retryCount, 1000);
// Log retry attempt with request_id if available
const apiError = parseApiError(error);
if (apiError.request_id) {
console.warn(
`[API Retry] ${status} error, retrying (${retryCount + 1}/${maxRetries}) - Request ID: ${apiError.request_id}`,
{
status,
retry_count: retryCount + 1,
max_retries: maxRetries,
delay_ms: delay,
request_id: apiError.request_id,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
}
// Wait before retrying
return sleep(delay).then(() => {
// Retry the request
return apiClient(originalRequest);
});
}
// If already retried maxRetries times, reject immediately
const apiError = parseApiError(error);
// Log final error with request_id after all retries failed
if (apiError.request_id) {
console.error(
`[API Error] ${status} 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,
},
);
}
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);
},
);