veza/apps/web/src/services/api/client.ts
senke 0f25d3c551 fix(webhooks): add DB migration and avoid 500 toast on developer portal
Backend:
- Add migrations/075_create_webhooks.sql: webhooks + webhook_failures tables
- Fixes GET /webhooks 500 (relation "webhooks" did not exist)

Frontend:
- Skip toast for 5xx on /webhooks so developer portal shows empty state
  instead of 'Une erreur serveur s'est produite' when table is missing

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 21:11:32 +01:00

2237 lines
78 KiB
TypeScript

import axios, {
AxiosError,
InternalAxiosRequestConfig,
AxiosResponse,
} from 'axios';
// CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables
import toast from '@/utils/toast';
import { z } from 'zod';
import { TokenStorage } from '../tokenStorage';
import { refreshToken } from '../tokenRefresh';
import { env } from '@/config/env';
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
import { formatUserFriendlyError } from '@/utils/errorMessages';
import { csrfService } from '../csrf';
import { logger, setLogContext } from '@/utils/logger';
import {
isTimeoutError as _isTimeoutError,
getTimeoutMessage as _getTimeoutMessage,
} from '@/utils/timeoutHandler';
import { offlineQueue } from '../offlineQueue';
import { requestDeduplication } from '../requestDeduplication';
import { responseCache } from '../responseCache';
import { invalidateStateAfterMutation } from '@/utils/stateInvalidation';
import { safeValidateApiResponse } from '@/schemas/apiSchemas';
import { safeValidateApiRequest } from '@/schemas/apiRequestSchemas';
import type { ApiResponse } from '@/types/api';
import { useRateLimitStore } from '@/stores/rateLimit';
/**
* Action 1.2.2.3: Validation error metrics tracker
* Tracks validation failure rate for monitoring
*/
interface ValidationMetrics {
totalValidations: number;
successfulValidations: number;
failedValidations: number;
failureRate: number; // percentage
lastFailureTime?: string;
lastSuccessTime?: string;
failuresByEndpoint: Record<string, number>;
}
class ValidationMetricsTracker {
private metrics: ValidationMetrics = {
totalValidations: 0,
successfulValidations: 0,
failedValidations: 0,
failureRate: 0,
failuresByEndpoint: {},
};
recordSuccess(_url?: string): void {
this.metrics.totalValidations++;
this.metrics.successfulValidations++;
this.metrics.lastSuccessTime = new Date().toISOString();
this.updateFailureRate();
}
recordFailure(url?: string): void {
this.metrics.totalValidations++;
this.metrics.failedValidations++;
this.metrics.lastFailureTime = new Date().toISOString();
if (url) {
const endpoint = this.normalizeEndpoint(url);
this.metrics.failuresByEndpoint[endpoint] =
(this.metrics.failuresByEndpoint[endpoint] || 0) + 1;
}
this.updateFailureRate();
}
private updateFailureRate(): void {
if (this.metrics.totalValidations > 0) {
this.metrics.failureRate =
(this.metrics.failedValidations / this.metrics.totalValidations) * 100;
}
}
private normalizeEndpoint(url?: string): string {
if (!url) return 'unknown';
// Normalize URL to endpoint pattern (e.g., /api/v1/tracks/123 -> /api/v1/tracks/:id)
try {
const urlObj = new URL(url, 'http://localhost');
const path = urlObj.pathname;
// Replace UUIDs and numeric IDs with :id
return path.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
.replace(/\/\d+/g, '/:id');
} catch {
// Fallback: remove query params and return path
const path = url.split('?')[0];
return path || 'unknown';
}
}
getMetrics(): ValidationMetrics {
return { ...this.metrics };
}
reset(): void {
this.metrics = {
totalValidations: 0,
successfulValidations: 0,
failedValidations: 0,
failureRate: 0,
failuresByEndpoint: {},
};
}
}
// Singleton instance for validation metrics
export const validationMetrics = new ValidationMetricsTracker();
/**
* Action 1.2.2.4: Validation error alerting
* Monitors validation metrics and alerts when thresholds are exceeded
*/
interface ValidationAlertConfig {
failureRateThreshold: number; // Percentage (e.g., 5 = 5%)
minValidationsForAlert: number; // Minimum validations before alerting
checkInterval: number; // Milliseconds between checks
}
const DEFAULT_ALERT_CONFIG: ValidationAlertConfig = {
failureRateThreshold: 5.0, // Alert if failure rate > 5%
minValidationsForAlert: 10, // Need at least 10 validations before alerting
checkInterval: 5 * 60 * 1000, // Check every 5 minutes
};
class ValidationAlerting {
private config: ValidationAlertConfig = DEFAULT_ALERT_CONFIG;
private checkIntervalId: NodeJS.Timeout | null = null;
private lastAlertTime: number = 0;
private alertCooldown: number = 15 * 60 * 1000; // 15 minutes between alerts
start(config?: Partial<ValidationAlertConfig>): void {
if (this.checkIntervalId) {
this.stop();
}
this.config = { ...DEFAULT_ALERT_CONFIG, ...config };
if (typeof window !== 'undefined') {
// Initial check after 1 minute
setTimeout(() => this.checkMetrics(), 60 * 1000);
// Periodic checks
this.checkIntervalId = setInterval(
() => this.checkMetrics(),
this.config.checkInterval,
);
}
}
stop(): void {
if (this.checkIntervalId) {
clearInterval(this.checkIntervalId);
this.checkIntervalId = null;
}
}
private checkMetrics(): void {
const metrics = validationMetrics.getMetrics();
// Skip if not enough validations
if (metrics.totalValidations < this.config.minValidationsForAlert) {
return;
}
// Check failure rate threshold
if (metrics.failureRate > this.config.failureRateThreshold) {
const now = Date.now();
// Cooldown to avoid alert spam
if (now - this.lastAlertTime < this.alertCooldown) {
return;
}
this.lastAlertTime = now;
// Log alert with structured context
logger.error(
'[API Validation Alert] High validation failure rate detected',
{
alert_type: 'high_validation_failure_rate',
failure_rate: metrics.failureRate.toFixed(2),
threshold: this.config.failureRateThreshold,
total_validations: metrics.totalValidations,
failed_validations: metrics.failedValidations,
successful_validations: metrics.successfulValidations,
last_failure_time: metrics.lastFailureTime,
failures_by_endpoint: metrics.failuresByEndpoint,
timestamp: new Date().toISOString(),
},
);
// Also send to Sentry if configured (via logger.error)
}
}
updateConfig(config: Partial<ValidationAlertConfig>): void {
this.config = { ...this.config, ...config };
}
}
// Singleton instance for validation alerting
export const validationAlerting = new ValidationAlerting();
// Start alerting in production (can be disabled via env var)
if (typeof window !== 'undefined' && import.meta.env.PROD) {
const enableAlerting = import.meta.env.VITE_ENABLE_VALIDATION_ALERTING !== 'false';
if (enableAlerting) {
validationAlerting.start();
}
}
/**
* 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
*/
// 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;
// Edge 2.3: Slow request detection threshold
// Requests taking longer than this will be considered "slow" and can trigger loading indicators
export const SLOW_REQUEST_THRESHOLD = 1000; // 1 second - threshold for showing loading indicators
// Client API réutilisable
export const apiClient = axios.create({
baseURL: env.API_URL,
timeout: API_TIMEOUTS.DEFAULT,
headers: {
'Content-Type': 'application/json',
},
// SECURITY: Activer withCredentials pour envoyer les cookies httpOnly automatiquement
// Les cookies httpOnly sont set par le backend et envoyés automatiquement avec chaque requête
withCredentials: true,
});
// P1.4: Refresh token loop protection
// Flag pour éviter les refresh en boucle
let isRefreshing = false;
let refreshAttempts = 0;
const MAX_REFRESH_ATTEMPTS = 3;
let failedQueue: Array<{
resolve: (value?: any) => void;
reject: (error?: any) => void;
}> = [];
// SECURITY: Action 5.1.1.3 - Removed proactive refresh cooldown logic
// Tokens are in httpOnly cookies, can't check expiration from JS
/**
* 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: [500, 502, 503, 504], // Server errors, gateway errors (429 excluded - don't retry rate limits)
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;
};
/**
* Edge 2.1: Track network failure patterns to distinguish partial vs complete failures
* Partial failures: Some requests succeed while others fail (intermittent connectivity)
* Complete failures: All requests fail (no connection at all)
*/
class NetworkFailureTracker {
private recentRequests: Array<{ success: boolean; timestamp: number }> = [];
private readonly windowSize = 10; // Track last 10 requests
private readonly windowMs = 30000; // 30 second window
recordRequest(success: boolean): void {
const now = Date.now();
this.recentRequests.push({ success, timestamp: now });
// Keep only requests within the time window
this.recentRequests = this.recentRequests.filter(
(req) => now - req.timestamp < this.windowMs,
);
// Keep only the most recent requests (limit window size)
if (this.recentRequests.length > this.windowSize) {
this.recentRequests = this.recentRequests.slice(-this.windowSize);
}
}
/**
* Edge 2.1: Determine if this is a partial network failure
* Partial failure = some requests succeed, some fail (intermittent connectivity)
* Complete failure = all requests fail (no connection)
*/
isPartialFailure(): boolean {
if (this.recentRequests.length === 0) {
return false; // No data to determine
}
const successCount = this.recentRequests.filter((r) => r.success).length;
const failureCount = this.recentRequests.filter((r) => !r.success).length;
// Partial failure: both successes and failures in recent window
return successCount > 0 && failureCount > 0;
}
/**
* Edge 2.1: Determine if this is a complete network failure
* Complete failure = all recent requests failed
*/
isCompleteFailure(): boolean {
if (this.recentRequests.length === 0) {
return false; // No data to determine
}
// Complete failure: all recent requests failed
return this.recentRequests.every((r) => !r.success);
}
reset(): void {
this.recentRequests = [];
}
}
const networkFailureTracker = new NetworkFailureTracker();
/**
* Edge 2.1: Check if an error represents a partial network failure
* Partial failures include:
* - HTTP 206 Partial Content
* - Timeout after partial data transfer
* - Connection drops mid-response
* - Intermittent connectivity (some requests succeed, some fail)
*/
const isPartialNetworkFailure = (error: AxiosError): boolean => {
// HTTP 206 Partial Content is a partial failure
if (error.response?.status === 206) {
return true;
}
// Timeout after partial data transfer (request started but didn't complete)
if (
error.code === 'ECONNABORTED' &&
error.message?.toLowerCase().includes('timeout') &&
error.request
) {
return true;
}
// Connection reset mid-response (partial data received)
if (error.code === 'ECONNRESET' && error.response) {
return true;
}
// Intermittent connectivity: some requests succeed, some fail
if (networkFailureTracker.isPartialFailure()) {
return true;
}
return false;
};
/**
* Edge 2.1: Check if an error represents a complete network failure
* Complete failures include:
* - All requests fail (no connection at all)
* - Server completely unreachable
* - No internet connection
*/
const isCompleteNetworkFailure = (error: AxiosError): boolean => {
// No response and no request = complete failure
if (!error.response && !error.request) {
return true;
}
// Connection refused = server completely unreachable
if (
error.code === 'ECONNREFUSED' ||
error.code === 'ERR_CONNECTION_REFUSED'
) {
return true;
}
// Network unreachable = no internet connection
if (
error.code === 'ENETUNREACH' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ERR_INTERNET_DISCONNECTED'
) {
return true;
}
// All recent requests failed = complete failure pattern
if (networkFailureTracker.isCompleteFailure()) {
return true;
}
return false;
};
/**
* 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;
}
// Don't retry when API returned HTML instead of JSON (wrong server on port 8080)
if (
error.code === 'ERR_BAD_RESPONSE' ||
error.message?.includes('HTML page instead of JSON')
) {
return false;
}
// Check if retry is disabled for this request
if ((error.config as any)?._disableRetry) {
return false;
}
// Edge 2.1: Partial failures are more retryable than complete failures
// Partial failures suggest intermittent connectivity that might resolve
if (isPartialNetworkFailure(error)) {
// Retry partial failures more aggressively (if idempotent)
return isIdempotentMethod(error.config?.method);
}
// 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);
};
/**
* 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;
};
// T0177: Fonction pour traiter la queue de requêtes en attente
// SECURITY: Action 5.1.1.3 - processQueue no longer needs token parameter
// Cookies are automatically sent with requests via withCredentials: true
const processQueue = (error: Error | null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(undefined); // No token needed, cookies are sent automatically
}
});
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(
async (config: InternalAxiosRequestConfig) => {
// Edge 2.3: Track request start time for slow request detection
const requestStartTime = Date.now();
(config as any)._requestStartTime = requestStartTime;
(config as any)._isSlowRequest = false;
// API Versioning: Add X-API-Version header to all requests
if (config.headers) {
config.headers['X-API-Version'] = env.API_VERSION;
}
// SECURITY: Action 5.1.1.3 - Tokens are in httpOnly cookies, not accessible from JS
// Cookies are automatically sent with requests via withCredentials: true
// No need to set Authorization header - backend reads from cookie
// 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'];
}
// CRITIQUE FIX #25: Ajouter le token CSRF pour toutes les requêtes mutantes (POST, PUT, DELETE, PATCH)
// Le token CSRF est requis pour toutes les requêtes qui modifient l'état côté serveur
const method = config.method?.toUpperCase();
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(
method || '',
);
// isCSRFRoute déjà défini plus haut
const isAuthRoute =
config.url?.includes('/auth/login') ||
config.url?.includes('/auth/register');
const isCSRFRoute = config.url?.includes('/csrf-token');
if (
isStateChanging &&
!isCSRFRoute &&
!isAuthRoute &&
config.headers
) {
// CRITIQUE FIX #25: S'assurer que le token CSRF est toujours présent pour les requêtes mutantes
// Si le token n'est pas disponible, en récupérer un nouveau avant d'envoyer la requête
let csrfToken = csrfService.getToken();
if (!csrfToken) {
// Si pas de token, essayer d'en récupérer un nouveau de manière synchrone si possible
// Sinon, l'interceptor de réponse gérera le retry avec nouveau token après une erreur 403
try {
csrfToken = await csrfService.ensureToken();
} catch (error) {
// Si la récupération échoue, continuer sans token - l'interceptor de réponse gérera le retry
logger.warn(
'[API] Failed to fetch CSRF token before request, will retry on 403',
{
url: config.url,
method: config.method,
},
);
}
}
if (csrfToken && config.headers) {
config.headers['X-CSRF-Token'] = csrfToken;
}
}
// Edge 2.2: Support AbortController for request cancellation
// If a signal is provided in the config, axios will use it automatically
// Users can pass a signal via config.signal to enable cancellation
// Helper functions createCancellableRequest() and createRequestWithTimeout()
// can be used to easily create cancellable requests
if (!config.signal && !config.cancelToken) {
// No cancellation configured - users can pass a signal via config.signal
// or use the helper functions for automatic cancellation support
}
// FE-TYPE-003: Validate request data if schema is provided
// Action 1.2.1.5: Ensure all requests are validated when 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) - they are validated separately
if (!(config.data instanceof FormData)) {
const validation = safeValidateApiRequest(requestSchema, config.data);
if (!validation.success) {
const requestId = getRequestId(config);
// Log validation error with structured logging
logger.warn(
'[API Request Validation Error]',
{
request_id: requestId,
url: config.url,
method: config.method?.toUpperCase(),
errors: validation.error?.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
code: e.code,
})),
},
validation.error,
);
// Throw error to prevent invalid request from being sent
// This ensures data integrity before sending to backend
const errorMessages =
validation.error?.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ') || 'Request validation failed';
throw new Error(`Request validation failed: ${errorMessages}`);
}
// Use validated data (Zod may transform/coerce values)
config.data = validation.data;
}
}
// Store request start time for duration calculation
(config as any)._requestStartTime = Date.now();
// Log request only when VITE_DEBUG=true or explicitly enabled (reduces console noise)
if ((import.meta.env.DEV && env.DEBUG) || (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,
});
}
return config;
},
(error) => {
// 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,
});
}
return Promise.reject(error);
},
);
// Detect when the API returns HTML (wrong server on port 8080, e.g. another app or proxy)
function isHtmlResponse(response: AxiosResponse): boolean {
const ct = response.headers?.['content-type'];
if (typeof ct === 'string' && ct.toLowerCase().includes('text/html')) {
return true;
}
const data = response.data;
if (typeof data === 'string') {
const trimmed = data.trim().toLowerCase();
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
}
return false;
}
// Interceptor de réponse pour unwrap le format backend et gérer les erreurs
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse<any> | any>) => {
// Detect wrong server: API must return JSON, not HTML (e.g. another app on 8080)
if (isHtmlResponse(response)) {
const msg =
'The API returned an HTML page instead of JSON. Another application may be using port 8080. Stop any other server (e.g. phishing lab) and ensure the Veza backend is running.';
if (typeof window !== 'undefined') {
const key = 'veza_wrong_server_shown';
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, 'true');
toast(msg, { icon: '⚠️', duration: 12000 });
}
}
return Promise.reject(
new AxiosError(
msg,
'ERR_BAD_RESPONSE',
response.config,
response.request,
response,
),
);
}
// Edge 2.1: Record successful request for partial failure detection
networkFailureTracker.recordRequest(true);
// Edge 2.3: Detect slow requests and mark them
const requestStartTime = (response.config as any)?._requestStartTime;
if (requestStartTime) {
const requestDuration = Date.now() - requestStartTime;
if (requestDuration > SLOW_REQUEST_THRESHOLD) {
(response.config as any)._isSlowRequest = true;
(response.config as any)._requestDuration = requestDuration;
if ((import.meta.env.DEV && env.DEBUG) || (response.config as any)?._enableLogging) {
logger.debug(
`[API Slow Request] ${response.config?.method?.toUpperCase()} ${response.config?.url} took ${requestDuration}ms`,
{
duration: requestDuration,
threshold: SLOW_REQUEST_THRESHOLD,
},
);
}
}
}
// 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 });
}
// Action 5.4.1.1: Parse rate limit headers
const rateLimitLimit =
response.headers['x-ratelimit-limit'] ||
response.headers['X-RateLimit-Limit'];
const rateLimitRemaining =
response.headers['x-ratelimit-remaining'] ||
response.headers['X-RateLimit-Remaining'];
const rateLimitReset =
response.headers['x-ratelimit-reset'] ||
response.headers['X-RateLimit-Reset'];
if (rateLimitLimit || rateLimitRemaining || rateLimitReset) {
useRateLimitStore.getState().updateRateLimit({
limit: rateLimitLimit,
remaining: rateLimitRemaining,
reset: rateLimitReset,
retryAfter: null, // Not set on successful responses
});
}
// Log successful response only when VITE_DEBUG=true or explicitly enabled (reduces console noise)
const shouldLogResponse =
(import.meta.env.DEV && env.DEBUG) || (response.config as any)?._enableLogging;
if (shouldLogResponse) {
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,
},
);
}
// API Versioning: Check for deprecation warning
const deprecatedHeader =
response.headers['x-api-deprecated'] ||
response.headers['X-API-Deprecated'];
if (deprecatedHeader === 'true') {
// Show deprecation warning (only once per session to avoid spam)
const deprecationKey = 'api_deprecation_warning_shown';
if (typeof window !== 'undefined' && !sessionStorage.getItem(deprecationKey)) {
const sunsetDate = response.headers['sunset'] || response.headers['Sunset'];
const message = sunsetDate
? `This API version is deprecated and will be removed on ${sunsetDate}. Please update to the latest version.`
: 'This API version is deprecated. Please update to the latest version.';
// Use toast with warning icon (react-hot-toast doesn't have toast.warning)
toast(message, {
icon: '⚠️',
duration: 10000, // Show for 10 seconds
});
sessionStorage.setItem(deprecationKey, 'true');
logger.warn('[API] Deprecated API version detected', {
url: response.config.url,
version: response.headers['x-api-version'] || response.headers['X-API-Version'],
sunset_date: sunsetDate,
});
}
}
// 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;
}
// 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);
}
}
// 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
// FE-STATE-004: Invalidate state after mutations
if (isMutation) {
const url = response.config.url || '';
const method = response.config.method || 'POST';
// Use centralized invalidation system
invalidateStateAfterMutation(url, method);
}
// INT-API-002: 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;
// FE-TYPE-002: Validate response data if schema is provided
// Action 1.2.2.1: Enhanced response validation for all responses with schemas
const responseSchema = (response.config as any)?._responseSchema as
| z.ZodSchema
| undefined;
if (responseSchema && unwrappedData !== null) {
const validation = safeValidateApiResponse(
responseSchema,
unwrappedData,
);
if (!validation.success) {
const requestId = getRequestId(response.config);
// Action 1.2.2.2: Production error logging for validation failures
// Enhanced logging with structured error details for production monitoring
const validationErrorContext = {
request_id: requestId,
url: response.config.url,
method: response.config.method?.toUpperCase(),
status: response.status,
error_type: 'api_response_validation_failed',
validation_errors: validation.error?.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
code: e.code,
received: e.code === 'invalid_type' ? e.received : undefined,
expected: e.code === 'invalid_type' ? e.expected : undefined,
})),
response_data_preview: JSON.stringify(unwrappedData).substring(0, 200),
schema_provided: !!responseSchema,
timestamp: new Date().toISOString(),
};
// Log to structured logger (sends to backend endpoint and Sentry in production)
logger.error(
'[API Response Validation Failed]',
validationErrorContext,
validation.error,
);
// Action 1.2.2.3: Track validation failure metrics
validationMetrics.recordFailure(response.config.url);
// Action 1.2.2.5: Validation error recovery mechanism
const recoveryConfig = (response.config as any)?._validationRecovery as
| { useCache?: boolean; retry?: boolean; notifyUser?: boolean }
| undefined;
const useCache = recoveryConfig?.useCache !== false; // Default: true
const retry = recoveryConfig?.retry === true; // Default: false (safety)
const notifyUser = recoveryConfig?.notifyUser !== false; // Default: true
// Recovery 1: Try cached response (for GET requests only)
if (useCache && method === 'GET') {
const cachedResponse = responseCache.get(response.config);
if (cachedResponse) {
// Cached response may be in wrapped or direct format
// For wrapped format, unwrap it first
let cachedData = cachedResponse.data;
if (cachedData && typeof cachedData === 'object' && 'success' in cachedData && cachedData.success === true) {
cachedData = (cachedData as any).data !== undefined ? (cachedData as any).data : null;
}
// Validate cached response before using it
if (cachedData !== null) {
const cachedValidation = safeValidateApiResponse(responseSchema, cachedData);
if (cachedValidation.success) {
logger.warn(
'[API Validation Recovery] Using cached response due to validation failure',
{
request_id: requestId,
url: response.config.url,
recovery_type: 'cache_fallback',
},
);
if (notifyUser && typeof window !== 'undefined') {
toast('Data may be outdated. Please refresh if issues persist.', {
icon: '⚠️',
duration: 5000,
});
}
// Return cached response with unwrapped data (matching current format)
return {
...cachedResponse,
data: cachedData,
} as AxiosResponse<any>;
}
}
}
}
// Recovery 2: Optional retry (only if explicitly enabled)
if (retry && !(response.config as any)?._validationRetryAttempted) {
(response.config as any)._validationRetryAttempted = true;
logger.warn(
'[API Validation Recovery] Retrying request due to validation failure',
{
request_id: requestId,
url: response.config.url,
recovery_type: 'retry',
},
);
// Retry the request (will go through the same validation again)
return apiClient.request(response.config);
}
// Continue with unvalidated data (graceful degradation)
// In production, this allows the app to continue functioning even if
// the backend response doesn't match the expected schema (e.g., during migrations)
// Validation errors are logged for monitoring and alerting
if (notifyUser && typeof window !== 'undefined') {
toast('Some data may be incomplete. Please refresh if issues persist.', {
icon: '⚠️',
duration: 5000,
});
}
} else {
// Log successful validation in debug mode
const requestId = getRequestId(response.config);
logger.debug('[API Response Validation Success]', {
request_id: requestId,
url: response.config.url,
});
// Action 1.2.2.3: Track validation success metrics
validationMetrics.recordSuccess(response.config.url);
}
}
return {
...response,
data: unwrappedData,
} as AxiosResponse<any>;
}
// 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);
}
}
// Action 1.3.2.2: Removed dual-format handling - backend now always returns wrapped format
// If we receive a response without 'success' field, log a warning (should not happen)
if (response.data && typeof response.data === 'object' && !('success' in response.data)) {
const requestId = getRequestId(response.config);
logger.warn(
'[API] Received non-wrapped response format (unexpected)',
{
request_id: requestId,
url: response.config.url,
method: response.config.method?.toUpperCase(),
status: response.status,
response_preview: JSON.stringify(response.data).substring(0, 200),
timestamp: new Date().toISOString(),
},
);
// Continue with the response as-is (graceful degradation)
// This should not happen if backend is properly deployed
}
return response;
},
async (error: AxiosError<ApiResponse<any>>) => {
// Don't retry or process cancelled requests
if (axios.isCancel(error)) {
const requestId = (error.config as any)?._requestId;
if ((import.meta.env.DEV && env.DEBUG) || (error.config as any)?._enableLogging) {
logger.debug(
`[API Request Cancelled] ${error.config?.method?.toUpperCase() || 'GET'} ${error.config?.url}`,
{
request_id: requestId,
},
);
}
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// FIX #22: Extraire le request_id depuis les headers de réponse d'erreur pour corrélation
let requestId = (originalRequest as any)?._requestId;
if (error.response?.headers) {
const requestIdFromHeader =
error.response.headers['x-request-id'] ||
error.response.headers['X-Request-ID'];
if (requestIdFromHeader) {
requestId = requestIdFromHeader;
// Mettre à jour le contexte global du logger avec le request_id
setLogContext({ request_id: requestId });
}
// Action 5.4.1.1: Parse rate limit headers from error response
const rateLimitLimit =
error.response.headers['x-ratelimit-limit'] ||
error.response.headers['X-RateLimit-Limit'];
const rateLimitRemaining =
error.response.headers['x-ratelimit-remaining'] ||
error.response.headers['X-RateLimit-Remaining'];
const rateLimitReset =
error.response.headers['x-ratelimit-reset'] ||
error.response.headers['X-RateLimit-Reset'];
const retryAfter =
error.response.headers['retry-after'] ||
error.response.headers['Retry-After'];
if (
rateLimitLimit ||
rateLimitRemaining ||
rateLimitReset ||
retryAfter
) {
useRateLimitStore.getState().updateRateLimit({
limit: rateLimitLimit,
remaining: rateLimitRemaining,
reset: rateLimitReset,
retryAfter,
});
}
}
// Log error response (only when VITE_DEBUG or explicitly enabled; avoid dumping HTML)
const shouldLogError =
(import.meta.env.DEV && env.DEBUG) || (originalRequest as any)?._enableLogging;
if (shouldLogError && error.response) {
const isHtml =
(error.response.headers?.['content-type'] as string)?.toLowerCase?.().includes('text/html') ||
(typeof error.response.data === 'string' &&
(error.response.data as string).trim().toLowerCase().startsWith('<!doctype'));
const sanitizedErrorData = isHtml
? '[HTML response omitted - wrong server on port 8080?]'
: sanitizeForLogging(error.response.data);
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 (shouldLogError && 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,
},
);
}
// P1.3: Handle 403 CSRF token invalid - refresh token and retry once
// This handles cases where the CSRF token has expired or is invalid
const isCSRFRoute = originalRequest?.url?.includes('/csrf-token');
const isAuthRoute =
originalRequest?.url?.includes('/auth/login') ||
originalRequest?.url?.includes('/auth/register');
if (
error.response?.status === 403 &&
originalRequest &&
!(originalRequest as any)._csrfRetry &&
!isCSRFRoute &&
!isAuthRoute
) {
// Check if this is likely a CSRF error
const errorData = error.response?.data as any;
const errorMessage = (typeof errorData?.error === 'string' ? errorData.error : errorData?.message || '').toLowerCase();
const isCSRFError =
errorMessage.includes('csrf') ||
errorMessage.includes('token') ||
errorMessage.includes('forbidden');
if (isCSRFError) {
(originalRequest as any)._csrfRetry = true;
logger.info('[API] CSRF token invalid (403), refreshing and retrying', {
request_id: requestId,
url: originalRequest?.url,
method: originalRequest?.method,
});
try {
// Refresh CSRF token
const newToken = await csrfService.ensureToken();
// Update request headers with new token
if (originalRequest.headers) {
originalRequest.headers['X-CSRF-Token'] = newToken;
}
logger.debug('[API] CSRF token refreshed, retrying request', {
request_id: requestId,
url: originalRequest?.url,
});
// Retry the request with new CSRF token
return apiClient.request(originalRequest);
} catch (csrfError) {
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,
});
}
// Fall through to reject with original error
}
}
}
// INT-AUTH-003: Détecter 401 et refresh automatiquement
// EXCLURE /auth/refresh et /auth/logout pour éviter les boucles.
// EXCLURE /auth/me : 401 = non connecté ; ne pas tenter de refresh ni rediriger (sinon boucle rechargement).
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
const isLogoutEndpoint = originalRequest?.url?.includes('/auth/logout');
const isAuthMeEndpoint = originalRequest?.url?.includes('/auth/me');
// INT-AUTH-003: Handle 401 and 400 on /auth/refresh endpoint - token expired/revoked/invalid, logout and redirect
// FIX: Gérer aussi les erreurs 400 (Bad Request) qui indiquent un refresh token invalide
if (
(error.response?.status === 401 || error.response?.status === 400) &&
isRefreshEndpoint
) {
logger.error(
`[API] ${error.response?.status} on /auth/refresh - refresh token expired/revoked/invalid, logging out`,
{
request_id: requestId,
url: originalRequest?.url,
status: error.response?.status,
},
);
// 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();
// Utiliser logoutLocal() au lieu de logout() pour éviter les appels API
// qui déclencheraient à nouveau le refresh
store.logoutLocal();
})
.catch((err: unknown) => {
logger.error('[API] Failed to import auth store for logout', {
error: err,
});
});
// Store error message for display after redirect
sessionStorage.setItem(
'auth_error',
'Votre session a expiré. Veuillez vous reconnecter.',
);
// Redirect to login
window.location.href = '/login';
}
return Promise.reject(parseApiError(error));
}
// INT-AUTH-003: Handle 401 on /auth/logout endpoint - token expired/invalid, just clear locally
// FIX: Si le logout échoue avec 401, on ne doit pas essayer de rafraîchir le token
// car on est déjà en train de se déconnecter
if (error.response?.status === 401 && isLogoutEndpoint) {
logger.warn(
'[API] 401 on /auth/logout - token expired/invalid, clearing tokens locally',
{
request_id: requestId,
url: originalRequest?.url,
},
);
// Clear tokens locally
TokenStorage.clearTokens();
csrfService.clearToken();
// Clear auth store state using logoutLocal to avoid API calls
if (typeof window !== 'undefined') {
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => {
const store = useAuthStore.getState();
store.logoutLocal();
})
.catch((err: unknown) => {
logger.error('[API] Failed to import auth store for logout', {
error: err,
});
});
}
// Don't try to refresh - just reject the error
return Promise.reject(parseApiError(error));
}
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isRefreshEndpoint &&
!isLogoutEndpoint &&
!isAuthMeEndpoint
) {
// INT-AUTH-003: Éviter les refresh multiples simultanés
if (isRefreshing) {
// Si un refresh est en cours, mettre la requête en queue
logger.debug('[API] Refresh already in progress, queuing request', {
request_id: requestId,
url: originalRequest?.url,
queue_size: failedQueue.length,
});
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => {
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
// Backend reads access token from httpOnly cookie automatically
logger.debug(
'[API] Replaying queued request after successful refresh',
{
request_id: requestId,
url: originalRequest?.url,
},
);
return apiClient(originalRequest);
})
.catch((err) => {
const errAny = err as { response?: { status?: number }; code?: number };
const errStatus = errAny?.response?.status ?? errAny?.code;
const errUrl = originalRequest?.url ?? '';
const isWebhooks5xx = errStatus != null && errStatus >= 500 && errUrl.includes('/webhooks');
if (!isWebhooks5xx) {
logger.error('[API] Queued request failed after refresh', {
request_id: requestId,
url: originalRequest?.url,
error: err,
});
}
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
logger.info('[API] Starting token refresh due to 401', {
request_id: requestId,
url: originalRequest?.url,
method: originalRequest?.method,
});
// P1.4: Check if max refresh attempts reached
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
logger.error(
'[API] Max refresh attempts reached, logging out',
{
request_id: requestId,
attempts: refreshAttempts,
max_attempts: MAX_REFRESH_ATTEMPTS,
},
);
// Reset counter
refreshAttempts = 0;
isRefreshing = false;
// Clear tokens and logout
TokenStorage.clearTokens();
csrfService.clearToken();
// Use logoutLocal to avoid API call loop
if (typeof window !== 'undefined') {
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => {
const store = useAuthStore.getState();
store.logoutLocal();
})
.catch((err: unknown) => {
logger.error('[API] Failed to import auth store for logout', {
error: err,
});
});
// Store error message for display after redirect
sessionStorage.setItem(
'auth_error',
'Votre session a expiré après plusieurs tentatives. Veuillez vous reconnecter.',
);
window.location.href = '/login';
}
// Process queue with error
processQueue(new Error('Max refresh attempts reached'));
return Promise.reject(parseApiError(error));
}
// Increment attempt counter
refreshAttempts++;
try {
// INT-AUTH-003: Refresh automatique du token
// SECURITY: Action 5.1.1.3 - Refresh uses cookies, no need to set Authorization header
await refreshToken();
logger.info(
'[API] Token refresh successful, retrying original request',
{
request_id: requestId,
url: originalRequest?.url,
queue_size: failedQueue.length,
attempt: refreshAttempts,
},
);
// P1.4: Reset counter on successful refresh
refreshAttempts = 0;
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
// Backend reads access token from httpOnly cookie automatically
// Cookies are sent automatically via withCredentials: true
// INT-AUTH-003: Traiter la queue et retry la requête originale
// Toutes les requêtes en queue seront rejouées (cookies sont automatiquement envoyés)
processQueue(null);
return apiClient(originalRequest);
} catch (refreshError) {
// INT-AUTH-003: Gérer cas refresh échoué (expiration, révocation, erreur réseau)
logger.error('[API] Token refresh failed', {
attempt: refreshAttempts,
max_attempts: MAX_REFRESH_ATTEMPTS,
request_id: requestId,
error: refreshError,
queue_size: failedQueue.length,
});
// Rejeter toutes les requêtes en queue
processQueue(refreshError as Error);
// Nettoyer les tokens
TokenStorage.clearTokens();
// Clear CSRF token
csrfService.clearToken();
// INT-AUTH-003: Clear auth store state and redirect to login
// FIX: Utiliser logoutLocal() pour éviter les boucles infinies
// (logout -> 401 -> refresh -> 400 -> logout -> ...)
if (typeof window !== 'undefined') {
// Import and use auth store to clear state
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => {
const store = useAuthStore.getState();
// Utiliser logoutLocal() au lieu de logout() pour éviter les appels API
// qui déclencheraient à nouveau le refresh
store.logoutLocal();
})
.catch((err: unknown) => {
logger.error('[API] Failed to import auth store for logout', {
error: err,
});
});
// Stocker un message d'erreur pour l'afficher après redirection
sessionStorage.setItem(
'auth_error',
'Votre session a expiré. Veuillez vous reconnecter.',
);
// Rediriger vers login si refresh échoue (seulement dans le navigateur)
window.location.href = '/login';
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
logger.debug('[API] Token refresh process completed', {
request_id: requestId,
is_refreshing: false,
});
}
}
// 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) {
const errMsg = csrfError instanceof Error ? csrfError.message : String(csrfError);
if (!errMsg.includes('HTML page instead of JSON')) {
logger.error('[API] Failed to refresh CSRF token after CSRF error', {
message: errMsg,
});
}
// Si on ne peut pas récupérer le token, rejeter l'erreur originale
const apiError = parseApiError(error);
return Promise.reject(apiError);
}
}
}
// INT-API-005: 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;
// INT-API-005: For 429 rate limit errors, don't retry - respect the rate limit
const isRateLimitError = status === 429;
// 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
// FIX: Pour les erreurs 500 sur /marketplace/products, désactiver complètement les retries
// car cela peut être dû à une liste vide (problème backend) et les retries sont inutiles
const isMarketplaceProducts = originalRequest?.url?.includes('/marketplace/products');
const is500OnMarketplace = status === 500 && isMarketplaceProducts;
// Si c'est une erreur 500 sur marketplace/products, ne jamais retry
if (is500OnMarketplace) {
logger.warn('[API] 500 error on marketplace/products, not retrying (likely empty state)', {
url: originalRequest?.url,
retry_count: retryCount,
request_id: parseApiError(error).request_id,
});
// Préserver l'erreur Axios originale pour que MarketplaceHome puisse vérifier response.status
// On ajoute le status HTTP à l'erreur parsée pour faciliter la détection
const apiError = parseApiError(error);
// Ajouter le status HTTP à l'erreur pour faciliter la détection côté composant
(apiError as any).httpStatus = status;
return Promise.reject(apiError);
}
// Check if error is retryable
if (
isRetryableError(error, DEFAULT_RETRY_CONFIG) &&
originalRequest &&
retryCount < effectiveMaxRetries
) {
// 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
// (429 rate limit errors are handled above and don't retry)
if (
!isIdempotent &&
status &&
status !== 500 &&
status !== 502 &&
status !== 503 &&
status !== 504
) {
// Don't retry non-idempotent methods on client errors (except 429 and 5xx)
const apiError = parseApiError(error);
return Promise.reject(apiError);
}
// Mark that we're retrying this request
(originalRequest as any)._retryCount = retryCount + 1;
// Calculate delay (exponential backoff with jitter)
const delay = getRetryDelay(
error,
retryCount,
DEFAULT_RETRY_CONFIG.baseDelay,
DEFAULT_RETRY_CONFIG.maxDelay,
);
// Log only first retry to reduce console noise when backend is down
const apiError = parseApiError(error);
const errorType = status
? `HTTP ${status}`
: error.code || 'Network Error';
if (retryCount === 0) {
if (apiError.request_id) {
logger.warn(
`[API Retry] ${errorType} error, retrying (1/${effectiveMaxRetries}) - Request ID: ${apiError.request_id}`,
{
status: status || 'N/A',
error_code: error.code || 'N/A',
retry_count: 1,
max_retries: effectiveMaxRetries,
delay_ms: Math.round(delay),
request_id: apiError.request_id,
url: originalRequest?.url,
method: originalRequest?.method,
is_idempotent: isIdempotent,
},
);
} else {
logger.warn(
`[API Retry] ${errorType} error, retrying (1/${effectiveMaxRetries})`,
{
status: status || 'N/A',
error_code: error.code || 'N/A',
retry_count: 1,
max_retries: effectiveMaxRetries,
delay_ms: Math.round(delay),
url: originalRequest?.url,
method: originalRequest?.method,
is_idempotent: isIdempotent,
},
);
}
}
// Wait before retrying
return sleep(delay).then(() => {
// Retry the request
return apiClient(originalRequest);
});
}
// 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) {
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) {
logger.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 {
logger.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);
// Action 3.2.1.4: Auth errors redirect to login
// isAuthMeEndpoint déjà défini plus haut : on ne redirige pas pour /auth/me (401 = non connecté, pas de redirect)
if (
status === 401 &&
!isRefreshEndpoint &&
!isLogoutEndpoint &&
!isAuthMeEndpoint &&
typeof window !== 'undefined'
) {
const errorCategory = getErrorCategory(apiError);
if (errorCategory === 'authentication') {
TokenStorage.clearTokens();
csrfService.clearToken();
import('@/features/auth/store/authStore')
.then(({ useAuthStore }) => {
const store = useAuthStore.getState();
store.logoutLocal();
})
.catch((err: unknown) => {
logger.error('[API] Failed to import auth store for logout', {
error: err,
});
});
sessionStorage.setItem(
'auth_error',
'Votre session a expiré. Veuillez vous reconnecter.',
);
window.location.href = '/login';
}
}
// FE-COMP-005: Show toast notification for API errors (unless disabled)
// Skip toast for "wrong server" (HTML instead of JSON): already shown in response interceptor
const isWrongServerError = apiError.message?.includes('HTML page instead of JSON') ?? false;
const urlForToast = originalRequest?.url ?? '';
const isWebhooks5xxForToast = status && status >= 500 && urlForToast.includes('/webhooks');
const shouldShowToast =
!(originalRequest as any)?._disableToast &&
status !== 401 && // Don't show toast for 401 (handled by refresh/redirect)
status !== 404 && // Don't show toast for 404 (handled by router)
!axios.isCancel(error) && // Don't show toast for cancelled requests
!isWrongServerError &&
!isWebhooks5xxForToast; // 5xx on webhooks: table may be missing, show empty state instead
// FIX: Implement toast throttling for network errors to prevent spam
const isNetworkError = !error.response;
// Track network errors for offline indicator
if (isNetworkError) {
const { recordNetworkError } = await import(
'@/utils/networkErrorTracker'
);
recordNetworkError(apiError);
}
const toastId = isNetworkError ? 'network-error-toast' : undefined;
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,
);
// FE-API-015: Queue request for offline replay if it's a network error
if (
!error.response &&
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,
id: 'offline-queue-toast', // Prevent duplicate queue toasts
},
);
} catch (queueError) {
logger.error('[API] Failed to queue request for offline replay', {
error: queueError,
});
}
}
}
// Edge 2.1: Distinguish partial vs complete network failures
const isPartialFailure = isPartialNetworkFailure(error);
const isCompleteFailure = isCompleteNetworkFailure(error);
// Use a fixed ID for network errors to prevent stacking
// For network errors, show a more helpful message with suggestions
let enhancedMessage = errorMessage;
if (isNetworkError) {
if (isPartialFailure) {
// Partial failure: intermittent connectivity - some requests succeed, some fail
enhancedMessage = `${errorMessage} ⚠️ Connexion intermittente détectée. Certaines requêtes réussissent, d'autres échouent. La connexion devrait se rétablir automatiquement.`;
} else if (isCompleteFailure) {
// Complete failure: no connection at all
enhancedMessage = `${errorMessage} ❌ Aucune connexion réseau. Vérifiez votre connexion internet et réessayez.`;
} else {
// Generic network error
enhancedMessage = `${errorMessage} 💡 Vérifiez votre connexion internet. Si le problème persiste, le serveur pourrait être temporairement indisponible.`;
}
}
// Edge 2.1: Log partial vs complete failure for monitoring
if (isPartialFailure || isCompleteFailure) {
logger.warn('[API] Network failure detected', {
request_id: requestId,
is_partial_failure: isPartialFailure,
is_complete_failure: isCompleteFailure,
url: originalRequest?.url,
method: originalRequest?.method,
error_code: error.code,
error_message: error.message,
});
}
toast.error(enhancedMessage, {
duration: 8000, // Longer duration for network errors to read suggestions
id: toastId, // Use fixed ID if it's a network error
});
}
// FIX #18, #22: Utiliser logger structuré avec request_id pour corrélation
// 5xx sur /webhooks = erreur backend, ne pas logger (éviter bruit console)
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);
},
);
/**
* 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 '';
}
}
/**
* 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();
* ```
*/
/**
* Edge 2.2: Handle request cancellation
* Creates a cancellable request with AbortController support.
* The request can be cancelled by calling the returned abort() function.
* Cancelled requests are handled gracefully and don't trigger error toasts or retries.
*
* @example
* ```typescript
* const { request, abort } = createCancellableRequest((signal) =>
* apiClient.get('/api/v1/tracks', { signal })
* );
*
* // Later, to cancel:
* abort();
* ```
*/
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).catch((error) => {
// Edge 2.2: Ensure cancelled requests are handled gracefully
// If the request was cancelled, the error will be handled by the error interceptor
// which checks axios.isCancel(error) and doesn't retry or show toasts
if (axios.isCancel(error) || error.name === 'AbortError' || signal.aborted) {
// Re-throw the cancellation error - it will be handled by the error interceptor
throw error;
}
// Re-throw other errors
throw error;
});
return {
request,
abort: () => {
// Edge 2.2: Abort the request if not already aborted
if (!signal.aborted) {
abortController.abort();
}
},
};
}
/**
* Edge 2.2: Handle request cancellation with timeout
* Creates a request with automatic timeout cancellation.
* The request will be automatically cancelled if it exceeds the timeout duration.
* Can also be manually cancelled by calling the returned abort() function.
*
* @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;
// Edge 2.2: Set up timeout that automatically cancels the request
const timeoutId = setTimeout(() => {
if (!signal.aborted) {
abortController.abort();
}
}, timeoutMs);
const request = requestFn(signal)
.catch((error) => {
// Edge 2.2: Ensure cancelled requests (timeout or manual) are handled gracefully
// The error interceptor will handle cancellation errors properly
if (axios.isCancel(error) || error.name === 'AbortError' || signal.aborted) {
// Re-throw the cancellation error - it will be handled by the error interceptor
throw error;
}
throw error;
})
.finally(() => {
// Clear timeout if request completes before timeout
clearTimeout(timeoutId);
});
return {
request,
abort: () => {
clearTimeout(timeoutId);
// Edge 2.2: Abort the request if not already aborted
if (!signal.aborted) {
abortController.abort();
}
},
};
}
/**
* FE-API-016: Enhanced API client methods with automatic deduplication
* FE-API-017: Enhanced with response caching for GET requests
* These methods automatically deduplicate identical concurrent requests and cache GET responses
*
* @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)
*
* // Cached responses are returned immediately
* const response1 = await deduplicatedApiClient.get('/tracks');
* const response2 = await deduplicatedApiClient.get('/tracks'); // Returns from cache
* ```
*/
export const deduplicatedApiClient = {
get: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
// 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>);
}
}
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),
);
},
};
/**
* Edge 2.3: Utility function to check if a request is slow
* Can be used by components to show additional loading indicators for slow requests
*
* @param config - Axios request config (from response.config or request config)
* @returns true if the request is considered slow, false otherwise
*
* @example
* ```typescript
* try {
* const response = await apiClient.get('/api/v1/tracks');
* if (isSlowRequest(response.config)) {
* // Show additional loading feedback
* }
* } catch (error) {
* // Handle error
* }
* ```
*/
export function isSlowRequest(
config?: InternalAxiosRequestConfig,
): boolean {
if (!config) return false;
return (config as any)?._isSlowRequest === true;
}
/**
* Edge 2.3: Utility function to get request duration
* Returns the duration of a request in milliseconds
*
* @param config - Axios request config (from response.config)
* @returns Request duration in milliseconds, or undefined if not available
*
* @example
* ```typescript
* const response = await apiClient.get('/api/v1/tracks');
* const duration = getRequestDuration(response.config);
* if (duration && duration > 2000) {
* console.log(`Request took ${duration}ms`);
* }
* ```
*/
export function getRequestDuration(
config?: InternalAxiosRequestConfig,
): number | undefined {
if (!config) return undefined;
return (config as any)?._requestDuration;
}