import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '@/store/authStore' import { toast } from '@/lib/toast' const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' export const apiClient = axios.create({ baseURL: `${API_BASE_URL}/api/v1`, headers: { 'Content-Type': 'application/json', }, }) // Global error handler for shared cases only. // By convention, 4xx errors are handled at the page/component level. function handleGlobalError(error: AxiosError) { // Network error (no response from server) if (!error.response) { if (error.code === 'ECONNABORTED') { toast.error('Request timeout - please try again') } else { toast.error('Network error - please check your connection') } return } const status = error.response.status const data = error.response.data as { detail?: string | unknown[] } // Extract a displayable error message from the response. // FastAPI returns `detail` as a string for most errors, but 422 validation // errors return an array of objects — we must not pass those to toast. const detail = typeof data?.detail === 'string' ? data.detail : undefined // Don't show toast for 401 (handled by refresh interceptor) if (status === 401) { return } // Rate limit — always worth notifying if (status === 429) { toast.error(detail || 'Too many requests — please try again shortly') return } // Client errors (4xx) remain page-owned to avoid duplicate/noisy toasts. // Global handling only covers 401/429/5xx. if (status >= 400 && status < 500) { return } // Server errors (5xx) if (status >= 500) { toast.error('Server error - please try again later') return } } // Request interceptor - add auth token apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = localStorage.getItem('access_token') if (token && config.headers) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) // Refresh queue: when multiple requests hit 401 simultaneously, // only the first triggers the actual refresh. Others wait for the result. let isRefreshing = false let refreshSubscribers: ((token: string) => void)[] = [] let refreshFailSubscribers: ((error: unknown) => void)[] = [] function onRefreshed(token: string) { refreshSubscribers.forEach(cb => cb(token)) refreshSubscribers = [] refreshFailSubscribers = [] } function onRefreshFailed(error: unknown) { refreshFailSubscribers.forEach(cb => cb(error)) refreshSubscribers = [] refreshFailSubscribers = [] } // Response interceptor - handle token refresh apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } // Only handle 401s that haven't already been retried if (error.response?.status !== 401 || originalRequest._retry) { // Show global error toast for non-401 errors // Pages can still catch errors explicitly for custom handling handleGlobalError(error) return Promise.reject(error) } originalRequest._retry = true const refreshToken = localStorage.getItem('refresh_token') if (!refreshToken) { return Promise.reject(error) } // If a refresh is already in progress, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { refreshSubscribers.push((newToken: string) => { if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken}` } resolve(apiClient(originalRequest)) }) refreshFailSubscribers.push((refreshError: unknown) => { reject(refreshError) }) }) } // This is the first 401 — perform the refresh isRefreshing = true try { const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, { headers: { Authorization: `Bearer ${refreshToken}`, }, }) const { access_token, refresh_token } = response.data localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) // Sync Zustand auth store useAuthStore.getState().setTokens({ access_token, refresh_token, token_type: 'bearer', }) isRefreshing = false onRefreshed(access_token) // Retry original request with new token if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${access_token}` } return apiClient(originalRequest) } catch (refreshError) { isRefreshing = false onRefreshFailed(refreshError) // Refresh failed — clear tokens and redirect to login localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') useAuthStore.getState().logout() window.location.href = '/login' return Promise.reject(refreshError) } } ) export default apiClient