Files
OpenQode/lib/retry-handler.mjs

205 lines
6.1 KiB
JavaScript

/**
* Enhanced Retry & Timeout Handler
* Based on: Mini-Agent retry.py
*
* Features:
* - Exponential backoff strategy
* - Configurable max retries and delays
* - Visual callback for retry UI
* - Timeout handling with graceful recovery
*/
/**
* Retry configuration class
*/
export class RetryConfig {
constructor(options = {}) {
this.enabled = options.enabled ?? true;
this.maxRetries = options.maxRetries ?? 3;
this.initialDelay = options.initialDelay ?? 1000; // ms
this.maxDelay = options.maxDelay ?? 60000; // ms
this.exponentialBase = options.exponentialBase ?? 2.0;
this.timeout = options.timeout ?? 120000; // 120s default
this.retryableErrors = options.retryableErrors ?? [
'ETIMEDOUT',
'ECONNRESET',
'ECONNREFUSED',
'ENOTFOUND',
'network',
'timeout',
'429', // Rate limit
'500', // Server error
'502', // Bad gateway
'503', // Service unavailable
'504' // Gateway timeout
];
}
/**
* Calculate delay time using exponential backoff
* @param {number} attempt - Current attempt (0-indexed)
* @returns {number} Delay in milliseconds
*/
calculateDelay(attempt) {
const delay = this.initialDelay * Math.pow(this.exponentialBase, attempt);
return Math.min(delay, this.maxDelay);
}
/**
* Check if error is retryable
*/
isRetryable(error) {
const errorString = String(error?.code || error?.message || error).toLowerCase();
return this.retryableErrors.some(code =>
errorString.includes(String(code).toLowerCase())
);
}
}
/**
* Create a retry wrapper for async functions
* @param {Function} fn - Async function to wrap
* @param {RetryConfig} config - Retry configuration
* @param {Function} onRetry - Callback on each retry (error, attempt, delay)
* @returns {Function} Wrapped function with retry logic
*/
export function withRetry(fn, config = new RetryConfig(), onRetry = null) {
return async function (...args) {
let lastError = null;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
// Pass abort signal if function accepts it
const result = await fn(...args, { signal: controller.signal });
clearTimeout(timeoutId);
return result;
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
lastError = error;
// Check if we should retry
const isTimeout = error.name === 'AbortError' ||
error.message?.includes('timed out');
const isRetryable = config.isRetryable(error) || isTimeout;
if (!isRetryable || attempt >= config.maxRetries) {
throw new RetryExhaustedError(lastError, attempt + 1);
}
// Calculate delay
const delay = config.calculateDelay(attempt);
// Call retry callback for UI updates
if (onRetry) {
onRetry(error, attempt + 1, delay);
}
// Wait before retry
await sleep(delay);
}
}
throw lastError;
};
}
/**
* Retry exhausted error
*/
export class RetryExhaustedError extends Error {
constructor(lastException, attempts) {
super(`Retry failed after ${attempts} attempts. Last error: ${lastException?.message || lastException}`);
this.name = 'RetryExhaustedError';
this.lastException = lastException;
this.attempts = attempts;
}
}
/**
* Sleep for specified milliseconds
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Format retry status for display
* @param {number} attempt - Current attempt number
* @param {number} maxRetries - Maximum retries
* @param {number} delay - Delay in ms before next retry
* @returns {string} Formatted status string
*/
export function formatRetryStatus(attempt, maxRetries, delay) {
const delaySeconds = (delay / 1000).toFixed(1);
return `⟳ Retry ${attempt}/${maxRetries} in ${delaySeconds}s...`;
}
/**
* Format timeout message
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {string} Formatted timeout message
*/
export function formatTimeoutMessage(timeoutMs) {
const seconds = Math.round(timeoutMs / 1000);
return `⏱ Request timed out (${seconds}s)`;
}
/**
* Create progress callback for retry UI
* @param {Function} setStatus - State setter for status message
* @returns {Function} Callback function for withRetry
*/
export function createRetryCallback(setStatus) {
return (error, attempt, delay) => {
const seconds = (delay / 1000).toFixed(1);
const errorMsg = error.message || 'Connection error';
setStatus({
type: 'retry',
attempt,
delay: seconds,
message: `${errorMsg} - Retrying in ${seconds}s... (${attempt}/3)`
});
};
}
/**
* Enhanced fetch with built-in retry and timeout
*/
export async function fetchWithRetry(url, options = {}, config = new RetryConfig()) {
const fetchFn = async (fetchUrl, fetchOptions, { signal }) => {
const response = await fetch(fetchUrl, {
...fetchOptions,
signal
});
if (!response.ok && config.retryableErrors.includes(String(response.status))) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
};
return withRetry(fetchFn, config)(url, options);
}
/**
* Default export
*/
export default {
RetryConfig,
RetryExhaustedError,
withRetry,
sleep,
formatRetryStatus,
formatTimeoutMessage,
createRetryCallback,
fetchWithRetry
};