Skip to content

feat!: add support for manual retries from hooks - #618

Open
cernymatej wants to merge 1 commit into
unjs:mainfrom
cernymatej:feat/retry-support
Open

feat!: add support for manual retries from hooks#618
cernymatej wants to merge 1 commit into
unjs:mainfrom
cernymatej:feat/retry-support

Conversation

@cernymatej

@cernymatej cernymatej commented Jul 29, 2026

Copy link
Copy Markdown

resolves #581
resolves #503 (PR #513)
resolves #358 (PR #359)
resolves #355
resolves #536 (PR #537)

Important

This PR is meant for the v2

This PR aims to resolve the above-mentioned issues by introducing a way to retry the current request based on custom logic in the interceptors. The PRs that are open are IMO not flexible enough to solve all the issues.

Interceptors can now start a retry by returning ctx.retry():

const api = $fetch.create({
  async onResponseError(ctx) {
    if (ctx.response.status === 401 && ctx.retries.count("auth") === 0) {
      await refreshSession()
      return ctx.retry({
        cause: "auth",
        options: { headers: { /* ... */ } },
      })
    }
  },
})

The replayed request goes through the whole pipeline again, so all interceptors re-run.

Every retry is recorded in ctx.retries:

ctx.retries.count("auth") // retries with a given cause
ctx.retries.count() // all retries
ctx.retries.last // { cause, trigger, status? }
ctx.retries.history // readonly RetryEntry[]

Merging / Cancelling

onResponseError: [
  // one layer starts the retry
  (ctx) => {
    if (isColdStart(ctx.response) && ctx.retries.count("warmup") < 2) {
      return ctx.retry({ cause: "warmup", delay: 1000 })
    }
  },
  // another one decorates any pending retry
  (ctx) => {
    if (ctx.pendingRetry) {
      return ctx.retry({
        delay: 3000, // longest delay wins
        options: { headers: { "x-retried-for": ctx.pendingRetry.cause! } },
      })
    }
  },
  // and a last one can still call it off
  (ctx) => {
    if (ctx.retries.count() >= 5) {
      ctx.cancelRetry()  // doesn't have to be returned
    }
  },
]
  • cause is immutable once set
  • headers merge per key
  • delay coerces to the longest
  • a retry targeting a different request than an already ctx.pendingRetry is ignored with an error log
  • ctx.retry() throws for stream bodies

Automatic retries

The declarative options (retry, retryDelay, retryStatusCodes) are unchanged, but the "budget" (retry, retryDelay) is reset after every manual retry. (500 -> auto retry -> 401 -> refresh -> 500 -> auto retry -> 200 works with retry: 1)

⚠️ Breaking changes

  • options.retry is no longer decremented between attempts
  • the error hooks and onResponse may now return a RetryIntent (return type widened from void)

Summary by CodeRabbit

  • New Features

    • Added configurable manual and automatic request retries through request and response hooks.
    • Added retry history, including causes, triggers, status codes, and delays.
    • Added controls to trigger or cancel pending retries.
    • Retry details are now available on fetch errors.
    • Timeouts now reset for each retry attempt.
  • Bug Fixes

    • Improved abort handling during retry delays and timeout scenarios.
    • Prevented retries for non-replayable stream request bodies.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Retry handling now supports typed retry intents and history, manual retries from hooks, automatic retry classification, per-attempt timeouts, abort-aware delays, and retry history exposure on errors. A comprehensive test suite covers retry lifecycle behavior and sleep().

Changes

Retry lifecycle

Layer / File(s) Summary
Retry contracts and utilities
src/types.ts, src/utils.ts
Adds retry history, intent, hook, and context types, plus helpers for delay handling, intent creation, option merging, and retry-hook processing.
Retry execution flow
src/fetch.ts, src/error.ts
Integrates manual and automatic retries into fetch attempts, applies fresh timeout signals per attempt, and exposes retry history on FetchError.
Retry lifecycle validation
test/retry.test.ts
Tests retry history, hook behavior, cancellation, aborts, timeouts, stream-body rejection, and abort-aware sleeping.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant doFetch
  participant RetryHooks
  participant Fetch
  participant ErrorHandler
  Caller->>doFetch: start request
  doFetch->>Fetch: execute attempt with timeout signal
  Fetch-->>doFetch: response or request error
  doFetch->>RetryHooks: run retry-capable hooks
  RetryHooks-->>doFetch: retry intent or no intent
  doFetch->>ErrorHandler: classify failure and inspect history
  ErrorHandler-->>doFetch: perform retry or return error
  doFetch->>Fetch: execute next attempt
  doFetch-->>Caller: response or FetchError with retries
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR covers manual hook retries, but it does not implement the requested retryBackoff/jitter option or a retryAttempt field for #581/#536. Add the retryBackoff option with jitter strategies and expose the current attempt on FetchContext, or clearly scope those issues out.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: enabling manual retries from hooks.
Out of Scope Changes check ✅ Passed No clear out-of-scope code changes were introduced; the additions support the retry feature and its tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

@cernymatej

Copy link
Copy Markdown
Author

some use case examples:

exponential backoff

await $fetch("/flaky", {
  retry: 5,
  // or just `ctx.retries.count()` if no other retries are present
  retryDelay: (ctx) => 100 * 2 ** ctx.retries.count('auto'),
});

simple polling

await $fetch(`/jobs/${id}`, {
  onResponse(ctx) {
    if (ctx.response._data?.pending && ctx.retries.count("poll") < 10) {
      return ctx.retry({ cause: "poll", delay: 1000 });
    }
  },
});

auth session refresh

await $fetch('/protected', {
  async onResponseError(ctx) {
    if (ctx.response.status === 401 && ctx.retries.count("auth") === 0) {
      await refreshSession()
      return ctx.retry({
        cause: "auth",
        options: { headers: { /* ... */ } },
      })
    }
  },
})

@cernymatej
cernymatej force-pushed the feat/retry-support branch from ccafc8d to 95d8d58 Compare July 29, 2026 20:10
@cernymatej
cernymatej marked this pull request as ready for review July 29, 2026 20:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
test/retry.test.ts (3)

175-198: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the console.error spy unconditionally.

If any assertion between lines 193–197 fails, mockRestore() at line 198 never runs and the mocked console.error leaks into the remaining tests in this file, hiding real errors. Prefer a global afterEach(() => vi.restoreAllMocks()) in this suite.

♻️ Suggested suite-level cleanup
   beforeEach(() => {
     authCalls = [];
   });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });

Then drop line 198.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/retry.test.ts` around lines 175 - 198, Ensure the console.error spy in
the retry test suite is restored unconditionally by adding suite-level afterEach
cleanup with vi.restoreAllMocks(). Remove the local consoleError.mockRestore()
call and preserve the existing assertions.

422-433: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tight timing margin makes this test flake-prone.

Three sequential 60 ms server delays under a 150 ms per-attempt timeout leaves only ~90 ms of slack per attempt for request overhead; a loaded CI runner can push a single attempt past 150 ms. Widening both values preserves the intent (cumulative 600 ms > 500 ms timeout still proves per-attempt semantics) with far more headroom.

♻️ Suggested widening
     const url = setSequence(
       "timeout-per-attempt",
       [500, 500, 200],
-      [60, 60, 60]
+      [200, 200, 200]
     );
     const res = await $fetch<{ calls: number }>(url, {
       retry: 2,
-      timeout: 150,
+      timeout: 500,
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/retry.test.ts` around lines 422 - 433, Widen the timing values in the
“applies the timeout to each attempt, not to the whole retry chain” test to
reduce CI flakiness while preserving the per-attempt semantics. Increase both
the timeout and each setSequence delay so the cumulative delay remains greater
than the timeout, and keep the expected three calls unchanged.

353-385: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the replay never fired.

Both tests verify the rejection and elapsed time, but not that the retry attempt was skipped. Adding a call-count check makes the abort-during-delay contract explicit.

💚 Suggested assertions
     await expect(promise).rejects.toThrow(/abort/i);
     expect(Date.now() - start).toBeLessThan(1000);
+    expect(sequences.get("abort-auto-delay")!.calls).toBe(1);

and likewise sequences.get("abort-manual-delay")!.calls in the manual-delay test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/retry.test.ts` around lines 353 - 385, Add assertions to both
abort-delay tests, using the sequence records from setSequence, to verify only
the initial request occurred: check sequences.get("abort-auto-delay")!.calls and
sequences.get("abort-manual-delay")!.calls after rejection. Preserve the
existing abort and elapsed-time assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/fetch.ts`:
- Around line 103-108: Update the retry delay calculation surrounding retryDelay
and sleep to implement the configured retryBackoff strategies: full jitter,
equal jitter, and decorrelated jitter, using the configured base and maximum
delays. Apply the documented precedence and coexistence rules between
retryBackoff and retryDelay, while preserving abort-signal handling and the
existing sleep flow.

In `@src/types.ts`:
- Around line 117-138: Mark the cause property readonly in both RetryEntry and
RetryIntent so recorded history and pending retry intents cannot be relabeled.
Leave the existing RetryHistory behavior and RetryCause type constraints
unchanged.
- Around line 94-104: Update the module augmentation example in the
documentation comment above the relevant types declaration so
FetchTypes.retryCause appears only once with the intended flexible
string-compatible type. Remove the conflicting duplicate property example while
preserving the explanation that strict and unrestricted string variants are
alternatives.

In `@src/utils.ts`:
- Around line 194-212: Centralize the stream-body replayability check in the
createRetryIntent flow, validating both context.options.body and any body
embedded in context.request while preserving the existing TypeError. In
src/utils.ts lines 194-212, expose or reuse a shared validation helper; in
src/fetch.ts lines 97-115, invoke it before recording or executing each
automatic retry so consumed Request streams are rejected consistently.

---

Nitpick comments:
In `@test/retry.test.ts`:
- Around line 175-198: Ensure the console.error spy in the retry test suite is
restored unconditionally by adding suite-level afterEach cleanup with
vi.restoreAllMocks(). Remove the local consoleError.mockRestore() call and
preserve the existing assertions.
- Around line 422-433: Widen the timing values in the “applies the timeout to
each attempt, not to the whole retry chain” test to reduce CI flakiness while
preserving the per-attempt semantics. Increase both the timeout and each
setSequence delay so the cumulative delay remains greater than the timeout, and
keep the expected three calls unchanged.
- Around line 353-385: Add assertions to both abort-delay tests, using the
sequence records from setSequence, to verify only the initial request occurred:
check sequences.get("abort-auto-delay")!.calls and
sequences.get("abort-manual-delay")!.calls after rejection. Preserve the
existing abort and elapsed-time assertions.
🪄 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 Plus

Run ID: e0f05072-7625-49a3-ad99-bea026bd9361

📥 Commits

Reviewing files that changed from the base of the PR and between 1dbc37f and 95d8d58.

📒 Files selected for processing (5)
  • src/error.ts
  • src/fetch.ts
  • src/types.ts
  • src/utils.ts
  • test/retry.test.ts

Comment thread src/fetch.ts
Comment thread src/types.ts
Comment thread src/types.ts
Comment on lines +117 to +138
export interface RetryEntry {
cause: RetryCause;
trigger: RetryTrigger;
status?: number;
}

export interface RetryHistory {
readonly history: readonly RetryEntry[];
readonly last: RetryEntry | undefined;
count(cause?: RetryCause): number;
}

export interface RetryIntent<T = any, R extends ResponseType = ResponseType> {
/**
* `'auto'` is reserved for automatic retries
* @default 'manual'
*/
cause?: Exclude<RetryCause, "auto">;
request?: FetchRequest;
options?: FetchOptions<R, T>;
delay?: number;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mark retry causes readonly to preserve cause-specific history.

Both recorded entries and pending intents expose an assignable cause. Hooks can therefore relabel pending or historical retries, changing count(cause) retroactively despite the immutable-cause contract.

Proposed fix
 export interface RetryEntry {
-  cause: RetryCause;
+  readonly cause: RetryCause;
   trigger: RetryTrigger;
   status?: number;
 }

 export interface RetryIntent<T = any, R extends ResponseType = ResponseType> {
-  cause?: Exclude<RetryCause, "auto">;
+  readonly cause?: Exclude<RetryCause, "auto">;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface RetryEntry {
cause: RetryCause;
trigger: RetryTrigger;
status?: number;
}
export interface RetryHistory {
readonly history: readonly RetryEntry[];
readonly last: RetryEntry | undefined;
count(cause?: RetryCause): number;
}
export interface RetryIntent<T = any, R extends ResponseType = ResponseType> {
/**
* `'auto'` is reserved for automatic retries
* @default 'manual'
*/
cause?: Exclude<RetryCause, "auto">;
request?: FetchRequest;
options?: FetchOptions<R, T>;
delay?: number;
}
export interface RetryEntry {
readonly cause: RetryCause;
trigger: RetryTrigger;
status?: number;
}
export interface RetryHistory {
readonly history: readonly RetryEntry[];
readonly last: RetryEntry | undefined;
count(cause?: RetryCause): number;
}
export interface RetryIntent<T = any, R extends ResponseType = ResponseType> {
/**
* `'auto'` is reserved for automatic retries
* `@default` 'manual'
*/
readonly cause?: Exclude<RetryCause, "auto">;
request?: FetchRequest;
options?: FetchOptions<R, T>;
delay?: number;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/types.ts` around lines 117 - 138, Mark the cause property readonly in
both RetryEntry and RetryIntent so recorded history and pending retry intents
cannot be relabeled. Leave the existing RetryHistory behavior and RetryCause
type constraints unchanged.

Comment thread src/utils.ts
Comment on lines +194 to +212
export function createRetryIntent<
T = any,
R extends ResponseType = ResponseType,
>(
context: FetchContext<T, R>,
input: RetryIntent<T, R> = {}
): RetryIntent<T, R> {
const body = context.options.body;
if (
body &&
(typeof (body as ReadableStream).pipeTo === "function" ||
typeof (body as any).pipe === "function")
) {
throw new TypeError(
"[ofetch] Cannot retry: request body is a stream and cannot be replayed."
);
}
return { ...input, [kRetryIntent]: true } as RetryIntent<T, R>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Centralize replayability validation across every retry path.

The current guard only covers manual retries whose body is present in context.options, leaving Request bodies and automatic retries able to replay consumed streams.

  • src/utils.ts#L194-L212: extract a shared check covering both context.options.body and bodies embedded in context.request.
  • src/fetch.ts#L97-L115: invoke that check before recording or executing an automatic retry.
📍 Affects 2 files
  • src/utils.ts#L194-L212 (this comment)
  • src/fetch.ts#L97-L115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils.ts` around lines 194 - 212, Centralize the stream-body
replayability check in the createRetryIntent flow, validating both
context.options.body and any body embedded in context.request while preserving
the existing TypeError. In src/utils.ts lines 194-212, expose or reuse a shared
validation helper; in src/fetch.ts lines 97-115, invoke it before recording or
executing each automatic retry so consumed Request streams are rejected
consistently.

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

Labels

None yet

Projects

None yet

1 participant