197 lines
5.0 KiB
TypeScript
197 lines
5.0 KiB
TypeScript
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<RetryConfig> = {}) {
|
|
this.config = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
this.delayCalculator = new RetryDelayCalculator(this.config);
|
|
}
|
|
|
|
/**
|
|
* Execute an operation with retry logic
|
|
*/
|
|
async execute<T>(
|
|
operation: () => Promise<T>,
|
|
context?: {
|
|
operationName?: string;
|
|
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
|
|
}
|
|
): Promise<T> {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Get current configuration
|
|
*/
|
|
getConfig(): RetryConfig {
|
|
return { ...this.config };
|
|
}
|
|
|
|
/**
|
|
* Update configuration
|
|
*/
|
|
updateConfig(config: Partial<RetryConfig>): void {
|
|
this.config = { ...this.config, ...config };
|
|
this.delayCalculator = new RetryDelayCalculator(this.config);
|
|
}
|
|
}
|