/**
* Retry Handler
*
* Implements exponential backoff retry logic for resilient operations.
* Handles transient failures with configurable retry policies.
*/
import { logger } from './logger.js';
/**
* Retry Handler Class
*/
export class RetryHandler {
constructor() {
this.defaultMaxRetries = 3;
this.defaultInitialDelay = 1000; // 1 second
this.defaultMaxDelay = 30000; // 30 seconds
this.defaultBackoffMultiplier = 2;
}
/**
* Execute operation with retry logic
* @param {Function} operation - Operation to execute (should return Promise)
* @param {Object} options - Retry options
* @returns {Promise<any>} Operation result
*/
async execute(operation, options = {}) {
const maxRetries = options.maxRetries ?? this.defaultMaxRetries;
const initialDelay = options.initialDelay ?? this.defaultInitialDelay;
const maxDelay = options.maxDelay ?? this.defaultMaxDelay;
const backoffMultiplier = options.backoffMultiplier ?? this.defaultBackoffMultiplier;
const operationName = options.operation || 'unknown';
const correlationId = options.correlationId || 'unknown';
let lastError = null;
let attempt = 0;
while (attempt <= maxRetries) {
attempt++;
try {
logger.debug('Executing operation', {
correlationId,
operation: operationName,
attempt,
maxRetries
});
const result = await operation();
if (attempt > 1) {
logger.info('Operation succeeded after retry', {
correlationId,
operation: operationName,
attempt
});
}
return result;
} catch (error) {
lastError = error;
logger.warn('Operation failed', {
correlationId,
operation: operationName,
attempt,
maxRetries,
error: error.message
});
// Don't retry on non-retryable errors
if (!this.isRetryable(error)) {
logger.debug('Error is not retryable', {
correlationId,
operation: operationName,
errorType: error.name
});
throw error;
}
// Don't retry if we've exhausted attempts
if (attempt >= maxRetries) {
logger.error('Operation failed after all retries', {
correlationId,
operation: operationName,
attempts: attempt,
finalError: error.message
});
throw error;
}
// Calculate delay with exponential backoff
const delay = this.calculateDelay(
attempt,
initialDelay,
maxDelay,
backoffMultiplier
);
logger.debug('Waiting before retry', {
correlationId,
operation: operationName,
attempt,
delay
});
await this.sleep(delay);
}
}
// This should never be reached, but TypeScript needs it
throw lastError;
}
/**
* Calculate delay for retry with exponential backoff
* @param {number} attempt - Current attempt number
* @param {number} initialDelay - Initial delay in milliseconds
* @param {number} maxDelay - Maximum delay in milliseconds
* @param {number} backoffMultiplier - Backoff multiplier
* @returns {number} Delay in milliseconds
*/
calculateDelay(attempt, initialDelay, maxDelay, backoffMultiplier) {
const exponentialDelay = initialDelay * Math.pow(backoffMultiplier, attempt - 1);
// Add some jitter to avoid thundering herd
const jitter = exponentialDelay * 0.1 * Math.random();
const delay = exponentialDelay + jitter;
return Math.min(delay, maxDelay);
}
/**
* Check if error is retryable
* @param {Error} error - Error to check
* @returns {boolean}
*/
isRetryable(error) {
// Network errors
if (error.name === 'NetworkError' || error.name === 'TypeError') {
return true;
}
// HTTP status codes that are retryable
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
if (error.status && retryableStatusCodes.includes(error.status)) {
return true;
}
// Specific error messages
const retryableMessages = [
'network',
'timeout',
'connection',
'rate limit',
'service unavailable'
];
const errorMessage = error.message?.toLowerCase() || '';
for (const message of retryableMessages) {
if (errorMessage.includes(message)) {
return true;
}
}
return false;
}
/**
* Sleep for specified duration
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Set default configuration
* @param {Object} config - Configuration options
*/
setDefaultConfig(config) {
if (config.maxRetries !== undefined) {
this.defaultMaxRetries = config.maxRetries;
}
if (config.initialDelay !== undefined) {
this.defaultInitialDelay = config.initialDelay;
}
if (config.maxDelay !== undefined) {
this.defaultMaxDelay = config.maxDelay;
}
if (config.backoffMultiplier !== undefined) {
this.defaultBackoffMultiplier = config.backoffMultiplier;
}
logger.info('Retry handler default configuration updated', {
maxRetries: this.defaultMaxRetries,
initialDelay: this.defaultInitialDelay,
maxDelay: this.defaultMaxDelay,
backoffMultiplier: this.defaultBackoffMultiplier
});
}
/**
* Get current configuration
* @returns {Object} Configuration
*/
getDefaultConfig() {
return {
maxRetries: this.defaultMaxRetries,
initialDelay: this.defaultInitialDelay,
maxDelay: this.defaultMaxDelay,
backoffMultiplier: this.defaultBackoffMultiplier
};
}
/**
* Execute multiple operations concurrently with retry
* @param {Array<Function>} operations - Array of operations to execute
* @param {Object} options - Retry options
* @returns {Promise<Array>} Results from all operations
*/
async executeAll(operations, options = {}) {
const promises = operations.map((operation, index) => {
return this.execute(operation, {
...options,
operation: `${options.operation || 'batch'}_${index}`
});
});
return await Promise.all(promises);
}
/**
* Execute operations sequentially with retry
* @param {Array<Function>} operations - Array of operations to execute
* @param {Object} options - Retry options
* @returns {Promise<Array>} Results from all operations
*/
async executeSequentially(operations, options = {}) {
const results = [];
for (let i = 0; i < operations.length; i++) {
const result = await this.execute(operations[i], {
...options,
operation: `${options.operation || 'sequential'}_${i}`
});
results.push(result);
}
return results;
}
}
// Export singleton instance
export const retryHandler = new RetryHandler();