/** * Base API Client for HTTP operations * * Provides generic HTTP methods with common request/response handling, * error handling, authentication, retry logic, and circuit breaker. */ import { Logger } from '../../interfaces/Logger'; import { ErrorReporter } from '../../interfaces/ErrorReporter'; import { ApiError, ApiErrorType } from './ApiError'; import { RetryHandler, CircuitBreakerRegistry, DEFAULT_RETRY_CONFIG } from './RetryHandler'; import { ApiConnectionMonitor } from './ApiConnectionMonitor'; import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; export interface BaseApiClientOptions { timeout?: number; retry?: boolean; retryConfig?: typeof DEFAULT_RETRY_CONFIG; allowUnauthenticated?: boolean; } export class BaseApiClient { protected baseUrl: string; private errorReporter: ErrorReporter; private logger: Logger; private retryHandler: RetryHandler; private circuitBreakerRegistry: CircuitBreakerRegistry; private connectionMonitor: ApiConnectionMonitor; private defaultOptions: BaseApiClientOptions; constructor( baseUrl: string, errorReporter: ErrorReporter, logger: Logger, options: BaseApiClientOptions = {} ) { this.baseUrl = baseUrl; this.errorReporter = errorReporter; this.logger = logger; this.retryHandler = new RetryHandler(options.retryConfig || DEFAULT_RETRY_CONFIG); this.circuitBreakerRegistry = CircuitBreakerRegistry.getInstance(); this.connectionMonitor = ApiConnectionMonitor.getInstance(); this.defaultOptions = { timeout: options.timeout || 30000, retry: options.retry !== false, retryConfig: options.retryConfig || DEFAULT_RETRY_CONFIG, }; // Start monitoring connection health // this.connectionMonitor.startMonitoring(); } /** * Classify HTTP status code into error type */ private classifyError(status: number): ApiErrorType { if (status >= 500) return 'SERVER_ERROR'; if (status === 429) return 'RATE_LIMIT_ERROR'; if (status === 401 || status === 403) return 'AUTH_ERROR'; if (status === 400) return 'VALIDATION_ERROR'; if (status === 404) return 'NOT_FOUND'; return 'UNKNOWN_ERROR'; } /** * Create an ApiError from fetch response */ private async createApiError( response: Response, method: string, path: string, retryCount: number = 0 ): Promise { const status = response.status; const errorType = this.classifyError(status); let message = response.statusText; let responseText = ''; try { responseText = await response.text(); if (responseText) { const errorData = JSON.parse(responseText); if (errorData.message) { message = errorData.message; } } } catch { // Keep default message } return new ApiError( message, errorType, { endpoint: path, method, statusCode: status, responseText, timestamp: new Date().toISOString(), retryCount, } ); } /** * Create an ApiError from network/timeout errors */ private createNetworkError( error: Error, method: string, path: string, retryCount: number = 0 ): ApiError { let errorType: ApiErrorType = 'NETWORK_ERROR'; let message = error.message; // More specific error classification if (error.name === 'AbortError') { errorType = 'CANCELED_ERROR'; message = 'Request was canceled'; } else if (error.name === 'TypeError' && error.message.includes('fetch')) { errorType = 'NETWORK_ERROR'; // Check for CORS specifically if (error.message.includes('Failed to fetch') || error.message.includes('fetch failed')) { message = 'Unable to connect to server. Possible CORS or network issue.'; } } else if (error.message.includes('timeout') || error.message.includes('timed out')) { errorType = 'TIMEOUT_ERROR'; message = 'Request timed out after 30 seconds'; } else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { errorType = 'NETWORK_ERROR'; // This could be CORS, network down, or server not responding message = 'Network error: Unable to reach the API server'; } return new ApiError( message, errorType, { endpoint: path, method, timestamp: new Date().toISOString(), retryCount, // Add helpful context for developers troubleshooting: this.getTroubleshootingContext(error, path), isRetryable: this.isRetryableError(errorType), isConnectivity: errorType === 'NETWORK_ERROR' || errorType === 'TIMEOUT_ERROR', developerHint: this.getDeveloperHint(error, path, method), }, error ); } /** * Check if error type is retryable */ private isRetryableError(errorType: ApiErrorType): boolean { const retryableTypes: ApiErrorType[] = [ 'NETWORK_ERROR', 'SERVER_ERROR', 'RATE_LIMIT_ERROR', 'TIMEOUT_ERROR', ]; return retryableTypes.includes(errorType); } /** * Get developer-friendly hint for troubleshooting */ private getDeveloperHint(error: Error, _path: string, _method: string): string { if (error.message.includes('fetch failed') || error.message.includes('Failed to fetch')) { return 'Check if API server is running and CORS is configured correctly'; } if (error.message.includes('timeout')) { return 'Request timed out - consider increasing timeout or checking network'; } if (error.message.includes('ECONNREFUSED')) { return 'Connection refused - verify API server address and port'; } return 'Review network connection and API endpoint configuration'; } /** * Get troubleshooting context for network errors */ private getTroubleshootingContext(error: Error, _path: string): string { if (typeof window !== 'undefined') { const baseUrl = this.baseUrl; const currentOrigin = window.location.origin; // Check if it's likely a CORS issue if (baseUrl && !baseUrl.includes(currentOrigin) && error.message.includes('Failed to fetch')) { return 'CORS issue likely. Check API server CORS configuration.'; } // Check if API server is same origin if (baseUrl.includes(currentOrigin) || baseUrl.startsWith('/')) { return 'Same-origin request. Check if API server is running.'; } } return 'Check network connection and API server status.'; } protected async request( method: string, path: string, data?: object | FormData, options: BaseApiClientOptions & { allowUnauthenticated?: boolean } = {}, ): Promise { const finalOptions = { ...this.defaultOptions, ...options }; const endpoint = `${this.baseUrl}${path}`; // Check circuit breaker const circuitBreaker = this.circuitBreakerRegistry.getBreaker(path); if (!circuitBreaker.canExecute()) { const error = new ApiError( 'Circuit breaker is open - service temporarily unavailable', 'SERVER_ERROR', { endpoint: path, method, timestamp: new Date().toISOString(), } ); this.handleError(error); throw error; } const executeRequest = async (signal: AbortSignal): Promise => { const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; const headers: Record = isFormData ? {} : { 'Content-Type': 'application/json', }; // Forward cookies if running on server if (typeof window === 'undefined') { try { const { cookies } = await import('next/headers'); const cookieStore = await cookies(); const cookieString = cookieStore.toString(); if (cookieString) { headers['Cookie'] = cookieString; } } catch (e) { // Not in a request context or next/headers not available } } const config: RequestInit = { method, headers, credentials: 'include', signal, }; if (data) { config.body = isFormData ? data : JSON.stringify(data); } const startTime = Date.now(); let requestId: string | undefined; // Log request start (only in development for maximum transparency) if (process.env.NODE_ENV === 'development') { try { const apiLogger = getGlobalApiLogger(); const headerObj: Record = {}; if (typeof headers === 'object') { Object.entries(headers).forEach(([key, value]) => { headerObj[key] = value; }); } requestId = apiLogger.logRequest( endpoint, method, headerObj, data ); } catch (e) { // Silent fail - logger might not be initialized } } try { const response = await fetch(endpoint, config); const responseTime = Date.now() - startTime; // Record success for monitoring this.connectionMonitor.recordSuccess(responseTime); if (!response.ok) { if ( finalOptions.allowUnauthenticated && (response.status === 401 || response.status === 403) ) { // For auth probe endpoints, 401/403 is expected return null as T; } const error = await this.createApiError(response, method, path); circuitBreaker.recordFailure(); this.connectionMonitor.recordFailure(error); this.handleError(error); // Log error if (process.env.NODE_ENV === 'development' && requestId) { try { const apiLogger = getGlobalApiLogger(); apiLogger.logError(requestId, error, responseTime); } catch (e) { // Silent fail } } throw error; } // Record successful circuit breaker call circuitBreaker.recordSuccess(); const text = await response.text(); if (!text) { // Log empty response if (process.env.NODE_ENV === 'development' && requestId) { try { const apiLogger = getGlobalApiLogger(); apiLogger.logResponse(requestId, response, null, responseTime); } catch (e) { // Silent fail } } return null as T; } const parsedData = JSON.parse(text) as T; // Log successful response if (process.env.NODE_ENV === 'development' && requestId) { try { const apiLogger = getGlobalApiLogger(); apiLogger.logResponse(requestId, response, parsedData, responseTime); } catch (e) { // Silent fail } } return parsedData; } catch (error) { const responseTime = Date.now() - startTime; if (error instanceof ApiError) { throw error; } // Convert to ApiError const apiError = this.createNetworkError(error as Error, method, path); circuitBreaker.recordFailure(); this.connectionMonitor.recordFailure(apiError); this.handleError(apiError); // Log network error if (process.env.NODE_ENV === 'development' && requestId) { try { const apiLogger = getGlobalApiLogger(); apiLogger.logError(requestId, apiError, responseTime); } catch (e) { // Silent fail } } throw apiError; } }; // Wrap with retry logic if enabled if (finalOptions.retry) { try { return await this.retryHandler.execute(executeRequest); } catch (error) { // If retry exhausted, throw the final error throw error; } } else { // No retry, just execute with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), finalOptions.timeout); try { return await executeRequest(controller.signal); } finally { clearTimeout(timeoutId); } } } /** * Handle errors - log and report */ private handleError(error: ApiError): void { const severity = error.getSeverity(); const message = error.getDeveloperMessage(); // Enhanced context for better debugging const enhancedContext = { ...error.context, severity, isRetryable: error.isRetryable(), isConnectivity: error.isConnectivityIssue(), }; // Use appropriate log level if (severity === 'error') { this.logger.error(message, error, enhancedContext); } else if (severity === 'warn') { this.logger.warn(message, enhancedContext); } else { this.logger.info(message, enhancedContext); } // Report to error tracking this.errorReporter.report(error, enhancedContext); } protected get(path: string, options?: BaseApiClientOptions): Promise { return this.request('GET', path, undefined, options); } protected post(path: string, data: object, options?: BaseApiClientOptions): Promise { return this.request('POST', path, data, options); } protected put(path: string, data: object, options?: BaseApiClientOptions): Promise { return this.request('PUT', path, data, options); } protected delete(path: string, options?: BaseApiClientOptions): Promise { return this.request('DELETE', path, undefined, options); } protected patch(path: string, data: object, options?: BaseApiClientOptions): Promise { return this.request('PATCH', path, data, options); } /** * Get current connection health status */ getConnectionStatus() { return { status: this.connectionMonitor.getStatus(), health: this.connectionMonitor.getHealth(), isAvailable: this.connectionMonitor.isAvailable(), reliability: this.connectionMonitor.getReliability(), }; } /** * Force a health check */ async checkHealth() { return this.connectionMonitor.performHealthCheck(); } /** * Get circuit breaker status for debugging */ getCircuitBreakerStatus() { return this.circuitBreakerRegistry.getStatus(); } }