141 lines
3.6 KiB
TypeScript
141 lines
3.6 KiB
TypeScript
|
|
/**
|
||
|
|
* Retry Plugin
|
||
|
|
*
|
||
|
|
* Automatically retries failed requests with exponential backoff
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { defaultLogger } from '../../observability/logger.js';
|
||
|
|
import type { Plugin, PluginErrorContext, RetryPluginConfig } from '../types.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Default configuration
|
||
|
|
*/
|
||
|
|
const DEFAULT_CONFIG: Required<RetryPluginConfig> = {
|
||
|
|
maxRetries: 3,
|
||
|
|
initialDelay: 1000, // 1 second
|
||
|
|
maxDelay: 30000, // 30 seconds
|
||
|
|
backoffMultiplier: 2,
|
||
|
|
retryableStatusCodes: [429, 502, 503, 504],
|
||
|
|
retryableErrors: [
|
||
|
|
'ECONNRESET',
|
||
|
|
'ENOTFOUND',
|
||
|
|
'ESOCKETTIMEDOUT',
|
||
|
|
'ETIMEDOUT',
|
||
|
|
'ECONNREFUSED',
|
||
|
|
'EHOSTUNREACH',
|
||
|
|
'EPIPE',
|
||
|
|
'EAI_AGAIN',
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create retry plugin
|
||
|
|
*/
|
||
|
|
export function createRetryPlugin(config: RetryPluginConfig = {}): Plugin {
|
||
|
|
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
|
||
|
|
const logger = defaultLogger;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if error is retryable
|
||
|
|
*/
|
||
|
|
function isRetryable(context: PluginErrorContext): boolean {
|
||
|
|
// Check if we've exceeded max retries
|
||
|
|
if (context.attempts >= pluginConfig.maxRetries) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check status code if response is available
|
||
|
|
if (context.response) {
|
||
|
|
return pluginConfig.retryableStatusCodes.includes(context.response.statusCode);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check error code/type
|
||
|
|
const errorCode = (context.error as any).code;
|
||
|
|
const errorType = context.error.name;
|
||
|
|
|
||
|
|
if (errorCode && pluginConfig.retryableErrors.includes(errorCode)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (pluginConfig.retryableErrors.includes(errorType)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for timeout errors
|
||
|
|
if (
|
||
|
|
errorType === 'TimeoutError' ||
|
||
|
|
context.error.message.toLowerCase().includes('timeout')
|
||
|
|
) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for connection errors
|
||
|
|
if (
|
||
|
|
errorType === 'ConnectionError' ||
|
||
|
|
context.error.message.toLowerCase().includes('connection')
|
||
|
|
) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Calculate retry delay with exponential backoff
|
||
|
|
*/
|
||
|
|
function calculateDelay(attempt: number): number {
|
||
|
|
const delay = pluginConfig.initialDelay * Math.pow(pluginConfig.backoffMultiplier, attempt);
|
||
|
|
return Math.min(delay, pluginConfig.maxDelay);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sleep for specified duration
|
||
|
|
*/
|
||
|
|
function sleep(ms: number): Promise<void> {
|
||
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
name: 'retry',
|
||
|
|
version: '1.0.0',
|
||
|
|
priority: 90, // Execute late, close to the actual request
|
||
|
|
|
||
|
|
onError: async (context: PluginErrorContext) => {
|
||
|
|
// Check if error is retryable
|
||
|
|
if (!isRetryable(context)) {
|
||
|
|
logger.debug('Error not retryable', {
|
||
|
|
error: context.error.name,
|
||
|
|
attempts: context.attempts,
|
||
|
|
maxRetries: pluginConfig.maxRetries,
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate delay
|
||
|
|
const delay = calculateDelay(context.attempts);
|
||
|
|
|
||
|
|
logger.info('Retrying request', {
|
||
|
|
requestId: context.request.requestId,
|
||
|
|
attempt: context.attempts + 1,
|
||
|
|
maxRetries: pluginConfig.maxRetries,
|
||
|
|
delay,
|
||
|
|
error: context.error.message,
|
||
|
|
statusCode: context.response?.statusCode,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Wait before retrying
|
||
|
|
await sleep(delay);
|
||
|
|
|
||
|
|
// Note: We don't actually retry the request here because we can't
|
||
|
|
// access the client from the plugin. Instead, we return null to
|
||
|
|
// indicate that the error was not handled, and the caller should
|
||
|
|
// handle the retry logic.
|
||
|
|
//
|
||
|
|
// In a real implementation, you would integrate this with the
|
||
|
|
// connection manager to actually retry the request.
|
||
|
|
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|