import axios from 'axios'; const sanitizeUrlForLogging = (url: string | undefined): string => { if (!url) return ''; try { const [base, query] = url.split('?'); if (!query) return url; const params = new URLSearchParams(query); if (params.has('token')) { params.set('token', '***'); } const queryString = params.toString(); return queryString ? `${base}?${queryString}` : base; } catch { return url; } }; // Global subscription error handler - will be set by the app // Can be async to support subscription status refresh let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise) | null = null; export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean | Promise) => { globalSubscriptionErrorHandler = handler; }; // Export a function to trigger subscription error handler from outside axios interceptors export const triggerSubscriptionError = async (error: any) => { const status = error?.response?.status; console.log('triggerSubscriptionError: Received error', { hasHandler: !!globalSubscriptionErrorHandler, status, dataKeys: error?.response?.data ? Object.keys(error.response.data) : null }); if (globalSubscriptionErrorHandler) { console.log('triggerSubscriptionError: Calling global subscription error handler'); const result = globalSubscriptionErrorHandler(error); // Handle both sync and async handlers return result instanceof Promise ? await result : result; } console.warn('triggerSubscriptionError: No global subscription error handler registered'); return false; }; // Optional token getter installed from within the app after Clerk is available let authTokenGetter: (() => Promise) | null = null; // Optional Clerk sign-out function - set by App.tsx when Clerk is available let clerkSignOut: (() => Promise) | null = null; export const setClerkSignOut = (signOutFn: () => Promise) => { clerkSignOut = signOutFn; }; export const setAuthTokenGetter = (getter: () => Promise) => { authTokenGetter = getter; }; export const getAuthTokenGetter = (): (() => Promise) | null => { return authTokenGetter; }; // Get API URL from environment variables export const getApiUrl = () => { const apiUrl = process.env.REACT_APP_API_URL; const isProduction = process.env.NODE_ENV === 'production'; // In production, require REACT_APP_API_URL to be set if (isProduction && !apiUrl) { console.error('[apiClient] ❌ REACT_APP_API_URL is not set for production! Please configure in Vercel environment variables.'); throw new Error('REACT_APP_API_URL environment variable is required for production. Please set it in your Vercel project settings.'); } if (isProduction) { return apiUrl; } // In development, use localhost by default const envUrl = process.env.REACT_APP_API_URL; const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost'; const isNgrok = envUrl && envUrl.includes('ngrok'); if (isLocalhost) { if (isNgrok) { console.warn('[apiClient] ⚠️ Overriding ngrok API URL in dev; using http://localhost:8000 to avoid CORS.'); } return 'http://localhost:8000'; } // Non-localhost dev (rare): use env if provided, otherwise localhost return envUrl || 'http://localhost:8000'; }; // Create a shared axios instance for all API calls const apiBaseUrl = getApiUrl(); export const apiClient = axios.create({ baseURL: apiBaseUrl, timeout: 60000, // Increased to 60 seconds for regular API calls headers: { 'Content-Type': 'application/json', }, }); // Create a specialized client for AI operations with extended timeout export const aiApiClient = axios.create({ baseURL: apiBaseUrl, timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses) headers: { 'Content-Type': 'application/json', }, }); // Create a specialized client for long-running operations like SEO analysis export const longRunningApiClient = axios.create({ baseURL: apiBaseUrl, timeout: 300000, // 5 minutes timeout for SEO analysis headers: { 'Content-Type': 'application/json', }, }); // Create a specialized client for polling operations with reasonable timeout export const pollingApiClient = axios.create({ baseURL: apiBaseUrl, timeout: 60000, // 60 seconds timeout for polling status checks headers: { 'Content-Type': 'application/json', }, }); // Backend availability circuit-breaker to prevent runaway polling loops. let backendFailureCount = 0; let backendUnavailableUntil = 0; const BACKEND_COOLDOWN_BASE_MS = 5000; const BACKEND_COOLDOWN_MAX_MS = 60000; const cooldownSkipLoggedBySource = new Map(); const isBackendTemporarilyUnavailable = () => Date.now() < backendUnavailableUntil; const openBackendCooldown = (reason: string) => { backendFailureCount = Math.min(6, backendFailureCount + 1); const cooldownMs = Math.min( BACKEND_COOLDOWN_MAX_MS, BACKEND_COOLDOWN_BASE_MS * (2 ** (backendFailureCount - 1)) ); backendUnavailableUntil = Date.now() + cooldownMs; console.warn( `[apiClient] Backend unavailable (${reason}). Cooling down requests for ${Math.ceil(cooldownMs / 1000)}s.` ); }; const clearBackendCooldown = () => { if (backendFailureCount > 0 || backendUnavailableUntil > 0) { console.info('[apiClient] Backend connectivity restored. Clearing cooldown state.'); } backendFailureCount = 0; backendUnavailableUntil = 0; cooldownSkipLoggedBySource.clear(); }; const buildCooldownError = () => { const secondsRemaining = Math.max(1, Math.ceil((backendUnavailableUntil - Date.now()) / 1000)); return new Error( `Backend is temporarily unavailable. Retrying in ${secondsRemaining}s to avoid request storms.` ); }; export const isBackendCooldownActive = (): boolean => isBackendTemporarilyUnavailable(); export const getBackendCooldownSecondsRemaining = (): number => { if (!isBackendTemporarilyUnavailable()) { return 0; } return Math.max(1, Math.ceil((backendUnavailableUntil - Date.now()) / 1000)); }; export const logBackendCooldownSkipOnce = (source: string): void => { if (!isBackendTemporarilyUnavailable()) { return; } const lastLoggedWindow = cooldownSkipLoggedBySource.get(source); if (lastLoggedWindow === backendUnavailableUntil) { return; } cooldownSkipLoggedBySource.set(source, backendUnavailableUntil); const secondsRemaining = getBackendCooldownSecondsRemaining(); console.debug( `[${source}] Skipping request while backend cooldown is active (${secondsRemaining}s remaining).` ); }; export const noteBackendUnavailable = (reason: string): void => { openBackendCooldown(reason || 'external_network_error'); }; export const noteBackendRecovered = (): void => { clearBackendCooldown(); }; // Add request interceptor for logging and authentication apiClient.interceptors.request.use( async (config) => { const safeUrl = sanitizeUrlForLogging(config.url); console.log(`Making ${config.method?.toUpperCase()} request to ${safeUrl}`); if (isBackendTemporarilyUnavailable()) { return Promise.reject(buildCooldownError()); } try { if (!authTokenGetter) { // If authTokenGetter is not set, reject the request to prevent 401 errors // This usually means TokenInstaller hasn't run yet or Clerk isn't ready console.error(`[apiClient] ❌ authTokenGetter not set for ${config.url} - rejecting request`); console.error(`[apiClient] This usually means TokenInstaller hasn't run yet. Please wait for authentication to initialize.`); return Promise.reject(new Error('Authentication not ready. Please wait for sign-in to complete.')); } try { const token = await authTokenGetter(); if (token) { config.headers = config.headers || {}; (config.headers as any)['Authorization'] = `Bearer ${token}`; const safeUrlWithToken = sanitizeUrlForLogging(config.url); console.log(`[apiClient] ✅ Auth token attached for request to ${safeUrlWithToken}`); } else { // Token getter returned null - reject request to prevent 401 errors // ProtectedRoute should ensure user is authenticated before components render console.error(`[apiClient] ❌ authTokenGetter returned null for ${config.url} - rejecting request`); console.error(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`); // Redirect if on protected route to force re-auth const isRootRoute = window.location.pathname === '/'; if (!isRootRoute) { console.warn('[apiClient] Redirecting to login due to missing auth token'); try { window.location.assign('/'); } catch {} } return Promise.reject(new Error('Authentication token not available. Please sign in to continue.')); } } catch (tokenError) { console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError); // Reject request if token getter throws an error return Promise.reject(new Error('Failed to get authentication token. Please try signing in again.')); } } catch (e) { console.error(`[apiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e); return Promise.reject(e); } return config; }, (error) => { return Promise.reject(error); } ); // Custom error types for better error handling export class ConnectionError extends Error { constructor(message: string) { super(message); this.name = 'ConnectionError'; } } export class NetworkError extends Error { constructor(message: string) { super(message); this.name = 'NetworkError'; } } // Add response interceptor with automatic token refresh on 401 apiClient.interceptors.response.use( (response) => { clearBackendCooldown(); return response; }, async (error) => { const originalRequest = error.config; // Handle network errors and timeouts (backend not available) if (!error.response) { // Network error, timeout, or backend not reachable openBackendCooldown(error?.message || 'network_error'); const connectionError = new NetworkError( 'Unable to connect to the backend server. Please check if the server is running.' ); console.error('Network/Connection Error:', error.message || error); return Promise.reject(connectionError); } // Handle server errors (5xx) if (error.response.status >= 500) { openBackendCooldown(`http_${error.response.status}`); const connectionError = new ConnectionError( 'Backend server is experiencing issues. Please try again later.' ); console.error('Server Error:', error.response.status, error.response.data); return Promise.reject(connectionError); } // If 401 and we haven't retried yet, try to refresh token and retry if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) { originalRequest._retry = true; try { // Get fresh token const newToken = await authTokenGetter(); if (newToken) { // Update the request with new token originalRequest.headers['Authorization'] = `Bearer ${newToken}`; // Retry the request return apiClient(originalRequest); } } catch (retryError) { console.error('Token refresh failed:', retryError); } // If retry failed, token is expired - sign out user and redirect to sign in const isRootRoute = window.location.pathname === '/'; const isContentPlanningRoute = window.location.pathname.includes('/content-planning'); // Don't redirect from root route or content-planning during app initialization // ProtectedRoute should handle authentication state if (!isRootRoute && !isContentPlanningRoute) { // Token expired - sign out user and redirect to landing/sign-in console.warn('401 Unauthorized - token expired, signing out user'); // Clear any cached auth data localStorage.removeItem('user_id'); localStorage.removeItem('auth_token'); // Use Clerk signOut if available, otherwise just redirect if (clerkSignOut) { clerkSignOut() .then(() => { // Redirect to landing page after sign out window.location.assign('/'); }) .catch((err) => { console.error('Error during Clerk sign out:', err); // Fallback: redirect anyway window.location.assign('/'); }); } else { // Fallback: redirect to landing (will show sign-in if Clerk handles it) window.location.assign('/'); } } else if (isContentPlanningRoute) { // For content-planning, just log the error - ProtectedRoute will handle redirect if needed console.warn('401 Unauthorized for content-planning route - ProtectedRoute should handle this'); } else { console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)'); } } // Handle 401 errors that weren't retried (e.g., no authTokenGetter, already retried, etc.) if (error?.response?.status === 401 && (originalRequest._retry || !authTokenGetter)) { const isRootRoute = window.location.pathname === '/'; const isContentPlanningRoute = window.location.pathname.includes('/content-planning'); // Don't redirect for content-planning during initial load - let ProtectedRoute handle it // This prevents redirect loops when requests are made before auth is fully ready if (!isRootRoute && !isContentPlanningRoute) { // Token expired - sign out user and redirect console.warn('401 Unauthorized - token expired (not retried), signing out user'); localStorage.removeItem('user_id'); localStorage.removeItem('auth_token'); if (clerkSignOut) { clerkSignOut() .then(() => window.location.assign('/')) .catch(() => window.location.assign('/')); } else { window.location.assign('/'); } } else if (isContentPlanningRoute) { // For content-planning, just log the error - ProtectedRoute will handle redirect if needed console.warn('401 Unauthorized for content-planning route - ProtectedRoute should handle this'); } } // Check if it's a subscription-related error and handle it globally if (error.response?.status === 429 || error.response?.status === 402) { console.log('API Client: Detected subscription error, triggering global handler'); if (globalSubscriptionErrorHandler) { const result = globalSubscriptionErrorHandler(error); const wasHandled = result instanceof Promise ? await result : result; if (wasHandled) { console.log('API Client: Subscription error handled by global handler'); return Promise.reject(error); } } } console.error('API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } ); // Add interceptors for AI client aiApiClient.interceptors.request.use( async (config) => { const safeUrl = sanitizeUrlForLogging(config.url); // Reduced logging frequency - only log in development or for errors if (process.env.NODE_ENV === 'development') { console.log(`Making AI ${config.method?.toUpperCase()} request to ${safeUrl}`); } if (isBackendTemporarilyUnavailable()) { return Promise.reject(buildCooldownError()); } try { if (!authTokenGetter) { console.warn(`[aiApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`); } else { try { const token = await authTokenGetter(); if (token) { config.headers = config.headers || {}; (config.headers as any)['Authorization'] = `Bearer ${token}`; // Only log auth token attachment in development for debugging if (process.env.NODE_ENV === 'development') { const safeUrlWithToken = sanitizeUrlForLogging(config.url); console.log(`[aiApiClient] ✅ Auth token attached for request to ${safeUrlWithToken}`); } } else { console.warn(`[aiApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`); } } catch (tokenError) { console.error(`[aiApiClient] ❌ Error getting auth token for ${config.url}:`, tokenError); } } } catch (e) { console.error(`[aiApiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e); } return config; }, (error) => { return Promise.reject(error); } ); aiApiClient.interceptors.response.use( (response) => { clearBackendCooldown(); return response; }, async (error) => { const originalRequest = error.config; if (!error.response) { openBackendCooldown(error?.message || 'network_error'); return Promise.reject( new NetworkError('Unable to connect to the backend server. Please check if the server is running.') ); } if (error.response.status >= 500) { openBackendCooldown(`http_${error.response.status}`); return Promise.reject( new ConnectionError('Backend server is experiencing issues. Please try again later.') ); } // If 401 and we haven't retried yet, try to refresh token and retry if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) { originalRequest._retry = true; try { const newToken = await authTokenGetter(); if (newToken) { originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return aiApiClient(originalRequest); } } catch (retryError) { console.error('Token refresh failed:', retryError); } const isRootRoute = window.location.pathname === '/'; // Don't redirect from root route during app initialization if (!isRootRoute) { // Token expired - sign out user and redirect console.warn('401 Unauthorized - token expired, signing out user'); localStorage.removeItem('user_id'); localStorage.removeItem('auth_token'); if (clerkSignOut) { clerkSignOut() .then(() => window.location.assign('/')) .catch(() => window.location.assign('/')); } else { window.location.assign('/'); } } else { console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)'); } } // Check if it's a subscription-related error and handle it globally if (error.response?.status === 429 || error.response?.status === 402) { console.log('AI API Client: Detected subscription error, triggering global handler'); if (globalSubscriptionErrorHandler) { const result = globalSubscriptionErrorHandler(error); const wasHandled = result instanceof Promise ? await result : result; if (wasHandled) { console.log('AI API Client: Subscription error handled by global handler'); return Promise.reject(error); } } } console.error('AI API Error:', error.response?.status, error.response?.data); return Promise.reject(error); } ); // Add interceptors for long-running client longRunningApiClient.interceptors.request.use( async (config) => { console.log(`Making long-running ${config.method?.toUpperCase()} request to ${config.url}`); if (isBackendTemporarilyUnavailable()) { return Promise.reject(buildCooldownError()); } try { if (!authTokenGetter) { console.warn(`[longRunningApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`); } else { try { const token = await authTokenGetter(); if (token) { config.headers = config.headers || {}; (config.headers as any)['Authorization'] = `Bearer ${token}`; } else { console.warn(`[longRunningApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`); // Redirect if on protected route to force re-auth const isRootRoute = window.location.pathname === '/'; if (!isRootRoute) { console.warn('[longRunningApiClient] Redirecting to login due to missing auth token'); try { window.location.assign('/'); } catch {} } return Promise.reject(new Error('Authentication token not available. Please sign in or reload the page.')); } } catch (tokenError) { console.error(`[longRunningApiClient] ❌ Error getting auth token for ${config.url}:`, tokenError); return Promise.reject(new Error('Failed to get authentication token.')); } } } catch (e) { console.error(`[longRunningApiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e); return Promise.reject(e); } return config; }, (error) => { return Promise.reject(error); } ); longRunningApiClient.interceptors.response.use( (response) => { clearBackendCooldown(); return response; }, async (error) => { const originalRequest = error.config; if (!error.response) { openBackendCooldown(error?.message || 'network_error'); return Promise.reject( new NetworkError('Unable to connect to the backend server. Please check if the server is running.') ); } if (error.response.status >= 500) { openBackendCooldown(`http_${error.response.status}`); return Promise.reject( new ConnectionError('Backend server is experiencing issues. Please try again later.') ); } // If 401 and we haven't retried yet, try to refresh token and retry if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) { originalRequest._retry = true; try { const newToken = await authTokenGetter(); if (newToken) { originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return longRunningApiClient(originalRequest); } } catch (retryError) { console.error('Token refresh failed:', retryError); } } if (error?.response?.status === 401) { // Redirect on 401 unless we're on root route (app initialization) // We allow redirect on onboarding to handle expired sessions const isRootRoute = window.location.pathname === '/'; if (!isRootRoute) { try { window.location.assign('/'); } catch {} } else { console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)'); } } // Check if it's a subscription-related error and handle it globally if (error.response?.status === 429 || error.response?.status === 402) { console.log('Long-running API Client: Detected subscription error, triggering global handler'); if (globalSubscriptionErrorHandler) { const result = globalSubscriptionErrorHandler(error); const wasHandled = result instanceof Promise ? await result : result; if (wasHandled) { console.log('Long-running API Client: Subscription error handled by global handler'); return Promise.reject(error); } } } console.error('Long-running API Error:', error.message || error, error.response?.status, error.response?.data); return Promise.reject(error); } ); // Add interceptors for polling client pollingApiClient.interceptors.request.use( async (config) => { console.log(`Making polling ${config.method?.toUpperCase()} request to ${config.url}`); if (isBackendTemporarilyUnavailable()) { return Promise.reject(buildCooldownError()); } try { if (!authTokenGetter) { console.warn(`[pollingApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`); } else { try { const token = await authTokenGetter(); if (token) { config.headers = config.headers || {}; (config.headers as any)['Authorization'] = `Bearer ${token}`; } else { console.warn(`[pollingApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`); // Redirect if on protected route to force re-auth const isRootRoute = window.location.pathname === '/'; if (!isRootRoute) { console.warn('[pollingApiClient] Redirecting to login due to missing auth token'); try { window.location.assign('/'); } catch {} } return Promise.reject(new Error('Authentication token not available. Please sign in or reload the page.')); } } catch (tokenError) { console.error(`[pollingApiClient] ❌ Error getting auth token for ${config.url}:`, tokenError); return Promise.reject(new Error('Failed to get authentication token.')); } } } catch (e) { console.error(`[pollingApiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e); return Promise.reject(e); } return config; }, (error) => { return Promise.reject(error); } ); pollingApiClient.interceptors.response.use( (response) => { clearBackendCooldown(); return response; }, async (error) => { const originalRequest = error.config; if (!error.response) { openBackendCooldown(error?.message || 'network_error'); return Promise.reject( new NetworkError('Unable to connect to the backend server. Please check if the server is running.') ); } if (error.response.status >= 500) { openBackendCooldown(`http_${error.response.status}`); return Promise.reject( new ConnectionError('Backend server is experiencing issues. Please try again later.') ); } // If 401 and we haven't retried yet, try to refresh token and retry if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) { originalRequest._retry = true; try { const newToken = await authTokenGetter(); if (newToken) { originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return pollingApiClient(originalRequest); } } catch (retryError) { console.error('Token refresh failed:', retryError); } } if (error?.response?.status === 401) { // Redirect on 401 unless we're on root route (app initialization) // We allow redirect on onboarding to handle expired sessions const isRootRoute = window.location.pathname === '/'; if (!isRootRoute) { try { window.location.assign('/'); } catch {} } else { console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)'); } } // Check if it's a subscription-related error and handle it globally if (error.response?.status === 429 || error.response?.status === 402) { console.log('Polling API Client: Detected subscription error', { status: error.response?.status, data: error.response?.data, hasHandler: !!globalSubscriptionErrorHandler }); if (globalSubscriptionErrorHandler) { const result = globalSubscriptionErrorHandler(error); const wasHandled = result instanceof Promise ? await result : result; if (wasHandled) { console.log('Polling API Client: Subscription error handled by global handler - modal should be shown'); } else { console.warn('Polling API Client: Subscription error not handled by global handler'); } // Always reject so the polling hook can also handle it return Promise.reject(error); } else { console.warn('Polling API Client: No global subscription error handler registered'); } } console.error('Polling API Error:', error.message || error, error.response?.status, error.response?.data); return Promise.reject(error); } );