veza/apps/web/src/services/api/interceptors/error.ts
senke 4b57b46bac feat: frontend improvements — UI polish, player bar, auth flow, i18n
- 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>
2026-03-18 11:35:44 +01:00

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);
};
}