(
operation: () => Promise<T>,
options: RetryOptions = {}
)
| 96 | * Executes a function with exponential backoff retry logic |
| 97 | */ |
| 98 | export async function retryWithExponentialBackoff<T>( |
| 99 | operation: () => Promise<T>, |
| 100 | options: RetryOptions = {} |
| 101 | ): Promise<T> { |
| 102 | const { |
| 103 | maxRetries = 5, |
| 104 | initialDelayMs = 1000, |
| 105 | maxDelayMs = 30000, |
| 106 | backoffMultiplier = 2, |
| 107 | retryCondition = isRetryableError, |
| 108 | } = options |
| 109 | |
| 110 | let lastError: Error | undefined |
| 111 | let delay = initialDelayMs |
| 112 | |
| 113 | for (let attempt = 0; attempt <= maxRetries; attempt++) { |
| 114 | try { |
| 115 | logger.debug(`Executing operation attempt ${attempt + 1}/${maxRetries + 1}`) |
| 116 | const result = await operation() |
| 117 | |
| 118 | if (attempt > 0) { |
| 119 | logger.info(`Operation succeeded after ${attempt + 1} attempts`) |
| 120 | } |
| 121 | |
| 122 | return result |
| 123 | } catch (error) { |
| 124 | lastError = toError(error) |
| 125 | logger.warn(`Operation failed on attempt ${attempt + 1}`, { error }) |
| 126 | |
| 127 | // If this is the last attempt, throw the error |
| 128 | if (attempt === maxRetries) { |
| 129 | logger.error(`Operation failed after ${maxRetries + 1} attempts`, { error }) |
| 130 | throw lastError |
| 131 | } |
| 132 | |
| 133 | // Check if error is retryable |
| 134 | if (!retryCondition(error as RetryableError)) { |
| 135 | logger.warn('Error is not retryable, throwing immediately', { error }) |
| 136 | throw lastError |
| 137 | } |
| 138 | |
| 139 | // Use Retry-After if the server told us how long to wait, otherwise exponential backoff. |
| 140 | // Cap Retry-After at maxDelayMs to bound total retry duration (matches Google Cloud SDK behavior). |
| 141 | const retryAfterMs = (lastError as HTTPError)?.retryAfterMs |
| 142 | const cappedRetryAfter = retryAfterMs ? Math.min(retryAfterMs, maxDelayMs) : undefined |
| 143 | |
| 144 | if (retryAfterMs && retryAfterMs > maxDelayMs) { |
| 145 | logger.warn( |
| 146 | `Retry-After ${retryAfterMs}ms exceeds maxDelayMs ${maxDelayMs}ms — capping to ${maxDelayMs}ms` |
| 147 | ) |
| 148 | } |
| 149 | |
| 150 | const jitter = randomFloat() * 0.1 * delay |
| 151 | const actualDelay = cappedRetryAfter ?? Math.min(delay + jitter, maxDelayMs) |
| 152 | |
| 153 | logger.info( |
| 154 | `Retrying in ${Math.round(actualDelay)}ms (attempt ${attempt + 1}/${maxRetries + 1})${cappedRetryAfter ? ' (Retry-After)' : ''}` |
| 155 | ) |
no test coverage detected