/** * Retry utility with exponential backoff * Handles transient failures gracefully with configurable retry logic */ import { logger } from './logger'; import { supabase } from './supabaseClient'; export interface RetryOptions { /** Maximum number of attempts (default: 3) */ maxAttempts?: number; /** Base delay in milliseconds (default: 1000) */ baseDelay?: number; /** Maximum delay in milliseconds (default: 10000) */ maxDelay?: number; /** Multiplier for exponential backoff (default: 2) */ backoffMultiplier?: number; /** Add jitter to prevent thundering herd (default: true) */ jitter?: boolean; /** Callback invoked before each retry attempt */ onRetry?: (attempt: number, error: unknown, delay: number) => void; /** Custom function to determine if error is retryable (default: isRetryableError) */ shouldRetry?: (error: unknown) => boolean; } /** * Determines if an error is transient and retryable * @param error - The error to check * @returns true if error is retryable, false otherwise */ export function isRetryableError(error: unknown): boolean { // Network/timeout errors from fetch if (error instanceof TypeError && error.message.includes('fetch')) { return true; } // Network/timeout errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('network') || message.includes('timeout') || message.includes('connection') || message.includes('econnrefused') || message.includes('enotfound')) { return true; } } // Supabase/PostgreSQL errors if (error && typeof error === 'object') { const supabaseError = error as { code?: string; status?: number }; // Connection/timeout errors if (supabaseError.code === 'PGRST301') return true; // Connection timeout if (supabaseError.code === 'PGRST204') return true; // Temporary failure if (supabaseError.code === 'PGRST000') return true; // Connection error // HTTP status codes indicating transient failures if (supabaseError.status === 429) return true; // Rate limit if (supabaseError.status === 503) return true; // Service unavailable if (supabaseError.status === 504) return true; // Gateway timeout if (supabaseError.status && supabaseError.status >= 500 && supabaseError.status < 600) { return true; // Server errors (5xx) } // Database-level transient errors if (supabaseError.code === '40001') return true; // Serialization failure if (supabaseError.code === '40P01') return true; // Deadlock detected if (supabaseError.code === '57014') return true; // Query cancelled if (supabaseError.code === '08000') return true; // Connection exception if (supabaseError.code === '08003') return true; // Connection does not exist if (supabaseError.code === '08006') return true; // Connection failure if (supabaseError.code === '08001') return true; // Unable to connect if (supabaseError.code === '08004') return true; // Server rejected connection } return false; } /** * Calculates delay for next retry attempt using exponential backoff * @param attempt - Current attempt number (0-indexed) * @param options - Retry configuration * @returns Delay in milliseconds */ function calculateBackoffDelay(attempt: number, options: Required): number { const exponentialDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt); const cappedDelay = Math.min(exponentialDelay, options.maxDelay); // Add jitter (randomness) to prevent thundering herd if (options.jitter) { const jitterAmount = cappedDelay * 0.3; // ±30% jitter const jitterOffset = (Math.random() * 2 - 1) * jitterAmount; return Math.max(0, cappedDelay + jitterOffset); } return cappedDelay; } /** * Load retry configuration from admin settings */ export async function loadRetryConfig(): Promise> { try { const { data: settings } = await supabase .from('admin_settings') .select('setting_key, setting_value') .in('setting_key', [ 'retry.max_attempts', 'retry.base_delay', 'retry.max_delay', 'retry.backoff_multiplier' ]); if (!settings || settings.length === 0) { return getDefaultRetryConfig(); } const config: any = {}; settings.forEach(s => { const key = s.setting_key.replace('retry.', ''); const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); if (key === 'backoff_multiplier') { config[camelKey] = parseFloat(String(s.setting_value)); } else { config[camelKey] = parseInt(String(s.setting_value)); } }); return { maxAttempts: config.maxAttempts ?? 3, baseDelay: config.baseDelay ?? 1000, maxDelay: config.maxDelay ?? 10000, backoffMultiplier: config.backoffMultiplier ?? 2, jitter: true, onRetry: () => {}, shouldRetry: isRetryableError }; } catch (error) { logger.error('Failed to load retry config', { error }); return getDefaultRetryConfig(); } } function getDefaultRetryConfig(): Required { return { maxAttempts: 3, baseDelay: 1000, maxDelay: 10000, backoffMultiplier: 2, jitter: true, onRetry: () => {}, shouldRetry: isRetryableError }; } // Cache admin config for 5 minutes let cachedRetryConfig: Required | null = null; let configCacheTime: number = 0; const CONFIG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes async function getCachedRetryConfig(): Promise> { const now = Date.now(); if (cachedRetryConfig && (now - configCacheTime < CONFIG_CACHE_TTL)) { return cachedRetryConfig; } cachedRetryConfig = await loadRetryConfig(); configCacheTime = now; return cachedRetryConfig; } /** * Executes a function with retry logic and exponential backoff * * @param fn - Async function to execute * @param options - Retry configuration options * @returns Result of the function execution * @throws Last error if all retry attempts fail * * @example * ```typescript * const result = await withRetry( * async () => await supabase.rpc('my_function', { data }), * { * maxAttempts: 3, * onRetry: (attempt, error, delay) => { * toast.info(`Retrying... (${attempt}/3)`); * } * } * ); * ``` */ export async function withRetry( fn: () => Promise, options?: RetryOptions ): Promise { // Load config from admin settings const adminConfig = await getCachedRetryConfig(); // Merge: options override admin settings const config: Required = { maxAttempts: options?.maxAttempts ?? adminConfig.maxAttempts, baseDelay: options?.baseDelay ?? adminConfig.baseDelay, maxDelay: options?.maxDelay ?? adminConfig.maxDelay, backoffMultiplier: options?.backoffMultiplier ?? adminConfig.backoffMultiplier, jitter: options?.jitter ?? adminConfig.jitter, onRetry: options?.onRetry ?? adminConfig.onRetry, shouldRetry: options?.shouldRetry ?? adminConfig.shouldRetry, }; let lastError: unknown; for (let attempt = 0; attempt < config.maxAttempts; attempt++) { try { // Execute the function directly const result = await fn(); // Log successful retry if not first attempt if (attempt > 0) { logger.info('Retry succeeded', { attempt: attempt + 1, totalAttempts: config.maxAttempts }); } return result; } catch (error) { lastError = error; // Check if we should retry const isLastAttempt = attempt === config.maxAttempts - 1; const shouldRetry = config.shouldRetry(error); if (isLastAttempt || !shouldRetry) { // Log final failure logger.error('Retry exhausted or non-retryable error', { attempt: attempt + 1, maxAttempts: config.maxAttempts, isRetryable: shouldRetry, error: error instanceof Error ? error.message : String(error) }); throw error; } // Calculate delay for next attempt const delay = calculateBackoffDelay(attempt, config); // Log retry attempt logger.warn('Retrying after error', { attempt: attempt + 1, maxAttempts: config.maxAttempts, delay, error: error instanceof Error ? error.message : String(error) }); // Invoke callback config.onRetry(attempt + 1, error, delay); // Wait before retrying await new Promise(resolve => setTimeout(resolve, delay)); } } // This should never be reached, but TypeScript requires it throw lastError; }