import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import useAuthStore from '../../store/useAuthStore'; const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:8000'; const normalized = String(rawBase).replace(/\/$/, ''); const API_BASE_URL = /\/api(\/?$)/i.test(normalized) ? normalized : normalized + '/api'; // Validate HTTPS in production if (import.meta.env.MODE === 'production' && !normalized.startsWith('https://')) { console.error( '⚠️ SECURITY WARNING: API URL is not using HTTPS in production!', 'This exposes sensitive data to interception.', 'Please configure VITE_API_URL to use HTTPS.' ); // Only show warning, don't block - some deployments may use reverse proxy // But log the security concern if (typeof window !== 'undefined') { console.warn('API calls will be made over HTTP. Consider using HTTPS.'); } } const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // Note: 503 is excluded because it's used for "service unavailable" (like disabled features) // and should not be retried - it's an intentional state, not a transient error const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 504]; // Helper function to get CSRF token from cookies const getCsrfToken = (): string | null => { if (typeof document === 'undefined') { return null; } // CSRF token cookie name (matches backend XSRF-TOKEN) const cookies = document.cookie.split(';'); for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); if (name === 'XSRF-TOKEN') { return decodeURIComponent(value); } } return null; }; const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, timeout: 30000, withCredentials: true, }); // Map to store AbortControllers for cancelable requests const cancelTokenMap = new Map(); const retryRequest = async ( error: AxiosError, retryCount: number = 0 ): Promise => { const config = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; if ( config._retry || retryCount >= MAX_RETRIES || !error.config || !error.response || !RETRYABLE_STATUS_CODES.includes(error.response.status) ) { return Promise.reject(error); } config._retry = true; const delay = RETRY_DELAY * Math.pow(2, retryCount); await new Promise(resolve => setTimeout(resolve, delay)); console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}): ${config.url}`); return apiClient.request(config); }; apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { if (config.url && typeof config.url === 'string') { if (config.url.startsWith('/api/')) { config.url = config.url.replace(/^\/api/, ''); } config.url = config.url.replace(/\/\/+/, '/'); } if (config.data instanceof FormData) { delete config.headers['Content-Type']; } const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // Add CSRF token for state-changing requests (POST, PUT, DELETE, PATCH) // Skip for GET, HEAD, OPTIONS (safe methods) const method = (config.method || 'GET').toUpperCase(); if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { // Skip CSRF token for auth endpoints (they're exempt) const path = config.url || ''; const isAuthEndpoint = path.startsWith('/auth/') || path.startsWith('/api/auth/'); if (!isAuthEndpoint) { const csrfToken = getCsrfToken(); if (csrfToken) { config.headers['X-XSRF-TOKEN'] = csrfToken; } else { // CSRF token not found - this will be handled in error interceptor // The backend will return 403 and we'll retry after fetching the token if (import.meta.env.DEV) { console.warn('CSRF token not found in cookies. Request may fail and retry after fetching token.'); } } } } const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; config.headers['X-Request-ID'] = requestId; // Create AbortController for request cancellation // Skip if signal is already provided if (!config.signal) { const abortController = new AbortController(); config.signal = abortController.signal; // Store controller for potential cancellation const cancelKey = `${config.method || 'GET'}_${config.url}_${requestId}`; cancelTokenMap.set(cancelKey, abortController); // Clean up after request completes (set timeout to avoid memory leaks) setTimeout(() => { cancelTokenMap.delete(cancelKey); }, 60000); // Clean up after 60 seconds } (config as any).metadata = { startTime: new Date(), requestId }; return config; }, (error: AxiosError) => { return Promise.reject(error); } ); apiClient.interceptors.response.use( (response) => { const config = response.config as InternalAxiosRequestConfig & { metadata?: any }; // Clean up abort controller on successful response if (config.metadata?.requestId && config.url) { const cancelKey = `${config.method || 'GET'}_${config.url}_${config.metadata.requestId}`; cancelTokenMap.delete(cancelKey); } if (config.metadata?.startTime) { const duration = new Date().getTime() - config.metadata.startTime.getTime(); if (duration > 1000) { console.warn(`Slow request detected: ${config.url} took ${duration}ms`); } } const requestId = response.headers['x-request-id']; if (requestId && import.meta.env.DEV) { console.debug(`Request completed: ${config.url} [${requestId}]`); } return response; }, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; // Clean up abort controller on error if (originalRequest && (originalRequest as any).metadata?.requestId && originalRequest.url) { const cancelKey = `${originalRequest.method || 'GET'}_${originalRequest.url}_${(originalRequest as any).metadata.requestId}`; cancelTokenMap.delete(cancelKey); } // Don't process cancellation errors if (error.code === 'ERR_CANCELED' || error.message === 'canceled') { return Promise.reject({ ...error, message: 'Request was cancelled', isCancelled: true, }); } if (!error.response) { if (error.code === 'ECONNABORTED') { return Promise.reject({ ...error, message: 'Request timeout. Please check your connection and try again.', }); } if (originalRequest && !originalRequest._retry) { return retryRequest(error); } console.error('Network error:', error); return Promise.reject({ ...error, message: 'Network error. Please check your internet connection.', }); } const status = error.response.status; const requestId = error.response.headers['x-request-id']; if (status === 401) { localStorage.removeItem('token'); localStorage.removeItem('userInfo'); // Update auth store state useAuthStore.getState().logout().catch(() => { // Ignore logout errors, just clear the state useAuthStore.setState({ token: null, userInfo: null, isAuthenticated: false, isLoading: false, }); }); // Dispatch custom event to trigger login modal window.dispatchEvent(new CustomEvent('auth:logout', { detail: { message: 'Session expired. Please login again.' } })); const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.'; return Promise.reject({ ...error, message: errorMessage, }); } if (status === 403) { const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.'; // Handle CSRF token missing/invalid errors - retry after getting token from error response if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) { // The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it await new Promise(resolve => setTimeout(resolve, 200)); // Check if we now have the CSRF token const csrfToken = getCsrfToken(); if (csrfToken && originalRequest.headers) { // Retry the original request with the CSRF token originalRequest._retry = true; originalRequest.headers['X-XSRF-TOKEN'] = csrfToken; return apiClient.request(originalRequest); } else { // If still no token, try fetching it via GET request try { await apiClient.get('/health', { headers: { ...originalRequest.headers }, timeout: 5000 }); await new Promise(resolve => setTimeout(resolve, 100)); const newToken = getCsrfToken(); if (newToken && originalRequest.headers) { originalRequest._retry = true; originalRequest.headers['X-XSRF-TOKEN'] = newToken; return apiClient.request(originalRequest); } } catch (e) { // Ignore errors from token fetch attempts if (import.meta.env.DEV) { console.warn('Failed to fetch CSRF token:', e); } } } } return Promise.reject({ ...error, message: errorMessage, }); } if (status === 404) { const errorMessage = (error.response?.data as any)?.message || 'Resource not found.'; return Promise.reject({ ...error, message: errorMessage, }); } if (status === 429) { const retryAfter = error.response.headers['retry-after']; return Promise.reject({ ...error, message: `Too many requests. ${retryAfter ? `Please try again after ${retryAfter} seconds.` : 'Please try again later.'}`, retryAfter: retryAfter ? parseInt(retryAfter) : undefined, }); } // Handle 503 (Service Unavailable) separately - often used for disabled features // Don't retry these as they're intentional states, not transient errors if (status === 503) { const errorData = error.response.data as any; const errorMessage = errorData?.detail || errorData?.message || 'Service temporarily unavailable'; return Promise.reject({ ...error, message: errorMessage, requestId, }); } if (status >= 500 && status < 600) { if (originalRequest && !originalRequest._retry) { return retryRequest(error); } console.error(`Server error [${requestId || 'unknown'}]:`, error); const errorMessage = (error.response?.data as any)?.message || 'Server error. Please try again later.'; return Promise.reject({ ...error, message: errorMessage, requestId, }); } if (status === 400) { const errorData = error.response.data as any; return Promise.reject({ ...error, message: errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.', errors: errorData?.errors || [], }); } const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred'; return Promise.reject({ ...error, message: errorMessage, requestId, }); } ); /** * Cancel all pending requests (useful for cleanup on unmount) */ export const cancelAllPendingRequests = (): void => { cancelTokenMap.forEach((controller) => { controller.abort(); }); cancelTokenMap.clear(); }; /** * Cancel requests matching a pattern (useful for canceling specific endpoint requests) */ export const cancelRequestsByPattern = (pattern: string | RegExp): void => { const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern; const keysToCancel: string[] = []; cancelTokenMap.forEach((controller, key) => { if (regex.test(key)) { controller.abort(); keysToCancel.push(key); } }); keysToCancel.forEach((key) => cancelTokenMap.delete(key)); }; export default apiClient;