|
| 1 | +import logger from '../config/logger'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Options controlling retry/backoff behaviour for `withRetry`. |
| 5 | + */ |
| 6 | +export interface RetryOptions { |
| 7 | + /** Maximum number of attempts (including the first). Default: 5. */ |
| 8 | + maxAttempts?: number; |
| 9 | + /** Base delay in milliseconds used for exponential backoff. Default: 250. */ |
| 10 | + baseDelayMs?: number; |
| 11 | + /** Upper bound on any single backoff delay. Default: 8000. */ |
| 12 | + maxDelayMs?: number; |
| 13 | + /** Multiplier applied to the delay on each retry. Default: 2. */ |
| 14 | + factor?: number; |
| 15 | + /** Fraction of jitter (0-1) applied to each computed delay. Default: 0.2. */ |
| 16 | + jitter?: number; |
| 17 | + /** Label used in log messages to identify the operation being retried. */ |
| 18 | + operationName?: string; |
| 19 | + /** Predicate deciding whether a given error should trigger a retry. Defaults to retrying everything. */ |
| 20 | + isRetryable?: (error: unknown) => boolean; |
| 21 | +} |
| 22 | + |
| 23 | +const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'operationName' | 'isRetryable'>> = { |
| 24 | + maxAttempts: 5, |
| 25 | + baseDelayMs: 250, |
| 26 | + maxDelayMs: 8000, |
| 27 | + factor: 2, |
| 28 | + jitter: 0.2, |
| 29 | +}; |
| 30 | + |
| 31 | +function sleep(ms: number): Promise<void> { |
| 32 | + return new Promise((resolve) => setTimeout(resolve, ms)); |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Compute the delay for a given retry attempt using exponential backoff with |
| 37 | + * full jitter, capped at `maxDelayMs`. |
| 38 | + * |
| 39 | + * @param attempt Zero-based retry attempt number (0 = first retry). |
| 40 | + */ |
| 41 | +export function computeBackoffDelay( |
| 42 | + attempt: number, |
| 43 | + options: Required<Omit<RetryOptions, 'operationName' | 'isRetryable'>>, |
| 44 | +): number { |
| 45 | + const exponential = options.baseDelayMs * Math.pow(options.factor, attempt); |
| 46 | + const capped = Math.min(exponential, options.maxDelayMs); |
| 47 | + const jitterRange = capped * options.jitter; |
| 48 | + const jitterOffset = (Math.random() * 2 - 1) * jitterRange; |
| 49 | + return Math.max(0, Math.round(capped + jitterOffset)); |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Execute `fn`, retrying with exponential backoff on failure. |
| 54 | + * |
| 55 | + * Intended for wrapping Soroban RPC calls that may fail transiently due to |
| 56 | + * rate limiting (HTTP 429) or temporary node outages. Every failed attempt |
| 57 | + * is logged; once all attempts are exhausted the last error is rethrown so |
| 58 | + * callers can handle it as they would an unwrapped RPC failure. |
| 59 | + * |
| 60 | + * @param fn The async operation to execute. |
| 61 | + * @param options Retry/backoff configuration. |
| 62 | + */ |
| 63 | +export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> { |
| 64 | + const resolved = { ...DEFAULT_OPTIONS, ...options }; |
| 65 | + const operationName = options.operationName ?? 'rpc-call'; |
| 66 | + const isRetryable = options.isRetryable ?? ((): boolean => true); |
| 67 | + |
| 68 | + let lastError: unknown; |
| 69 | + |
| 70 | + for (let attempt = 0; attempt < resolved.maxAttempts; attempt += 1) { |
| 71 | + try { |
| 72 | + return await fn(); |
| 73 | + } catch (err) { |
| 74 | + lastError = err; |
| 75 | + const attemptNumber = attempt + 1; |
| 76 | + const isLastAttempt = attemptNumber >= resolved.maxAttempts; |
| 77 | + const message = err instanceof Error ? err.message : String(err); |
| 78 | + |
| 79 | + if (!isRetryable(err) || isLastAttempt) { |
| 80 | + logger.error( |
| 81 | + `[RPC Retry] ${operationName} failed permanently after ${attemptNumber} attempt(s) — error="${message}"`, |
| 82 | + ); |
| 83 | + throw err; |
| 84 | + } |
| 85 | + |
| 86 | + const delayMs = computeBackoffDelay(attempt, resolved); |
| 87 | + |
| 88 | + logger.warn( |
| 89 | + `[RPC Retry] ${operationName} attempt ${attemptNumber}/${resolved.maxAttempts} failed ` + |
| 90 | + `— error="${message}" — retrying in ${delayMs}ms`, |
| 91 | + ); |
| 92 | + |
| 93 | + await sleep(delayMs); |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + // Unreachable in practice (loop always returns or throws), kept for type safety. |
| 98 | + throw lastError; |
| 99 | +} |
0 commit comments