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