Skip to content
chiltepin

Generated from: “Explain the change we made to the retry helper so reviewers can see exactly what moved, with the new function they should call.

Retry helper — what moved

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTREVIEW

Retry helper — what moved

The old retry() became withRetry(); the helper moved to src/lib/retry/ and gained backoff, jitter, and an error filter.

SECTION 01 · Note

Assumptions

Note
The request did not name the helper or its language. This doc assumes a TypeScript helper at src/lib/retry.ts named retry(fn, attempts, delayMs), and a new one named withRetry(fn, options) in src/lib/retry/index.ts. Defaults (3 attempts, 200 ms base, 5 s cap, full jitter) are placeholders; replace them with the merged values before you review.

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.

SECTION 02 · Code
Before — src/lib/retry.tsTypeScript
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;
}
After — src/lib/retry/index.tsTypeScript
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

SECTION 03 · Spec

withRetry(fn, options)

Import
import { withRetry } from "@/lib/retry";
Signature
withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>
attempts
Total calls including the first. Default 3.
baseDelayMs
Delay before the second attempt. Default 200.
maxDelayMs
Cap on any single delay. Default 5000.
jitter
full | none. Default full: the delay is a random value in [0, computed delay].
retryOn
(err: unknown) => boolean. Default isTransientError: network errors, timeouts, HTTP 408, 429, 502, 503, 504.
signal
Optional AbortSignal. Checked between attempts, never mid-call.
Delay formula
delay = baseDelayMs * 2^(attempt - 1)delay = min(delay, maxDelayMs)jitter full: delay = random(0, delay)
Throws
The last error unchanged. The helper never wraps it.

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

SECTION 04 · Flowchart

withRetry on a failed attempt

FLOW
Flowchart: 10 stepsCall fn()Resolved?Return valueretryOn(err)?Attempts left?signal aborted?SleepbackoffDelay(attempt)Throw errThrow last errThrow AbortError123456789
1yes2no3no4yes5no6yes7yes8no9attempt + 1
Legendstartstepdecision (diamond)exitnexterror pathhappy path

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

SECTION 05 · Steps

Replace retry() with withRetry()

  1. Change the import

    The old path still resolves through the deprecated wrapper, but the new path avoids the warning.

    TypeScript
    import { withRetry } from "@/lib/retry";
  2. Move 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 });
    
  3. Decide 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.

    TypeScript
    await 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.

  4. Pass the request signal when there is one

    Without a signal a cancelled request still runs its remaining attempts.

    TypeScript
    await withRetry(() => fetchUser(id), { signal: req.signal });
  5. Run the retry tests

    The suite asserts the delay sequence with a fake clock and the three exit paths.

    bash
    pnpm 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.

View the Markdown
```meta
title: Retry helper — what moved
subtitle: The old retry() became withRetry(); the helper moved to src/lib/retry/ and gained backoff, jitter, and an error filter.
tag: REVIEW
```

```callout
tone: note
title: Assumptions
body: "The request did not name the helper or its language. This doc assumes a TypeScript helper at src/lib/retry.ts named retry(fn, attempts, delayMs), and a new one named withRetry(fn, options) in src/lib/retry/index.ts. Defaults (3 attempts, 200 ms base, 5 s cap, full jitter) are placeholders; replace them with the merged values before you review."
```

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.

```code
kind: compare
lines: true
blocks:
  - title: "Before — src/lib/retry.ts"
    lang: TypeScript
    highlight: "1-2,7-8"
    code: |
      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;
      }
  - title: "After — src/lib/retry/index.ts"
    lang: TypeScript
    highlight: "1-3,10-14"
    code: |
      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

```spec
title: withRetry(fn, options)
accent: teal
rows:
  - { label: Import, value: "import { withRetry } from \"@/lib/retry\";" }
  - { label: Signature, value: "withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>" }
  - { label: attempts, value: "Total calls including the first. Default 3." }
  - { label: baseDelayMs, value: "Delay before the second attempt. Default 200." }
  - { label: maxDelayMs, value: "Cap on any single delay. Default 5000." }
  - { label: jitter, value: "full | none. Default full: the delay is a random value in [0, computed delay]." }
  - { label: retryOn, value: "(err: unknown) => boolean. Default isTransientError: network errors, timeouts, HTTP 408, 429, 502, 503, 504." }
  - { label: signal, value: "Optional AbortSignal. Checked between attempts, never mid-call." }
  - { label: Delay formula, steps: ["delay = baseDelayMs * 2^(attempt - 1)", "delay = min(delay, maxDelayMs)", "jitter full: delay = random(0, delay)"] }
  - { label: Throws, value: "The last error unchanged. The helper never wraps it." }
```

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

```flow
title: withRetry on a failed attempt
dir: LR
nodes:
  - { id: call, col: 1, row: 1, kind: start, label: Call fn() }
  - { id: ok, col: 2, row: 1, kind: decision, label: "Resolved?" }
  - { id: done, col: 3, row: 1, kind: end, label: Return value }
  - { id: transient, col: 2, row: 2, kind: decision, label: "retryOn(err)?" }
  - { id: left, col: 3, row: 2, kind: decision, label: "Attempts left?" }
  - { id: aborted, col: 4, row: 2, kind: decision, label: "signal aborted?" }
  - { id: wait, col: 5, row: 2, kind: process, label: Sleep backoffDelay(attempt) }
  - { id: throwNow, col: 2, row: 3, kind: end, label: Throw err }
  - { id: throwLast, col: 3, row: 3, kind: end, label: Throw last err }
  - { id: throwAbort, col: 4, row: 3, kind: end, label: Throw AbortError }
edges:
  - call -> ok
  - ok -> done: "yes"
  - ok --> transient: "no"
  - transient -x-> throwNow: "no"
  - transient -> left: "yes"
  - left -x-> throwLast: "no"
  - left -> aborted: "yes"
  - aborted -x-> throwAbort: "yes"
  - aborted -> wait: "no"
  - wait -> call: "attempt + 1"
```

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

```steps
title: Replace retry() with withRetry()
items:
  - title: Change the import
    body: The old path still resolves through the deprecated wrapper, but the new path avoids the warning.
    code: import { withRetry } from "@/lib/retry";
    lang: TypeScript
  - title: Move positional arguments into options
    body: attempts keeps its meaning. delayMs becomes baseDelayMs and now grows per attempt.
    code: |
      // before
      await retry(() => client.get(url), 5, 100);
      // after
      await withRetry(() => client.get(url), { attempts: 5, baseDelayMs: 100 });
    lang: TypeScript
  - title: Decide which errors are worth a retry
    body: The default filter skips 4xx errors except 408 and 429. Pass retryOn only when the call needs a different rule.
    code: "await withRetry(() => db.query(sql), { retryOn: (e) => e instanceof DeadlockError });"
    lang: TypeScript
    note: "A call that relied on retrying every error must pass retryOn: () => true to keep that behaviour."
  - title: Pass the request signal when there is one
    body: Without a signal a cancelled request still runs its remaining attempts.
    code: "await withRetry(() => fetchUser(id), { signal: req.signal });"
    lang: TypeScript
  - title: Run the retry tests
    body: The suite asserts the delay sequence with a fake clock and the three exit paths.
    code: pnpm test src/lib/retry
    lang: bash
```

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.