Retry helper — what moved
The old retry() became withRetry(); the helper moved to src/lib/retry/ and gained backoff, jitter, and an error filter.
Assumptions
The old helper retried every error with one fixed delay. Under load that turned a slow dependency into a synchronized storm of retries, and it retried errors that can never succeed, such as a 400. The change moves the helper into its own folder and replaces the positional arguments with an options object. It adds exponential backoff with jitter, and a predicate that says which errors are worth a second try.
What moved
The file src/lib/retry.ts is now the folder src/lib/retry/. The loop lives in index.ts; the delay math moved to backoff.ts; the transient-error test moved from src/http/errors.ts to classify.ts. The old retry export stays for one release as a wrapper that logs a deprecation warning.
export async function retry<T>(fn: () => Promise<T>, attempts = 3, delayMs = 200): Promise<T> { let lastError: unknown; for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { lastError = err; await sleep(delayMs); } } throw lastError; }
import { backoffDelay } from "./backoff"; import { isTransientError } from "./classify"; export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> { const opts = { ...DEFAULTS, ...options }; let lastError: unknown; for (let attempt = 1; attempt <= opts.attempts; attempt++) { try { return await fn(); } catch (err) { lastError = err; if (!opts.retryOn(err) || attempt === opts.attempts) throw err; opts.signal?.throwIfAborted(); await sleep(backoffDelay(attempt, opts)); } } throw lastError; }
Two behaviours changed, not only names. A non-transient error now throws on the first attempt instead of after attempts tries. An AbortSignal stops the loop between attempts, so a cancelled request no longer keeps a worker busy.
The function to call
withRetry(fn, options)
The defaults give delays of about 200 ms, 400 ms, and 800 ms before jitter. Callers that need more than three attempts must say so. The old helper had the same default, so an unchanged call keeps its attempt count.
What one call does on failure
withRetry on a failed attempt
The three exits throw three different things. retryOn false throws the original error at once. Running out of attempts throws the last error. An aborted signal throws the signal's reason, so callers can tell cancellation from failure.
Migrate a call site
Replace retry() with withRetry()
- 1Change the import
The old path still resolves through the deprecated wrapper, but the new path avoids the warning.
TypeScriptimport { withRetry } from "@/lib/retry"; - 2Move positional arguments into options
attempts keeps its meaning. delayMs becomes baseDelayMs and now grows per attempt.
TypeScript// before await retry(() => client.get(url), 5, 100); // after await withRetry(() => client.get(url), { attempts: 5, baseDelayMs: 100 }); - 3Decide which errors are worth a retry
The default filter skips 4xx errors except 408 and 429. Pass retryOn only when the call needs a different rule.
TypeScriptawait withRetry(() => db.query(sql), { retryOn: (e) => e instanceof DeadlockError });A call that relied on retrying every error must pass retryOn: () => true to keep that behaviour.
- 4Pass the request signal when there is one
Without a signal a cancelled request still runs its remaining attempts.
TypeScriptawait withRetry(() => fetchUser(id), { signal: req.signal }); - 5Run the retry tests
The suite asserts the delay sequence with a fake clock and the three exit paths.
bashpnpm test src/lib/retry
Call sites that keep using retry() work until the next minor release, when the wrapper is removed. The deprecation warning names the file and line, so the remaining sites are easy to find in the logs.