feat: add retry utility with exponential backoff#10
Conversation
Adds a generic retry helper for transient failures like API throttling and network errors. Supports configurable max retries, exponential backoff with jitter, and retryable error filtering.
40bf63f to
551bb0d
Compare
|
Bug: The loop is Options to fix:
|
|
Bug: sleeps after the final failed attempt. On the last iteration of the loop, if Fix: skip the sleep when |
|
PR description says "exponential backoff and jitter" but there is no jitter. The delay calculation Options:
|
|
Bug: const date = new Date(headerValue);
return date.getTime() - Date.now();
Either can end up passed to |
|
The stated use case in the PR description is "API calls that return throttling errors," which is exactly where
Otherwise the two pieces of this PR don't actually compose. |
|
AWS SDK v3 exposes error codes on Given the PR's stated goal ("Integration with API calls that return throttling errors"), this is likely to silently not retry the exact errors it's meant to handle. Options:
|
|
Missing: not exported from
|
|
Missing jitter despite PR description claiming it The PR summary says "exponential backoff and jitter", but the delay calculation is fully deterministic: const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);With no jitter, if many callers hit the same throttled API at once they will all retry in lock-step, which is exactly the thundering-herd problem jitter is meant to solve. Options:
See https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ for the tradeoffs. |
|
Unnecessary sleep after the final attempt On the last iteration of the loop, if Fix: skip the sleep on the final attempt, e.g. for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
// ... retryable check ...
if (attempt === opts.maxRetries - 1) break;
const delay = ...;
await sleep(delay);
}
}
throw lastError; |
|
export function parseRetryAfter(headerValue: string): number {
const seconds = parseInt(headerValue);
if (!isNaN(seconds)) {
return seconds * 1000;
}
const date = new Date(headerValue);
return date.getTime() - Date.now();
}Two problems for callers that pass this value into
Also minor: Suggested fix: clamp to export function parseRetryAfter(headerValue: string): number {
const trimmed = headerValue.trim();
if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000;
const ms = new Date(trimmed).getTime() - Date.now();
return Number.isFinite(ms) ? Math.max(0, ms) : 0;
} |
|
Missing jitter despite being advertised The PR summary says this adds "exponential backoff and jitter", but the delay calculation has no jitter: const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);If multiple clients hit a throttling error simultaneously, they will all retry in lockstep at exactly 1s, 2s, 4s, etc., which defeats much of the purpose of backoff. Options:
|
|
The field is named for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
try {
return await fn();
} ...
}With the default
Whichever you pick, please update the JSDoc so callers know which semantic applies. |
|
Unnecessary sleep on the final attempt On the last loop iteration, when Guard the sleep so it only runs when another attempt will follow, e.g.: if (attempt < opts.maxRetries - 1) {
const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
await sleep(delay);
} |
|
export function parseRetryAfter(headerValue: string): number {
const seconds = parseInt(headerValue);
if (!isNaN(seconds)) {
return seconds * 1000;
}
const date = new Date(headerValue);
return date.getTime() - Date.now();
}Two problems that will cause
Suggest clamping and handling the invalid case, e.g.: const ms = date.getTime() - Date.now();
return Number.isFinite(ms) ? Math.max(0, ms) : 0;and similarly |
Coverage Report
|
|
Issue: Sleeps after the final failed attempt before throwing. In for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
// ... retryability check ...
const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
await sleep(delay); // <-- runs even on the last attempt
}
}
throw lastError;Fix options:
|
|
Issue: No jitter is applied, despite the PR description claiming "exponential backoff and jitter". The delay calculation is purely deterministic: const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);There is no randomization, so multiple concurrent callers hitting the same throttled API will all retry in lockstep — which is exactly the thundering herd problem jitter is supposed to prevent. Either add jitter or update the PR description/docstring to not claim jitter. Common options:
|
|
Issue: The loop runs Fix options:
Either is fine, but the name and behavior should agree — otherwise callers will mis-configure it. |
|
Issue: export function parseRetryAfter(headerValue: string): number {
const seconds = parseInt(headerValue);
if (!isNaN(seconds)) {
return seconds * 1000;
}
const date = new Date(headerValue);
return date.getTime() - Date.now();
}Problems:
Fix options:
|
| const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs); | ||
| await sleep(delay); | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
- Add full jitter:
const delay = Math.random() * Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs); - Add equal/decorrelated jitter (see the classic AWS Architecture Blog post on backoff/jitter).
- If jitter isn't actually wanted, update the doc comment and PR description to drop the claim.
| await sleep(delay); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Unnecessary sleep after the final attempt. On the last iteration (attempt === maxRetries - 1), if fn() throws, the loop still runs await sleep(delay) before exiting and throwing lastError. That's wasted wall-clock time (up to maxDelayMs, i.e. 30s with defaults) with no retry following it.
Fix: skip the sleep on the last attempt, e.g.:
if (attempt < opts.maxRetries - 1) {
const delay = /* ... */;
await sleep(delay);
}|
|
||
| // Try parsing as HTTP date | ||
| const date = new Date(headerValue); | ||
| return date.getTime() - Date.now(); |
There was a problem hiding this comment.
parseRetryAfter can return negative or NaN milliseconds.
- If
headerValueis an HTTP date in the past (e.g., a slightly skewed clock),date.getTime() - Date.now()is negative. - If
headerValueis neither a number nor a parseable date,new Date(headerValue).getTime()isNaN, so the return value isNaN.
Callers are likely to pass the result straight to sleep() / setTimeout, where NaN or negatives get coerced to 0 (fine) or can cause bugs downstream in logic that compares against maxDelayMs, etc.
Suggested fix: clamp to >= 0 and fall back on invalid input, e.g.:
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 parseInt to satisfy common lint rules.)
| let lastError: Error | undefined; | ||
|
|
||
| for (let attempt = 0; attempt < opts.maxRetries; attempt++) { | ||
| try { |
There was a problem hiding this comment.
maxRetries semantics are misleading. With the current loop (for (let attempt = 0; attempt < opts.maxRetries; attempt++)), maxRetries: 3 means 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:
- Rename the option to
maxAttemptsso the field matches the behavior. - Keep the
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.
|
Bug: sleeps after the final failed attempt before throwing In Options to fix:
|
|
Missing jitter despite being advertised The PR summary says const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);Without jitter, concurrent callers that hit a throttling error will all retry in lockstep, which defeats much of the purpose for API throttling scenarios (one of the stated use cases). Please either add jitter (e.g. |
|
Two related issues in
Options to fix:
|
|
No tests added The PR's own test plan lists "Unit tests for retry logic" and "Integration with API calls that return throttling errors" as unchecked items, and no test file was added (the rest of |
Summary
withRetry()async helper with exponential backoff and jitterparseRetryAfter()for parsing HTTP Retry-After headersTest plan