Skip to content

feat(shared): Add cancellation and per-error delay options to retry#9182

Open
wobsoriano wants to merge 4 commits into
mainfrom
rob/shared-retry-cancellation
Open

feat(shared): Add cancellation and per-error delay options to retry#9182
wobsoriano wants to merge 4 commits into
mainfrom
rob/shared-retry-cancellation

Conversation

@wobsoriano

@wobsoriano wobsoriano commented Jul 16, 2026

Copy link
Copy Markdown
Member

Description

Follow-up to #9173, which added a hand-rolled exponential backoff to Expo's initialization resource recovery because the shared retry helper couldn't express two things it needed. This adds them:

  • signal?: AbortSignal: cancels retrying. Aborting rejects the returned promise with the signal's abort reason and immediately interrupts a pending backoff delay. It does not abort a callback that is already executing.
  • initialDelay now also accepts a function (error, iteration) => number, deriving the backoff base delay from the error that triggered the retry (e.g. 2s for network errors, 10s for 5xx). The exponential factor, cap, and jitter still apply.

Both changes are optional and additive. Defaults and existing behavior are unchanged, and no current retry consumer is affected.

A follow-up PR will migrate the Expo recovery logic from #9173 onto this helper, gaining jitter on its retry delays.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • New Features

    • Retry operations can now be cancelled using an AbortSignal.
    • Retry delays can be calculated dynamically based on the error and retry attempt.
    • Pending retry delays stop promptly when cancellation occurs.
  • Bug Fixes

    • Cancelled retries now reject with the appropriate abort reason and prevent further attempts.

Adds an optional signal (AbortSignal) option so callers can cancel
retrying promptly, and allows initialDelay to be a function deriving
the backoff base delay from the error that triggered the retry.
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eb3289e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 23 packages
Name Type
@clerk/shared Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/chrome-extension Patch
@clerk/clerk-js Patch
@clerk/electron Patch
@clerk/expo-passkeys Patch
@clerk/expo Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/headless Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/react Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/ui Patch
@clerk/vue Patch
@clerk/swingset Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jul 16, 2026 8:30pm
swingset Ready Ready Preview, Comment Jul 16, 2026 8:30pm

Request Review

The `retry` helper now supports cancellation and error-dependent backoff. Added `signal` option for cancellation and modified `initialDelay` to accept a function for backoff base delay.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The shared retry helper now supports AbortSignal cancellation, abort-aware delays, and error-dependent initial backoff calculation. Tests cover pre-aborted signals, cancellation during delays, and dynamic delay bases, with a changeset documenting the patch.

Changes

Retry cancellation and dynamic backoff

Layer / File(s) Summary
Retry options and abort-aware backoff
packages/shared/src/retry.ts
retry accepts an AbortSignal, interrupts pending delays, stops subsequent attempts after cancellation, and supports function-based initialDelay values derived from errors and iterations.
Retry behavior validation and release guidance
packages/shared/src/__tests__/retry.spec.ts, .changeset/shared-retry-cancellation.md
Tests cover pre-aborted and during-delay cancellation plus error-derived backoff delays; the changeset records the updated retry behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant retry
  participant Callback
  participant Delay
  participant AbortSignal

  Caller->>retry: Start retry with options
  retry->>Callback: Execute attempt
  Callback-->>retry: Throw error
  retry->>Delay: Schedule error-derived backoff
  AbortSignal-->>Delay: Abort with reason
  Delay-->>retry: Reject pending delay
  retry-->>Caller: Reject with abort reason
Loading

Poem

I’m a bunny with retries tucked tight,
Abort hops in and ends the night.
Errors tune the delays just right,
Tests watch each timer’s flight.
Patch notes bloom in code’s moonlight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding cancellation and per-error delay support to the shared retry helper.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9182

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9182

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9182

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9182

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9182

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9182

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9182

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9182

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9182

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9182

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9182

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9182

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9182

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9182

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9182

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9182

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9182

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9182

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9182

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9182

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9182

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9182

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9182

commit: eb3289e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@packages/shared/src/retry.ts`:
- Around line 5-11: Update the JSDoc for the public initialDelay callback
parameter in retry configuration to explicitly state that iteration starts at 1,
clarifying its indexing semantics without changing the callback type or
behavior.
- Around line 138-142: Update the retry loop around the callback’s catch
handling to recheck signal cancellation immediately after the callback rejects,
before returning the original error or invoking retry hooks; when aborted, throw
abortReason(signal) to preserve the cancellation contract. Add a regression test
covering a callback that aborts the signal while running and then rejects.
🪄 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: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: fa8bdc4b-3b48-4f3e-8b60-0c1aca0aa78a

📥 Commits

Reviewing files that changed from the base of the PR and between b0ed361 and 401b36c.

📒 Files selected for processing (3)
  • .changeset/shared-retry-cancellation.md
  • packages/shared/src/__tests__/retry.spec.ts
  • packages/shared/src/retry.ts

Comment on lines +5 to +11
* The base delay of the exponential backoff: either milliseconds, or a function
* deriving them from the error that triggered the retry and the iteration number.
* The exponential factor, max delay, and jitter still apply.
*
* @default 125
*/
initialDelay: Milliseconds;
initialDelay: Milliseconds | ((error: unknown, iteration: number) => Milliseconds);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document that iteration starts at 1.

This new public callback parameter otherwise has ambiguous indexing semantics.

Proposed documentation update
-   * deriving them from the error that triggered the retry and the iteration number.
+   * deriving them from the error that triggered the retry and the iteration number,
+   * starting at 1 for the first retry.

The Docs team may also need to review this generated reference-doc change. As per path instructions, JSDoc on public/reference-facing APIs is customer-facing documentation.

📝 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
* The base delay of the exponential backoff: either milliseconds, or a function
* deriving them from the error that triggered the retry and the iteration number.
* The exponential factor, max delay, and jitter still apply.
*
* @default 125
*/
initialDelay: Milliseconds;
initialDelay: Milliseconds | ((error: unknown, iteration: number) => Milliseconds);
/**
* The base delay of the exponential backoff: either milliseconds, or a function
* deriving them from the error that triggered the retry and the iteration number,
* starting at 1 for the first retry.
* The exponential factor, max delay, and jitter still apply.
*
* `@default` 125
*/
initialDelay: Milliseconds | ((error: unknown, iteration: number) => Milliseconds);
🤖 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 `@packages/shared/src/retry.ts` around lines 5 - 11, Update the JSDoc for the
public initialDelay callback parameter in retry configuration to explicitly
state that iteration starts at 1, clarifying its indexing semantics without
changing the callback type or behavior.

Source: Path instructions

Comment on lines 138 to +142
while (true) {
if (signal?.aborted) {
throw abortReason(signal);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recheck cancellation immediately after the callback rejects.

If the signal aborts while callback is running, the catch block can return the original error or invoke retry hooks before observing cancellation. This violates the documented guarantee that cancellation rejects with the abort reason.

Proposed fix
     } catch (e) {
+      if (signal?.aborted) {
+        throw abortReason(signal);
+      }
+
       iterations++;

Add a regression test where the signal aborts during an active callback and that callback subsequently rejects.

📝 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
while (true) {
if (signal?.aborted) {
throw abortReason(signal);
}
if (signal?.aborted) {
throw abortReason(signal);
}
🤖 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 `@packages/shared/src/retry.ts` around lines 138 - 142, Update the retry loop
around the callback’s catch handling to recheck signal cancellation immediately
after the callback rejects, before returning the original error or invoking
retry hooks; when aborted, throw abortReason(signal) to preserve the
cancellation contract. Add a regression test covering a callback that aborts the
signal while running and then rejects.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-16T20:38:46.758Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on eb3289e.

@wobsoriano wobsoriano requested review from a team and mikepitre and removed request for a team July 16, 2026 19:40
Comment on lines 144 to 145
try {
return await callback();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/retry.ts:144

If signal aborts while callback() is in flight and the callback then succeeds, retry returns the result normally instead of rejecting with the abort reason. This contradicts the documented contract that aborting rejects the returned promise. The loop awaits the callback at await callback() and returns immediately without rechecking signal.aborted, so a slow successful attempt resolves after cancellation. Recheck signal?.aborted after the await and throw abortReason(signal) before returning.

Suggested change
try {
return await callback();
try {
const result = await callback();
if (signal?.aborted) {
throw abortReason(signal);
}
return result;
} catch (e) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/retry.ts around lines 144-145:

If `signal` aborts while `callback()` is in flight and the callback then succeeds, `retry` returns the result normally instead of rejecting with the abort reason. This contradicts the documented contract that aborting rejects the returned promise. The loop awaits the callback at `await callback()` and returns immediately without rechecking `signal.aborted`, so a slow successful attempt resolves after cancellation. Recheck `signal?.aborted` after the await and throw `abortReason(signal)` before returning.

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.

1 participant