399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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<string, AbortController>();
|
|
|
|
const retryRequest = async (
|
|
error: AxiosError,
|
|
retryCount: number = 0
|
|
): Promise<any> => {
|
|
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;
|