(fn: (attempt: number) => Promise<T>, opts: RetryOptions = {})
| 120 | * permanent). The first attempt has attempt index 0. |
| 121 | */ |
| 122 | export async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: RetryOptions = {}): Promise<T> { |
| 123 | const maxAttempts = opts.maxAttempts ?? 4; |
| 124 | const baseMs = opts.baseMs ?? 400; |
| 125 | const capMs = opts.capMs ?? 8000; |
| 126 | const retryable = opts.isRetryable ?? isTransientError; |
| 127 | const maxRetryAfterMs = opts.maxRetryAfterMs ?? 60_000; |
| 128 | const label = opts.label ?? 'op'; |
| 129 | |
| 130 | let lastErr: unknown; |
| 131 | for (let attempt = 0; attempt < maxAttempts; attempt++) { |
| 132 | if (opts.signal?.aborted) throw new Error('aborted'); |
| 133 | try { |
| 134 | return await fn(attempt); |
| 135 | } catch (err) { |
| 136 | lastErr = err; |
| 137 | const isLast = attempt === maxAttempts - 1; |
| 138 | if (isLast || !retryable(err)) throw err; |
| 139 | |
| 140 | // Compute wait: prefer server's Retry-After, else full-jitter backoff. |
| 141 | const server = retryAfterMs(err); |
| 142 | // A Retry-After longer than we're willing to block (e.g. a daily quota |
| 143 | // 429 asking for many minutes) → fail fast instead of hanging the turn. |
| 144 | if (server !== undefined && server > maxRetryAfterMs) { |
| 145 | logger.warn(`Retry ${label}: server Retry-After ${Math.round(server / 1000)}s exceeds cap ${Math.round(maxRetryAfterMs / 1000)}s — giving up (switch model/provider)`, { |
| 146 | status: statusOf(err), |
| 147 | }); |
| 148 | throw err; |
| 149 | } |
| 150 | const expo = Math.min(capMs, baseMs * 2 ** attempt); |
| 151 | const wait = server ?? Math.floor(Math.random() * expo); |
| 152 | logger.warn(`Retry ${label}`, { |
| 153 | attempt: attempt + 1, |
| 154 | maxAttempts, |
| 155 | waitMs: wait, |
| 156 | status: statusOf(err), |
| 157 | err: String((err as any)?.message ?? err).slice(0, 160), |
| 158 | }); |
| 159 | try { |
| 160 | await abortableSleep(wait, opts.signal); |
| 161 | } catch { |
| 162 | throw err; // aborted during backoff → surface the original error |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | throw lastErr; |
| 167 | } |
no test coverage detected