import { RetryConfig, RetryStrategy } from './types.js'; import { ElasticsearchError } from './elasticsearch-error.js'; /** * Calculates delay based on retry strategy */ export class RetryDelayCalculator { constructor(private config: RetryConfig) {} /** * Calculate delay for the given attempt number */ calculateDelay(attempt: number): number { let delay: number; switch (this.config.strategy) { case 'none': return 0; case 'fixed': delay = this.config.initialDelay; break; case 'linear': delay = this.config.initialDelay * attempt; break; case 'exponential': const multiplier = this.config.backoffMultiplier || 2; delay = this.config.initialDelay * Math.pow(multiplier, attempt - 1); break; default: delay = this.config.initialDelay; } // Cap at max delay delay = Math.min(delay, this.config.maxDelay); // Add jitter if configured if (this.config.jitterFactor && this.config.jitterFactor > 0) { const jitter = delay * this.config.jitterFactor * Math.random(); delay = delay + jitter; } return Math.floor(delay); } } /** * Default retry configuration */ export const DEFAULT_RETRY_CONFIG: RetryConfig = { maxAttempts: 3, strategy: 'exponential', initialDelay: 1000, maxDelay: 30000, backoffMultiplier: 2, jitterFactor: 0.1, }; /** * Determines if an error should be retried based on its characteristics */ export function shouldRetryError(error: Error): boolean { // If it's our custom error, check the retryable flag if (error instanceof ElasticsearchError) { return error.retryable; } // For native errors, check specific types if (error.name === 'TimeoutError') return true; if (error.message.includes('ECONNREFUSED')) return true; if (error.message.includes('ECONNRESET')) return true; if (error.message.includes('ETIMEDOUT')) return true; if (error.message.includes('ENETUNREACH')) return true; if (error.message.includes('EHOSTUNREACH')) return true; // HTTP status codes that are retryable if ('statusCode' in error) { const statusCode = (error as any).statusCode; if (statusCode === 429) return true; // Too Many Requests if (statusCode === 503) return true; // Service Unavailable if (statusCode === 504) return true; // Gateway Timeout if (statusCode >= 500 && statusCode < 600) return true; // Server errors } return false; } /** * Retry policy for executing operations with automatic retry * * @example * ```typescript * const policy = new RetryPolicy({ * maxAttempts: 5, * strategy: 'exponential', * initialDelay: 1000, * maxDelay: 30000, * }); * * const result = await policy.execute(async () => { * return await someElasticsearchOperation(); * }); * ``` */ export class RetryPolicy { private config: RetryConfig; private delayCalculator: RetryDelayCalculator; constructor(config: Partial = {}) { this.config = { ...DEFAULT_RETRY_CONFIG, ...config }; this.delayCalculator = new RetryDelayCalculator(this.config); } /** * Execute an operation with retry logic */ async execute( operation: () => Promise, context?: { operationName?: string; onRetry?: (attempt: number, error: Error, delayMs: number) => void; } ): Promise { let lastError: Error | undefined; let attempt = 0; while (attempt < this.config.maxAttempts) { attempt++; try { return await operation(); } catch (error) { lastError = error as Error; // Check if we should retry const shouldRetry = this.shouldRetry(lastError, attempt); if (!shouldRetry || attempt >= this.config.maxAttempts) { throw lastError; } // Calculate delay const delay = this.delayCalculator.calculateDelay(attempt); // Call retry callback if provided if (context?.onRetry) { context.onRetry(attempt, lastError, delay); } // Wait before retrying await this.sleep(delay); } } // Should never reach here, but TypeScript doesn't know that throw lastError || new Error('Retry policy exhausted'); } /** * Determine if an error should be retried */ private shouldRetry(error: Error, attempt: number): boolean { // Check custom shouldRetry function first if (this.config.shouldRetry) { return this.config.shouldRetry(error, attempt); } // Use default retry logic return shouldRetryError(error); } /** * Sleep for the specified number of milliseconds */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get current configuration */ getConfig(): RetryConfig { return { ...this.config }; } /** * Update configuration */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; this.delayCalculator = new RetryDelayCalculator(this.config); } }