* Execute an operation with retry logic and exponential backoff * @template T * @param {() => Promise } operation - The async operation to execute * @param {Partial } [config] - Retry configuration (optional) * @param {string} [operationName] - Name of the operation for logging *
(operation, config = {}, operationName = "operation")
| 165 | * @throws {Error} If all retry attempts fail |
| 166 | */ |
| 167 | async function withRetry(operation, config = {}, operationName = "operation") { |
| 168 | const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; |
| 169 | let lastError; |
| 170 | let delay = fullConfig.initialDelayMs; |
| 171 | |
| 172 | for (let attempt = 0; attempt <= fullConfig.maxRetries; attempt++) { |
| 173 | try { |
| 174 | if (attempt > 0) { |
| 175 | const jitter = fullConfig.jitterMs > 0 ? Math.floor(Math.random() * fullConfig.jitterMs) : 0; |
| 176 | const delayWithJitter = delay + jitter; |
| 177 | core.info(`Retry attempt ${attempt}/${fullConfig.maxRetries} for ${operationName} after ${delayWithJitter}ms delay`); |
| 178 | logRetryEvent(lastError, operationName, attempt, delayWithJitter); |
| 179 | await sleep(delayWithJitter); |
| 180 | } |
| 181 | |
| 182 | const result = await operation(); |
| 183 | |
| 184 | if (attempt > 0) { |
| 185 | core.info(`✓ ${operationName} succeeded on retry attempt ${attempt}`); |
| 186 | } |
| 187 | |
| 188 | return result; |
| 189 | } catch (error) { |
| 190 | lastError = error; |
| 191 | const errorMsg = getErrorMessage(error); |
| 192 | |
| 193 | // Check if this error should be retried |
| 194 | if (!fullConfig.shouldRetry(error)) { |
| 195 | core.debug(`${operationName} failed with non-retryable error: ${errorMsg}`); |
| 196 | throw enhanceError(error, { |
| 197 | operation: operationName, |
| 198 | attempt: attempt + 1, |
| 199 | retryable: false, |
| 200 | suggestion: "This error cannot be resolved by retrying. Please check the error details and fix the underlying issue.", |
| 201 | }); |
| 202 | } |
| 203 | |
| 204 | // If this was the last attempt, throw the enhanced error |
| 205 | if (attempt === fullConfig.maxRetries) { |
| 206 | core.warning(`${operationName} failed after ${fullConfig.maxRetries} retry attempts: ${errorMsg}`); |
| 207 | throw enhanceError(error, { |
| 208 | operation: operationName, |
| 209 | attempt: attempt + 1, |
| 210 | maxRetries: fullConfig.maxRetries, |
| 211 | retryable: true, |
| 212 | suggestion: "All retry attempts exhausted. This may be a persistent issue. Check GitHub status or try again later.", |
| 213 | }); |
| 214 | } |
| 215 | |
| 216 | // Log the retry attempt |
| 217 | core.warning(`${operationName} failed (attempt ${attempt + 1}/${fullConfig.maxRetries + 1}): ${errorMsg}`); |
| 218 | |
| 219 | // Calculate next delay: honour Retry-After header when present, otherwise |
| 220 | // use exponential backoff. Either way the result is capped at maxDelayMs. |
| 221 | const retryAfterMs = getRetryAfterMs(error); |
| 222 | if (retryAfterMs !== null) { |
| 223 | const cappedDelay = Math.min(retryAfterMs, fullConfig.maxDelayMs); |
| 224 | if (cappedDelay < retryAfterMs) { |