import axios from 'axios'; import Config from '../../config'; import { tokenStorage } from '../storage'; // Fix: CONC-L2 — carries HTTP status + response body so screens can branch on // optimistic-concurrency conflicts (409 { message, currentState }). export class ApiError extends Error { constructor( message: string, public status: number, public data?: unknown, ) { super(message); this.name = 'ApiError'; } } type RefreshFn = () => Promise; let _refreshTokens: RefreshFn | null = null; export function registerTokenRefresh(fn: RefreshFn) { _refreshTokens = fn; } type SessionExpiredListener = () => void; const sessionExpiredListeners: SessionExpiredListener[] = []; export function onSessionExpired(listener: SessionExpiredListener): () => void { sessionExpiredListeners.push(listener); return () => { const idx = sessionExpiredListeners.indexOf(listener); if (idx >= 0) sessionExpiredListeners.splice(idx, 1); }; } function emitSessionExpired() { sessionExpiredListeners.forEach((fn) => fn()); } let isRefreshing = false; let refreshSubscribers: Array<(token: string) => void> = []; function subscribeToRefresh(cb: (token: string) => void) { refreshSubscribers.push(cb); } function onRefreshComplete(token: string) { refreshSubscribers.forEach((cb) => cb(token)); refreshSubscribers = []; } const apiClient = axios.create({ baseURL: Config.API_URL, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }); apiClient.interceptors.request.use( async (config) => { const tokens = await tokenStorage.get(); if (tokens?.accessToken) { config.headers.Authorization = `Bearer ${tokens.accessToken}`; } return config; }, (error) => Promise.reject(error), ); apiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response) { const { status, data, config: originalRequest } = error.response; if (status === 401 && !originalRequest._retry) { originalRequest._retry = true; if (isRefreshing) { return new Promise((resolve) => { subscribeToRefresh((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; resolve(apiClient(originalRequest)); }); }); } isRefreshing = true; try { if (!_refreshTokens) throw new Error('No refresh handler'); await _refreshTokens(); const tokens = await tokenStorage.get(); const newToken = tokens?.accessToken ?? ''; onRefreshComplete(newToken); originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient(originalRequest); } catch { await tokenStorage.clear(); emitSessionExpired(); return Promise.reject( new Error('Session expired. Please log in again.'), ); } finally { isRefreshing = false; } } if (status === 403) { return Promise.reject( new Error('You do not have permission to perform this action.'), ); } if (status === 404) { return Promise.reject( new Error('The requested resource was not found.'), ); } const message = data?.detail || data?.title || data?.message || 'An error occurred'; return Promise.reject(new ApiError(message, status, data)); } if (error.request) { return Promise.reject( new Error('No response from server. Please check your connection.'), ); } return Promise.reject(error); }, ); export default apiClient;