/** * Network Error Recovery Utilities * * Provides robust network error handling with: * - Request timeout handling * - Circuit breaker pattern to prevent cascading failures * - Automatic retry with exponential backoff * - Request deduplication for concurrent calls */ import { ApiError, ApiResponse } from '@/types'; import { isOnline } from './networkStatus'; import { ErrorCodes } from '@/types/errors'; // ==================== Configuration ==================== /** * Default request timeout (30 seconds) */ export const DEFAULT_REQUEST_TIMEOUT = 30000; /** * Short timeout for quick operations (10 seconds) */ export const SHORT_REQUEST_TIMEOUT = 10000; /** * Long timeout for uploads and heavy operations (60 seconds) */ export const LONG_REQUEST_TIMEOUT = 60000; // ==================== Timeout Handling ==================== /** * AbortController with timeout * Creates an AbortController that automatically aborts after the specified timeout * * @param timeoutMs - Timeout in milliseconds * @returns Object with signal and cleanup function * * @example * const { signal, cleanup } = createTimeoutController(10000); * try { * const response = await fetch(url, { signal }); * cleanup(); * } catch (e) { * cleanup(); * if (e.name === 'AbortError') { * // Handle timeout * } * } */ export function createTimeoutController(timeoutMs: number = DEFAULT_REQUEST_TIMEOUT): { controller: AbortController; signal: AbortSignal; cleanup: () => void; isTimedOut: () => boolean; } { const controller = new AbortController(); let timedOut = false; const timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); const cleanup = () => { clearTimeout(timeoutId); }; return { controller, signal: controller.signal, cleanup, isTimedOut: () => timedOut, }; } /** * Wrap fetch with timeout support * * @param url - Request URL * @param options - Fetch options * @param timeoutMs - Timeout in milliseconds * @returns Promise with Response * @throws Error with code NETWORK_TIMEOUT if request times out * * @example * try { * const response = await fetchWithTimeout('https://api.example.com', { method: 'GET' }, 10000); * const data = await response.json(); * } catch (e) { * if (e.code === 'NETWORK_TIMEOUT') { * // Handle timeout * } * } */ export async function fetchWithTimeout( url: string, options: RequestInit = {}, timeoutMs: number = DEFAULT_REQUEST_TIMEOUT ): Promise { const { signal, cleanup, isTimedOut } = createTimeoutController(timeoutMs); try { // Merge signals if one was already provided const mergedOptions: RequestInit = { ...options, signal: options.signal ? anySignal([options.signal, signal]) : signal, }; const response = await fetch(url, mergedOptions); cleanup(); return response; } catch (error) { cleanup(); // Check if this was a timeout if (isTimedOut() || (error instanceof Error && error.name === 'AbortError')) { const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`); (timeoutError as any).code = ErrorCodes.NETWORK_TIMEOUT; (timeoutError as any).name = 'TimeoutError'; throw timeoutError; } throw error; } } /** * Combine multiple AbortSignals into one * The combined signal aborts when any of the input signals abort */ function anySignal(signals: AbortSignal[]): AbortSignal { const controller = new AbortController(); for (const signal of signals) { if (signal.aborted) { controller.abort(); return controller.signal; } signal.addEventListener('abort', () => controller.abort(), { once: true }); } return controller.signal; } // ==================== Circuit Breaker ==================== /** * Circuit breaker states */ type CircuitState = 'closed' | 'open' | 'half-open'; /** * Circuit breaker configuration */ interface CircuitBreakerConfig { /** Number of failures before opening circuit */ failureThreshold: number; /** Time to wait before trying again (ms) */ resetTimeout: number; /** Number of successes needed to close circuit from half-open */ successThreshold: number; } /** * Default circuit breaker configuration */ const DEFAULT_CIRCUIT_CONFIG: CircuitBreakerConfig = { failureThreshold: 5, resetTimeout: 30000, // 30 seconds successThreshold: 2, }; /** * Circuit breaker state storage */ interface CircuitBreakerState { state: CircuitState; failures: number; successes: number; lastFailure: number | null; lastStateChange: number; } /** * Circuit breakers by endpoint key */ const circuits = new Map(); /** * Get or create circuit breaker state for a key */ function getCircuit(key: string): CircuitBreakerState { if (!circuits.has(key)) { circuits.set(key, { state: 'closed', failures: 0, successes: 0, lastFailure: null, lastStateChange: Date.now(), }); } return circuits.get(key)!; } /** * Check if circuit is allowing requests */ export function isCircuitOpen(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): boolean { const circuit = getCircuit(key); if (circuit.state === 'open') { // Check if enough time has passed to try again const now = Date.now(); if (now - circuit.lastStateChange >= config.resetTimeout) { // Transition to half-open circuit.state = 'half-open'; circuit.successes = 0; circuit.lastStateChange = now; return false; } return true; } return false; } /** * Record a successful request */ export function recordSuccess(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): void { const circuit = getCircuit(key); if (circuit.state === 'half-open') { circuit.successes++; if (circuit.successes >= config.successThreshold) { // Close the circuit circuit.state = 'closed'; circuit.failures = 0; circuit.successes = 0; circuit.lastStateChange = Date.now(); } } else if (circuit.state === 'closed') { // Reset failure count on success circuit.failures = 0; } } /** * Record a failed request */ export function recordFailure(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): void { const circuit = getCircuit(key); const now = Date.now(); circuit.failures++; circuit.lastFailure = now; if (circuit.state === 'half-open') { // Any failure in half-open state opens the circuit circuit.state = 'open'; circuit.lastStateChange = now; } else if (circuit.state === 'closed' && circuit.failures >= config.failureThreshold) { // Open the circuit circuit.state = 'open'; circuit.lastStateChange = now; } } /** * Get current circuit state for debugging */ export function getCircuitState(key: string): CircuitBreakerState { return getCircuit(key); } /** * Reset circuit breaker (for testing or manual recovery) */ export function resetCircuit(key: string): void { circuits.delete(key); } /** * Reset all circuits */ export function resetAllCircuits(): void { circuits.clear(); } // ==================== Request Deduplication ==================== /** * In-flight request cache for deduplication */ const inflightRequests = new Map>(); /** * Generate a cache key for a request */ export function generateRequestKey(method: string, url: string, body?: string): string { const bodyHash = body ? simpleHash(body) : ''; return `${method}:${url}:${bodyHash}`; } /** * Simple hash function for request body */ function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return hash.toString(36); } /** * Deduplicate concurrent identical requests * If a request with the same key is already in flight, return the same promise * * @param key - Unique key for the request * @param request - Function that returns the request promise * @returns Promise with the result * * @example * // Both calls will use the same underlying request * const [result1, result2] = await Promise.all([ * deduplicateRequest('get-user-1', () => api.getUser(1)), * deduplicateRequest('get-user-1', () => api.getUser(1)), * ]); */ export async function deduplicateRequest( key: string, request: () => Promise ): Promise { // Check if request is already in flight const existing = inflightRequests.get(key); if (existing) { return existing; } // Create new request and cache it const promise = request().finally(() => { // Remove from cache when complete inflightRequests.delete(key); }); inflightRequests.set(key, promise); return promise; } // ==================== Enhanced Fetch with All Features ==================== /** * Options for network-aware fetch */ export interface NetworkAwareFetchOptions extends RequestInit { /** Request timeout in milliseconds */ timeout?: number; /** Circuit breaker key (defaults to URL host) */ circuitKey?: string; /** Enable request deduplication for GET requests */ deduplicate?: boolean; /** Number of retry attempts */ retries?: number; /** Base delay between retries in ms */ retryDelay?: number; /** Multiplier for exponential backoff */ retryBackoff?: number; } /** * Enhanced fetch with timeout, circuit breaker, and retry support * * @param url - Request URL * @param options - Enhanced fetch options * @returns Promise with Response * * @example * const response = await networkAwareFetch('https://api.example.com/data', { * method: 'GET', * timeout: 10000, * retries: 3, * deduplicate: true, * }); */ export async function networkAwareFetch( url: string, options: NetworkAwareFetchOptions = {} ): Promise { const { timeout = DEFAULT_REQUEST_TIMEOUT, circuitKey = new URL(url).host, deduplicate = false, retries = 0, retryDelay = 1000, retryBackoff = 2, ...fetchOptions } = options; // Check if circuit is open if (isCircuitOpen(circuitKey)) { const error = new Error('Service temporarily unavailable due to repeated failures'); (error as any).code = ErrorCodes.SERVICE_UNAVAILABLE; throw error; } // Check network status const online = await isOnline(); if (!online) { const error = new Error('No internet connection'); (error as any).code = ErrorCodes.NETWORK_OFFLINE; throw error; } // Create the actual fetch function const doFetch = async (): Promise => { try { const response = await fetchWithTimeout(url, fetchOptions, timeout); // Record success if response is OK if (response.ok) { recordSuccess(circuitKey); } else if (response.status >= 500) { // Server errors trigger circuit breaker recordFailure(circuitKey); } return response; } catch (error) { recordFailure(circuitKey); throw error; } }; // Handle deduplication for GET requests const method = (fetchOptions.method || 'GET').toUpperCase(); if (deduplicate && method === 'GET') { const key = generateRequestKey(method, url); return deduplicateRequest(key, doFetch); } // Execute with retries if configured if (retries > 0) { return executeWithRetry(doFetch, retries, retryDelay, retryBackoff); } return doFetch(); } /** * Execute a function with retry support */ async function executeWithRetry( fn: () => Promise, maxRetries: number, baseDelay: number, backoffMultiplier: number ): Promise { let lastError: Error | undefined; let delay = baseDelay; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // Check if error is retryable const errorCode = (error as any)?.code; const isRetryable = errorCode === ErrorCodes.NETWORK_TIMEOUT || errorCode === ErrorCodes.NETWORK_ERROR || errorCode === ErrorCodes.SERVICE_UNAVAILABLE; if (!isRetryable || attempt === maxRetries) { throw lastError; } // Wait before retry await new Promise(resolve => setTimeout(resolve, delay)); delay *= backoffMultiplier; } } throw lastError || new Error('Max retries exceeded'); } // ==================== API Response Helpers ==================== /** * Create an error response for network issues */ export function createNetworkErrorResponse( code: string, message: string ): ApiResponse { return { ok: false, error: { message, code, }, }; } /** * Check if an error is a timeout error */ export function isTimeoutError(error: unknown): boolean { if (error instanceof Error) { return ( (error as any).code === ErrorCodes.NETWORK_TIMEOUT || error.name === 'TimeoutError' || error.name === 'AbortError' || error.message.includes('timeout') ); } return false; } /** * Check if an error is a network error */ export function isNetworkError(error: unknown): boolean { if (error instanceof Error) { const code = (error as any).code; return ( code === ErrorCodes.NETWORK_ERROR || code === ErrorCodes.NETWORK_OFFLINE || code === ErrorCodes.NETWORK_TIMEOUT || error.message.toLowerCase().includes('network') || error.message.toLowerCase().includes('fetch') ); } return false; } /** * Get appropriate error code from an error */ export function getErrorCode(error: unknown): string { if (error instanceof Error) { const code = (error as any).code; if (code) return code; if (isTimeoutError(error)) return ErrorCodes.NETWORK_TIMEOUT; if (error.message.toLowerCase().includes('offline')) return ErrorCodes.NETWORK_OFFLINE; if (isNetworkError(error)) return ErrorCodes.NETWORK_ERROR; } return ErrorCodes.UNKNOWN_ERROR; } /** * Convert any error to an ApiError */ export function toApiError(error: unknown): ApiError { if (error instanceof Error) { return { message: error.message, code: getErrorCode(error), }; } if (typeof error === 'string') { return { message: error, code: ErrorCodes.UNKNOWN_ERROR, }; } return { message: 'An unknown error occurred', code: ErrorCodes.UNKNOWN_ERROR, }; } // ==================== Exports ==================== export type { CircuitBreakerConfig, CircuitBreakerState, CircuitState };