Skip to content

feat: enhance retry system with attempt tracking, retryCondition, and onRetry hook#549

Open
productdevbook wants to merge 2 commits intounjs:mainfrom
productdevbook:feat/retry-improvements
Open

feat: enhance retry system with attempt tracking, retryCondition, and onRetry hook#549
productdevbook wants to merge 2 commits intounjs:mainfrom
productdevbook:feat/retry-improvements

Conversation

@productdevbook
Copy link
Copy Markdown

@productdevbook productdevbook commented Mar 30, 2026

Summary

  • Add FetchRetryState ({ attempt, limit }) to FetchContext for retry tracking
  • Add retryCondition option for custom retry logic beyond status codes
  • Add onRetry hook called before each retry (e.g., token refresh)
  • Fix retry not checking custom retryStatusCodes for client errors

Changes

src/types.ts

  • New FetchRetryState interface with attempt and limit fields
  • retryCondition option: (context) => boolean | Promise<boolean>
  • onRetry hook in FetchHooks

src/fetch.ts

  • Retry state tracking via FetchRetryState instead of decrementing retry count
  • retryCondition evaluated alongside retryStatusCodes (retries if either matches)
  • onRetry hook called before each retry with full context
  • retryDelay callback now receives context with retry state

Usage examples

// Exponential backoff with attempt tracking
await $fetch('/api', {
  retry: 3,
  retryDelay(ctx) {
    return Math.pow(2, ctx.retry.attempt) * 1000
  },
})

// Custom retry condition
await $fetch('/api', {
  retry: 3,
  retryCondition(ctx) {
    return ctx.response?.headers.get('x-retry') === 'true'
  },
})

// Token refresh on retry
await $fetch('/api', {
  retry: 1,
  retryStatusCodes: [401],
  async onRetry(ctx) {
    const token = await refreshToken()
    ctx.options.headers.set('Authorization', `Bearer ${token}`)
  },
})

Test plan

  • Retry state exposed in onResponseError context
  • Retry state exposed in retryDelay callback
  • retryCondition callback triggers retry
  • retryCondition works alongside retryStatusCodes
  • onRetry hook called with correct attempt/limit
  • onRetry can modify request options (token refresh)
  • Abort signal prevents retry even with retryCondition
  • retryCondition receives error context for network failures
  • Type tests for FetchRetryState, retryCondition, onRetry
  • All existing tests pass (36 + 6 type tests)
  • Lint, typecheck, build pass

Resolves #536, #503, #358, #495

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Custom retry conditions: sync/async predicates can control retries (override status-code-only rules)
    • New onRetry hook: runs before each retry and can modify outgoing request options
    • Retry state exposed in request context (attempt count and limit)
    • retryDelay accepts function or value; network failures still consult retry logic; aborted requests are not retried
  • Tests

    • Added comprehensive tests covering retry behavior, hooks, delay, and type shapes

… onRetry hook

- Add `FetchRetryState` to `FetchContext` with `attempt` and `limit` fields
- Add `retryCondition` option for custom retry logic beyond status codes
- Add `onRetry` hook called before each retry attempt (e.g., token refresh)
- Fix retry not checking custom `retryStatusCodes` for client errors (unjs#495)
- `retryDelay` callback now receives full context with retry state (unjs#536)

Resolves unjs#536, unjs#503, unjs#358, unjs#495

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

The diff adds a retry-state object (FetchRetryState) exposed as context.retry, a retryCondition option for custom retry decisions, an onRetry hook invoked before each retry (receiving retry state), and updates fetch logic to track attempts, call hooks, compute delays, and re-invoke requests with retry metadata.

Changes

Cohort / File(s) Summary
Core fetch logic
src/fetch.ts
Added internal retry-state handling: extract/attach _retryState, initialize context.retry, evaluate retryLimit/currentAttempt, call onRetry, compute retryDelay, wait, and re-invoke $fetchRaw with retry metadata. Reworked onError retry gating to use retryCondition or retryStatusCodes.
Types & API surface
src/types.ts
Exported FetchRetryState (attempt, limit); added retryCondition to FetchOptions; added optional retry to FetchContext; added onRetry hook signature to FetchHooks (receives context & required retry state).
Tests
test/index.test.ts, test/types.test-d.ts
Added runtime tests for retry behavior (state exposure, retryCondition, network-failure handling, onRetry mutation, abort behavior) and type tests validating FetchRetryState, FetchContext.retry, retryDelay/retryCondition signatures, and onRetry hook typing.

Sequence Diagram

sequenceDiagram
    participant Client as Client/Request
    participant FetchHandler as Fetch Handler
    participant Hooks as Hooks (onError/onRetry/onResponseError)
    participant Retry as Retry Logic

    Client->>FetchHandler: Send request with retry options
    FetchHandler->>FetchHandler: Perform initial request
    FetchHandler->>Retry: Error or retryable response observed
    Retry->>Retry: Evaluate retryCondition OR retryStatusCodes
    alt Should retry
        Retry->>FetchHandler: Set context.retry { attempt, limit }
        Retry->>Hooks: Call onRetry hooks with context.retry
        Hooks->>Hooks: Optionally mutate request options
        Retry->>Hooks: Compute delay via retryDelay(context)
        Hooks-->>Retry: Return delay
        Retry->>Retry: Wait delay
        Retry->>FetchHandler: Re-invoke $fetchRaw with _retryState
        FetchHandler->>FetchHandler: Execute retry request (loop as needed)
    else Do not retry
        FetchHandler-->>Client: Propagate error/response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble code and count each try,

attempt one, then two—oh my!
A gentle hop, a backoff beat,
onRetry tunes my tiny feet.
Retry state guides each repeat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main changes: enhanced retry system with attempt tracking via FetchRetryState, retryCondition callback, and onRetry hook.
Linked Issues check ✅ Passed The PR fully addresses the core requirement from #536 to expose retry attempt information in FetchContext for implementing exponential backoff and custom retry logic.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the stated objectives: FetchRetryState exposure, retryCondition implementation, onRetry hook addition, and enhanced retry state tracking.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fetch.ts`:
- Line 138: The initial retry state uses a default { attempt: 0, limit: 0 }
which misreports limit to hooks; change the initialization of retry (where you
currently set retry: _retryState ?? { attempt: 0, limit: 0 }) to compute the
limit from context.options.retry when _retryState is null/undefined (e.g., set
limit: context.options.retry ?? someFallback) so hooks like
onRequest/onResponseError see the real configured limit; update the assignment
that references _retryState and ensure any helper that reads retry still expects
{ attempt, limit } shape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08ab19b1-b9b0-48e2-9f8f-d758208c90b4

📥 Commits

Reviewing files that changed from the base of the PR and between dfbe3ca and b551fb2.

📒 Files selected for processing (4)
  • src/fetch.ts
  • src/types.ts
  • test/index.test.ts
  • test/types.test-d.ts

…ct retry.limit init

- Restore fallback to 500 for network errors (no response) to maintain
  backward-compatible retry behavior
- Change retryCondition to replacement semantics: when provided, it
  decides whether to retry (replaces status code check entirely)
- Initialize retry.limit correctly on first request (was 0, now computed)
- Add tests for network error retry, retryCondition suppression, limit tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/index.test.ts (1)

610-617: Reduce potential flakiness in network-error tests.

Using http://localhost:1 can be environment-dependent. Consider creating a temporary local TCP server, capturing its ephemeral port, closing it, then using that closed port as the target URL for deterministic connection-refused behavior (Line 611 and Line 668).

Also applies to: 666-680

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index.test.ts` around lines 610 - 617, The current tests use a hardcoded
port (http://localhost:1) which is flaky; change both tests (the "retries
network errors by default (no response)" test and the other test around the same
area) to create a temporary TCP server via Node's net.createServer(), listen(0)
to get an ephemeral port, capture server.address().port, close() the server to
free the port, then use the closed port URL (e.g. http://127.0.0.1:${port}) as
the $fetch target so the connection is deterministically refused; update the
test setup where $fetch is called (the test name and the other nearby test
referencing a closed-port connection) to use this pattern and clean up the
server before asserting fetch call counts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/index.test.ts`:
- Around line 610-617: The current tests use a hardcoded port
(http://localhost:1) which is flaky; change both tests (the "retries network
errors by default (no response)" test and the other test around the same area)
to create a temporary TCP server via Node's net.createServer(), listen(0) to get
an ephemeral port, capture server.address().port, close() the server to free the
port, then use the closed port URL (e.g. http://127.0.0.1:${port}) as the $fetch
target so the connection is deterministically refused; update the test setup
where $fetch is called (the test name and the other nearby test referencing a
closed-port connection) to use this pattern and clean up the server before
asserting fetch call counts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3c68612f-3f0f-4360-8f22-1fa33baa3419

📥 Commits

Reviewing files that changed from the base of the PR and between b551fb2 and 910b813.

📒 Files selected for processing (3)
  • src/fetch.ts
  • src/types.ts
  • test/index.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/types.ts
  • src/fetch.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add retryAttempt to FetchContext

1 participant