import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; // Base URL from environment or default 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'; // Retry configuration const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; // Create axios instance with enhanced configuration const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, timeout: 30000, // 30 seconds timeout withCredentials: true, // Enable sending cookies }); // Retry logic helper const retryRequest = async ( error: AxiosError, retryCount: number = 0 ): Promise => { const config = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; // Don't retry if already retried or not a retryable error if ( config._retry || retryCount >= MAX_RETRIES || !error.config || !error.response || !RETRYABLE_STATUS_CODES.includes(error.response.status) ) { return Promise.reject(error); } config._retry = true; // Exponential backoff 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); }; // Request interceptor - Add token and request ID apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Normalize request URL if (config.url && typeof config.url === 'string') { if (config.url.startsWith('/api/')) { config.url = config.url.replace(/^\/api/, ''); } config.url = config.url.replace(/\/\/+/, '/'); } // Add authorization token const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // Generate and add request ID for tracking const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; config.headers['X-Request-ID'] = requestId; // Add timestamp for request tracking (config as any).metadata = { startTime: new Date() }; return config; }, (error: AxiosError) => { return Promise.reject(error); } ); // Response interceptor - Handle errors with retry logic apiClient.interceptors.response.use( (response) => { // Log request duration const config = response.config as InternalAxiosRequestConfig & { metadata?: any }; if (config.metadata?.startTime) { const duration = new Date().getTime() - config.metadata.startTime.getTime(); if (duration > 1000) { // Log slow requests (>1s) console.warn(`Slow request detected: ${config.url} took ${duration}ms`); } } // Extract request ID from response headers for debugging 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 }; // Handle network errors (no response) if (!error.response) { if (error.code === 'ECONNABORTED') { return Promise.reject({ ...error, message: 'Request timeout. Please check your connection and try again.', }); } // Retry network errors 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']; // Handle 401 Unauthorized if (status === 401) { localStorage.removeItem('token'); localStorage.removeItem('userInfo'); // Don't redirect if already on login page if (!window.location.pathname.includes('/login')) { window.location.href = '/login'; } const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.'; return Promise.reject({ ...error, message: errorMessage, }); } // Handle 403 Forbidden if (status === 403) { const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.'; return Promise.reject({ ...error, message: errorMessage, }); } // Handle 404 Not Found if (status === 404) { const errorMessage = (error.response?.data as any)?.message || 'Resource not found.'; return Promise.reject({ ...error, message: errorMessage, }); } // Handle 429 Too Many Requests (rate limiting) 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 5xx server errors with retry 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, }); } // Handle validation errors (400) 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 || [], }); } // For other errors, extract message from response const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred'; return Promise.reject({ ...error, message: errorMessage, requestId, }); } ); export default apiClient;