/** * Intelligent retry logic for blockchain operations * * Distinguishes between transient and permanent errors, * only retrying operations that have a chance of success. */ export interface RetryOptions { /** Maximum number of retries */ maxRetries?: number; /** Initial delay in ms */ initialDelay?: number; /** Maximum delay in ms */ maxDelay?: number; /** Backoff multiplier */ backoffMultiplier?: number; /** Custom retry predicate */ shouldRetry?: (error: any, attempt: number) => boolean; /** Callback for retry attempts */ onRetry?: (error: any, attempt: number, delay: number) => void; } /** * Retryable error codes */ export const RETRYABLE_ERROR_CODES = new Set([ // Network errors 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN', // HTTP errors 'NETWORK_ERROR', 'TIMEOUT', 'SERVER_ERROR', // RPC errors 'RATE_LIMIT', 'TOO_MANY_REQUESTS', 'SERVICE_UNAVAILABLE', 'GATEWAY_TIMEOUT', // Blockchain errors 'NONCE_EXPIRED', 'REPLACEMENT_UNDERPRICED', 'NETWORK_CHANGED' ]); /** * Non-retryable error patterns */ const NON_RETRYABLE_PATTERNS = [ 'insufficient funds', 'invalid address', 'invalid signature', 'nonce too low', 'gas too low', 'execution reverted', 'invalid opcode', 'out of gas', 'user denied', 'user rejected', 'already known', 'intrinsic gas too low' ]; /** * Check if an error is retryable * * @param error - Error to check * @returns true if error might be transient */ export function isRetryableError(error: any): boolean { // Check error code if (error.code && RETRYABLE_ERROR_CODES.has(error.code)) { return true; } // Check status code if (error.status === 429 || error.status === 503 || error.status === 504) { return true; } // Check error message const message = error.message?.toLowerCase() || ''; // Check for retryable patterns if ( message.includes('rate limit') || message.includes('timeout') || message.includes('network') || message.includes('connection') || message.includes('temporary') || message.includes('try again') ) { return true; } // Check for non-retryable patterns for (const pattern of NON_RETRYABLE_PATTERNS) { if (message.includes(pattern)) { return false; } } // Default: don't retry unknown errors return false; } /** * Calculate delay with exponential backoff and jitter * * @param attempt - Current attempt number (1-based) * @param initialDelay - Initial delay in ms * @param maxDelay - Maximum delay in ms * @param multiplier - Backoff multiplier * @returns Delay in ms */ export function calculateBackoffDelay( attempt: number, initialDelay: number = 1000, maxDelay: number = 30000, multiplier: number = 2 ): number { // Exponential backoff: initialDelay * multiplier^(attempt-1) const exponentialDelay = initialDelay * Math.pow(multiplier, attempt - 1); // Cap at maxDelay const cappedDelay = Math.min(exponentialDelay, maxDelay); // Add jitter (±20%) to prevent thundering herd const jitter = cappedDelay * 0.2 * (Math.random() - 0.5); return Math.floor(cappedDelay + jitter); } /** * Retry an async operation with intelligent backoff * * @param operation - Async function to retry * @param options - Retry options * @returns Result of the operation * @throws Last error if all retries exhausted * * @example * ```typescript * const balance = await retryWithBackoff( * () => provider.getBalance(address), * { * maxRetries: 3, * initialDelay: 1000, * onRetry: (error, attempt, delay) => { * console.log(`Retry ${attempt} after ${delay}ms:`, error.message); * } * } * ); * ``` */ export async function retryWithBackoff( operation: () => Promise, options: RetryOptions = {} ): Promise { const { maxRetries = 3, initialDelay = 1000, maxDelay = 30000, backoffMultiplier = 2, shouldRetry = isRetryableError, onRetry } = options; let lastError: any; for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { try { return await operation(); } catch (error: any) { lastError = error; // Check if we should retry const isLastAttempt = attempt === maxRetries + 1; const canRetry = shouldRetry(error, attempt); if (isLastAttempt || !canRetry) { // Attach retry metadata to error error.attempts = attempt; error.retryable = canRetry; throw error; } // Calculate delay const delay = calculateBackoffDelay( attempt, initialDelay, maxDelay, backoffMultiplier ); // Notify caller if (onRetry) { onRetry(error, attempt, delay); } // Wait before retry await new Promise(resolve => setTimeout(resolve, delay)); } } // Should not reach here, but TypeScript needs it throw lastError; } /** * Retry with linear backoff (simpler, more predictable) * * @param operation - Async function to retry * @param maxRetries - Maximum retries * @param delayMs - Fixed delay between retries * @returns Result of the operation */ export async function retryWithLinearBackoff( operation: () => Promise, maxRetries: number = 3, delayMs: number = 1000 ): Promise { return retryWithBackoff(operation, { maxRetries, initialDelay: delayMs, maxDelay: delayMs, backoffMultiplier: 1 // No exponential growth }); } /** * Retry for blockchain transactions with custom logic * * @param operation - Transaction operation * @param options - Retry options * @returns Transaction result */ export async function retryTransaction( operation: () => Promise, options: RetryOptions = {} ): Promise { return retryWithBackoff(operation, { maxRetries: 5, // More retries for transactions initialDelay: 2000, // Longer initial delay maxDelay: 60000, // Up to 1 minute ...options, shouldRetry: (error, attempt) => { // Custom logic for transactions const message = error.message?.toLowerCase() || ''; // Don't retry user rejections if (message.includes('user denied') || message.includes('user rejected')) { return false; } // Don't retry if transaction already mined if (message.includes('already known') || message.includes('nonce too low')) { return false; } // Retry network/rate limit errors return isRetryableError(error); } }); }