/** * Retry logic and circuit breaker for API requests */ import { ApiError } from './ApiError'; export interface RetryConfig { maxRetries: number; baseDelay: number; // milliseconds maxDelay: number; // milliseconds backoffMultiplier: number; timeout: number; // milliseconds } export const DEFAULT_RETRY_CONFIG: RetryConfig = { maxRetries: 1, baseDelay: 1000, maxDelay: 10000, backoffMultiplier: 2, timeout: 30000, }; export interface CircuitBreakerConfig { failureThreshold: number; successThreshold: number; timeout: number; // milliseconds before trying again } export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = { failureThreshold: 5, successThreshold: 3, timeout: 60000, // 1 minute }; export class CircuitBreaker { private failures = 0; private successes = 0; private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; private lastFailureTime: number | null = null; private readonly config: CircuitBreakerConfig; constructor(config: CircuitBreakerConfig = DEFAULT_CIRCUIT_BREAKER_CONFIG) { this.config = config; } /** * Check if request should proceed */ canExecute(): boolean { if (this.state === 'CLOSED') { return true; } if (this.state === 'OPEN') { const now = Date.now(); if (this.lastFailureTime && now - this.lastFailureTime > this.config.timeout) { this.state = 'HALF_OPEN'; return true; } return false; } // HALF_OPEN - allow one request to test if service recovered return true; } /** * Record a successful request */ recordSuccess(): void { if (this.state === 'HALF_OPEN') { this.successes++; if (this.successes >= this.config.successThreshold) { this.reset(); } } else if (this.state === 'CLOSED') { // Keep failures in check this.failures = Math.max(0, this.failures - 1); } } /** * Record a failed request */ recordFailure(): void { this.failures++; this.lastFailureTime = Date.now(); if (this.state === 'HALF_OPEN') { this.state = 'OPEN'; this.successes = 0; } else if (this.state === 'CLOSED' && this.failures >= this.config.failureThreshold) { this.state = 'OPEN'; } } /** * Get current state */ getState(): string { return this.state; } /** * Get failure count */ getFailures(): number { return this.failures; } /** * Reset the circuit breaker */ reset(): void { this.failures = 0; this.successes = 0; this.state = 'CLOSED'; this.lastFailureTime = null; } } export class RetryHandler { private config: RetryConfig; private abortController: AbortController | null = null; constructor(config: RetryConfig = DEFAULT_RETRY_CONFIG) { this.config = config; } /** * Execute a function with retry logic */ async execute( fn: (signal: AbortSignal) => Promise, isRetryable?: (error: ApiError) => boolean ): Promise { this.abortController = new AbortController(); const signal = this.abortController.signal; let lastError: Error | null = null; for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) { try { // Check if already aborted if (signal.aborted) { throw new Error('Request aborted'); } const result = await fn(signal); return result; } catch (error) { lastError = error as Error; // Check if we should abort if (signal.aborted) { throw error; } // Check if this is the last attempt if (attempt === this.config.maxRetries) { break; } // Check if error is retryable if (error instanceof ApiError) { if (!error.isRetryable()) { throw error; } if (isRetryable && !isRetryable(error)) { throw error; } } // Calculate delay with exponential backoff const delay = this.calculateDelay(attempt); // Wait before retrying await this.sleep(delay, signal); } } // All retries exhausted throw lastError; } /** * Cancel the current request */ abort(): void { if (this.abortController) { this.abortController.abort(); } } /** * Calculate delay for retry attempt */ private calculateDelay(attempt: number): number { const delay = Math.min( this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt), this.config.maxDelay ); // Add jitter to prevent thundering herd const jitter = Math.random() * 0.3 * delay; return delay + jitter; } /** * Sleep with abort support */ private sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { signal.removeEventListener('abort', abortHandler); resolve(); }, ms); const abortHandler = () => { clearTimeout(timeout); signal.removeEventListener('abort', abortHandler); reject(new Error('Request aborted during retry delay')); }; signal.addEventListener('abort', abortHandler, { once: true }); }); } } /** * Global circuit breaker registry for different API endpoints */ export class CircuitBreakerRegistry { private static instance: CircuitBreakerRegistry; private breakers: Map = new Map(); private constructor() {} static getInstance(): CircuitBreakerRegistry { if (!CircuitBreakerRegistry.instance) { CircuitBreakerRegistry.instance = new CircuitBreakerRegistry(); } return CircuitBreakerRegistry.instance; } /** * Get or create circuit breaker for a specific endpoint */ getBreaker(endpoint: string, config?: CircuitBreakerConfig): CircuitBreaker { if (!this.breakers.has(endpoint)) { this.breakers.set(endpoint, new CircuitBreaker(config)); } return this.breakers.get(endpoint)!; } /** * Reset all circuit breakers */ resetAll(): void { this.breakers.forEach(breaker => breaker.reset()); } /** * Get status of all circuit breakers */ getStatus(): Record { const status: Record = {}; this.breakers.forEach((breaker, endpoint) => { status[endpoint] = { state: breaker.getState(), failures: breaker.getFailures(), }; }); return status; } }