-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add retry utility with exponential backoff #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /** | ||
| * Generic retry utility for transient failures (API throttling, network errors, etc.) | ||
| */ | ||
|
|
||
| export interface RetryOptions { | ||
| maxRetries: number; | ||
| baseDelayMs: number; | ||
| maxDelayMs: number; | ||
| retryableErrors?: string[]; | ||
| } | ||
|
|
||
| const DEFAULT_OPTIONS: RetryOptions = { | ||
| maxRetries: 3, | ||
| baseDelayMs: 1000, | ||
| maxDelayMs: 30000, | ||
| }; | ||
|
|
||
| /** | ||
| * Retry an async operation with exponential backoff and jitter. | ||
| */ | ||
| export async function withRetry<T>( | ||
| fn: () => Promise<T>, | ||
| options: Partial<RetryOptions> = {}, | ||
| ): Promise<T> { | ||
| const opts = { ...DEFAULT_OPTIONS, ...options }; | ||
| let lastError: Error | undefined; | ||
|
|
||
| for (let attempt = 0; attempt < opts.maxRetries; attempt++) { | ||
| try { | ||
| return await fn(); | ||
| } catch (err) { | ||
| lastError = err as Error; | ||
|
|
||
| if (opts.retryableErrors && opts.retryableErrors.length > 0) { | ||
| const isRetryable = opts.retryableErrors.some((code) => lastError!.message.includes(code)); | ||
| if (!isRetryable) { | ||
| throw lastError; | ||
| } | ||
| } | ||
|
|
||
| const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs); | ||
| await sleep(delay); | ||
| } | ||
| } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing jitter. The PR description says "exponential backoff and jitter," but the delay calculation is purely deterministic: const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);Without jitter, concurrent callers that fail at the same time (e.g., a burst of API calls hitting a throttling limit) will all retry in lockstep, defeating much of the point of backoff for throttling scenarios. Options:
|
||
|
|
||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unnecessary sleep after the final attempt. On the last iteration ( Fix: skip the sleep on the last attempt, e.g.: if (attempt < opts.maxRetries - 1) {
const delay = /* ... */;
await sleep(delay);
} |
||
| throw lastError; | ||
| } | ||
|
|
||
| /** | ||
| * Parse a retry-after header value to milliseconds. | ||
| */ | ||
| export function parseRetryAfter(headerValue: string): number { | ||
| const seconds = parseInt(headerValue); | ||
| if (!isNaN(seconds)) { | ||
| return seconds * 1000; | ||
| } | ||
|
|
||
| // Try parsing as HTTP date | ||
| const date = new Date(headerValue); | ||
| return date.getTime() - Date.now(); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Callers are likely to pass the result straight to Suggested fix: clamp to export function parseRetryAfter(headerValue: string): number {
const seconds = parseInt(headerValue, 10);
if (!isNaN(seconds)) return Math.max(0, seconds * 1000);
const ms = new Date(headerValue).getTime() - Date.now();
return Number.isFinite(ms) ? Math.max(0, ms) : 0;
}(Also, pass a radix to |
||
| } | ||
|
|
||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maxRetriessemantics are misleading. With the current loop (for (let attempt = 0; attempt < opts.maxRetries; attempt++)),maxRetries: 3means 3 total attempts — i.e., 1 initial call + 2 retries — not 1 initial call + 3 retries as the name implies. Callers reading the type will almost certainly pick the wrong value.Pick one of:
maxAttemptsso the field matches the behavior.maxRetriesname and change the loop toattempt <= opts.maxRetries(somaxRetries: 3→ 4 total attempts).Either is fine, but the current name/behavior mismatch is a foot-gun worth resolving before the util gets adopted.