756 lines
28 KiB
TypeScript
756 lines
28 KiB
TypeScript
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<boolean>) | null = null;
|
|
|
|
export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean | Promise<boolean>) => {
|
|
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<string | null>) | null = null;
|
|
|
|
// Optional Clerk sign-out function - set by App.tsx when Clerk is available
|
|
let clerkSignOut: (() => Promise<void>) | null = null;
|
|
|
|
export const setClerkSignOut = (signOutFn: () => Promise<void>) => {
|
|
clerkSignOut = signOutFn;
|
|
};
|
|
|
|
export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
|
authTokenGetter = getter;
|
|
};
|
|
|
|
export const getAuthTokenGetter = (): (() => Promise<string | null>) | 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<string, number>();
|
|
|
|
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);
|
|
}
|
|
);
|