Skip to content

Implement Phase 3 retry behavior and type refactors#2

Merged
adelrodriguez merged 1 commit intomainfrom
02-22-add_async_support_to_run_method_with_error_mapping
Feb 23, 2026
Merged

Implement Phase 3 retry behavior and type refactors#2
adelrodriguez merged 1 commit intomainfrom
02-22-add_async_support_to_run_method_with_error_mapping

Conversation

@adelrodriguez
Copy link
Copy Markdown
Owner

@adelrodriguez adelrodriguez commented Feb 22, 2026

Summary

  • completes Phase 3 by implementing retry execution in executeRun, including attempt limits, shouldRetry, and backoff delays (constant, linear, exponential with maxDelayMs)
  • adds RetryExhaustedError flow and ensures control errors (Panic, TimeoutError, CancellationError) are not retried or catch-mapped
  • keeps numeric retry shorthand as constant backoff and requires explicit backoff in object-form retry policies
  • aligns builder/types/docs with the retry and timeout type refactor (RetryOptions, TimeoutPolicy) and updates design notes
  • expands unit coverage for retry success, exhaustion, predicate gating, control-error behavior, and backoff timing formulas

Validation

  • bun run format
  • bun run check
  • bun run typecheck
  • bun run test

Summary by CodeRabbit

  • New Features

    • Configurable retry system with multiple backoff strategies, jitter, and integration with timeout/signal; richer run builder and result unions.
    • Expanded error-mapping allowing multiple mapped variants for sync and async paths; new control/error helpers and exposed retry primitives.
  • Tests

    • Extensive new unit and type-level tests covering retry behavior, backoff timing, sync/async flows, and multi-variant error mapping.
  • Documentation

    • Added examples demonstrating multi-variant error handling and TypeScript style guidance favoring type inference.

Greptile Summary

This PR implements Phase 3 of the retry system with comprehensive backoff strategies (constant, linear, exponential), jitter support, and intelligent sync/async execution switching. The implementation maintains synchronous execution when possible and only switches to async when encountering promises or retry delays, preventing stack overflow with a continuation-based loop for zero-delay retries.

Key Changes:

  • Implemented full retry execution in executeRun with attempt limits, shouldRetry predicate, and backoff delays
  • Added RetryExhaustedError flow and ensures control errors (Panic, TimeoutError, CancellationError) bypass retry and catch mapping
  • Refactored type system into modular files (types/run.ts, types/retry.ts, types/builder.ts, types/core.ts) with sync/async discrimination via NonPromise helper
  • Enhanced builder type signatures to track configuration-specific errors at compile time
  • Expanded test coverage for retry exhaustion, backoff timing formulas, multi-variant error mapping, and edge cases like 20k zero-delay retries

Implementation Highlights:

  • Uses symbol-based continuation (CONTINUE_SYNC) for efficient sync retry loops
  • Properly handles promise-like detection for thenable objects (addresses previous thread concern)
  • Creates fresh context per shouldRetry call with correct attempt number
  • Successfully tested with extensive unit tests and type-level compile-time checks

Confidence Score: 5/5

  • This PR is safe to merge with comprehensive test coverage and well-architected implementation
  • The implementation demonstrates careful design with extensive test coverage (including edge cases like 20k retries, backoff timing verification, and control error behavior), proper handling of previous review concerns (thenable detection, context management), clean type refactoring following the project's style guide, and successful validation via format/check/typecheck/test commands
  • No files require special attention

Important Files Changed

Filename Overview
src/lib/runner.ts Implements retry execution engine with sync/async switching and backoff delays
src/lib/retry.ts Adds retry delay calculation with constant/linear/exponential backoff and jitter support
src/lib/builder.ts Enhanced type signatures to track config errors (RetryExhaustedError, TimeoutError, CancellationError)
src/lib/utils.ts Added helper functions for promise detection, control error checking, and async sleep
src/lib/types/run.ts New file defining sync/async function types with NonPromise constraint
src/tests/types.test.ts New comprehensive compile-time type tests for all config combinations

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Start[executeRun called] --> AttemptSync[executeAttemptSync]
    AttemptSync --> TryExec[Execute tryFn]
    TryExec --> CheckPromise{Is result<br/>Promise-like?}
    
    CheckPromise -->|Yes| AsyncPath[Attach rejection handler]
    CheckPromise -->|No| SyncReturn[Return success value]
    
    TryExec -->|Throws| CheckControl{Is control<br/>error?}
    CheckControl -->|Yes| ReturnError[Return error immediately]
    CheckControl -->|No| EvalRetry[evaluateRetryDecision]
    
    EvalRetry --> ShouldRetry{shouldAttemptRetry?}
    ShouldRetry -->|No| CheckExhausted{isRetryExhausted?}
    CheckExhausted -->|Yes| RetryExhausted[Return RetryExhaustedError]
    CheckExhausted -->|No| Finalize[finalizeError]
    
    ShouldRetry -->|Yes| CheckDelay{delay > 0?}
    CheckDelay -->|No| SyncContinue[Continue sync loop]
    CheckDelay -->|Yes| SwitchAsync[Switch to async path]
    
    SwitchAsync --> AsyncSleep[await sleep]
    AsyncPath --> AsyncSleep
    AsyncSleep --> NextAttempt[Increment attempt]
    NextAttempt --> AsyncRetry[Recursive retry]
    
    Finalize --> HasCatch{Has catch<br/>handler?}
    HasCatch -->|Yes| ExecCatch[executeCatch]
    HasCatch -->|No| UnhandledExc[Return UnhandledException]
    
    SyncContinue --> AttemptSync
    
    ExecCatch --> CatchOk[Return mapped error]
    ExecCatch -->|Throws| PanicError[Throw Panic]
Loading

Last reviewed commit: f1a524d

Context used:

  • Context from dashboard - AGENTS.md (source)

@adelrodriguez adelrodriguez marked this pull request as ready for review February 22, 2026 22:20
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 22, 2026

Walkthrough

Split sync vs async run/catch shapes, made TryBuilder generic over configured run errors, rewrote executeRun to support retries/backoffs and sync→async transitions, added retry utilities and runtime guards, refined error code types, and expanded tests and docs with multi‑variant error examples and type assertions.

Changes

Cohort / File(s) Summary
Docs & planning
​.context/api-proposal.md, ​.context/plan.md, AGENTS.md
Added multi‑variant try$.run examples, updated plan phases (run/retryOptions/retry checked), and added TypeScript style guidance.
Public types (reorg)
src/lib/types.ts, src/lib/types/core.ts, src/lib/types/run.ts, src/lib/types/retry.ts, src/lib/types/builder.ts
Split and re-exported type surface into modular files; introduced ErrorCode, TryCtx, NonPromise, RunInput and related run/catch types, retry policy types, and BuilderConfig/Timeout/WrapFn types.
Builder API
src/lib/builder.ts
Made TryBuilder<E> generic over config errors; updated retry/timeout/signal to widen E; added conditional async result typing and new run overloads accepting sync/async try and catch shapes delegating to RunInput.
Runner implementation
src/lib/runner.ts
Overhauled executeRun with extensive overloads, added executeCatch, sync and async retry paths, shouldAttemptRetry/delay calculation, sleep-based waits, finalizeError routing, and control-error handling (Panic/Unhandled/RetryExhausted).
Retry utilities
src/lib/retry.ts
Added calculateRetryDelay, checkShouldAttemptRetry, checkIsRetryExhausted driven by BuilderConfig.retry; supports constant/linear/exponential backoffs, jitter, and context-aware shouldRetry.
Runtime utilities
src/lib/utils.ts
Added sleep(ms), checkIsControlError, and checkIsPromiseLike helpers; retained assertUnreachable.
Errors
src/lib/errors.ts
Refined error classes to declare literal code properties for CancellationError, TimeoutError, RetryExhaustedError, UnhandledException, and Panic.
Tests — behavior & types
src/__tests__/index.test.ts, src/__tests__/types.test.ts, src/lib/__tests__/runner.test.ts, src/lib/__tests__/utils.test.ts, src/lib/__tests__/retry.test.ts
Expanded test coverage for sync/async run/catch, multi‑variant error mapping, retry/backoff flows, utility guards, and added compile‑time type equality assertions.
Test helpers
src/__tests__/...
Introduced test-only error classes used in new mapping and retry tests.
Context & minor files
src/lib/context.ts
Updated type imports to use new builder/core types; no signature changes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the PR: implementing Phase 3 retry behavior and refactoring types, which aligns with the substantive changes across runner.ts, retry.ts, builder.ts, and type definitions.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 02-22-add_async_support_to_run_method_with_error_mapping

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
Owner Author

adelrodriguez commented Feb 22, 2026

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

8 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@adelrodriguez adelrodriguez changed the base branch from 02-22-feat_add_execution_runner to graphite-base/2 February 22, 2026 22:51
@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch from 4a7c447 to daa5bc3 Compare February 22, 2026 22:51
@adelrodriguez adelrodriguez changed the base branch from graphite-base/2 to 02-22-refactor_retry_and_timeout_types_with_improved_documentation February 22, 2026 22:51
@adelrodriguez adelrodriguez force-pushed the 02-22-refactor_retry_and_timeout_types_with_improved_documentation branch from 88617c1 to 72fb8cd Compare February 22, 2026 23:06
@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch from daa5bc3 to 7cb3592 Compare February 22, 2026 23:06
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

8 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@adelrodriguez adelrodriguez force-pushed the 02-22-refactor_retry_and_timeout_types_with_improved_documentation branch from 72fb8cd to 5f999ea Compare February 22, 2026 23:16
@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch 2 times, most recently from 430aad4 to d1ec5ac Compare February 22, 2026 23:24
@adelrodriguez adelrodriguez force-pushed the 02-22-refactor_retry_and_timeout_types_with_improved_documentation branch from 5f999ea to 9cf5642 Compare February 22, 2026 23:24
@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch from d1ec5ac to 8a95ade Compare February 22, 2026 23:27
@adelrodriguez adelrodriguez force-pushed the 02-22-refactor_retry_and_timeout_types_with_improved_documentation branch from 9cf5642 to 154a2e1 Compare February 22, 2026 23:27
Base automatically changed from 02-22-refactor_retry_and_timeout_types_with_improved_documentation to main February 22, 2026 23:29
@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch 4 times, most recently from 758b033 to d6184a8 Compare February 22, 2026 23:42
@adelrodriguez adelrodriguez changed the title Add async support to run method with error mapping Implement Phase 3 retry behavior and type refactors Feb 22, 2026
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

return input(ctx)
} catch (error) {
return new UnhandledException({ cause: error })
return await tryFn(ctx)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the tryFn returns a rejected promise instead of throwing synchronously, the await will catch it but the rejection won't be caught by the try-catch on line 195. The error will propagate through Promise.resolve(result) on line 211, where the .catch() handler on line 211-213 will handle it. While this works correctly, the control flow is implicit and relies on promise chaining behavior.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/runner.ts
Line: 196

Comment:
If the `tryFn` returns a rejected promise instead of throwing synchronously, the `await` will catch it but the rejection won't be caught by the try-catch on line 195. The error will propagate through `Promise.resolve(result)` on line 211, where the `.catch()` handler on line 211-213 will handle it. While this works correctly, the control flow is implicit and relies on promise chaining behavior.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

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: 3

🧹 Nitpick comments (6)
.context/plan.md (1)

10-10: LGTM — Phase 2 correctly marked complete.

The checkbox update aligns with the PR's delivered scope: async overloads, Promise detection, Panic on async catch-throw, and updated test coverage.

One minor doc inconsistency to consider: Phase 0 (Line 39) still lists MaybePromise<T> as a foundational core type, but the PR summary indicates it was removed as unused in types.ts. The plan could note this deviation to keep Phase 0's exit state accurate.

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

In @.context/plan.md at line 10, Phase 0's exit state still mentions the core
type MaybePromise<T> even though the PR removed it from types.ts; update the
plan text for "Phase 0" to reflect that MaybePromise<T> was removed (or mark it
as deprecated/removed) so the plan's documented exit state matches the code
changes — locate the "Phase 0" section and replace or annotate the
MaybePromise<T> mention accordingly.
src/lib/__tests__/utils.test.ts (1)

9-14: Consider adding a test for callable thenables (the typeof value === "function" branch).

The implementation of isPromiseLike explicitly handles typeof value === "function", but there's no test exercising that path. Also, the Reflect.set usage here could be simplified.

💡 Suggested additional test cases
   it("returns true for thenable objects", () => {
-    const thenable: Record<string, unknown> = {}
-    Reflect.set(thenable, "then", (_resolve: (value: string) => void) => null)
-
+    const thenable = { then: () => null }
     expect(isPromiseLike(thenable)).toBe(true)
   })
+
+  it("returns true for callable thenables", () => {
+    const fn = Object.assign(() => {}, { then: () => null })
+    expect(isPromiseLike(fn)).toBe(true)
+  })
+
+  it("returns false for objects with non-function then", () => {
+    expect(isPromiseLike({ then: "not a function" })).toBe(false)
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/utils.test.ts` around lines 9 - 14, Add a unit test that
exercises the typeof value === "function" branch of isPromiseLike by creating a
callable thenable (e.g., const thenable = () => {}; thenable.then = (resolve:
(v: string) => void) => null) and asserting isPromiseLike(thenable) === true;
replace Reflect.set usage with simple property assignment to keep the test
concise and clearly target the isPromiseLike function's callable-path.
src/lib/__tests__/runner.test.ts (1)

84-105: Prefer expect().rejects.toThrow() for async rejection assertions.

The manual try/catch pattern works but has a subtle issue: if await result unexpectedly resolves, line 101 throws a plain Error, and the assertion on line 103 would fail with a confusing message. Using expect().rejects produces a clearer failure.

♻️ Cleaner async Panic assertion
-  it("throws Panic when async catch rejects", async () => {
-    const result = executeRun(
+  it("throws Panic when async catch rejects", async () => {
+    const result = executeRun(
       {},
       {
         catch: async () => {
           await Promise.resolve()
           throw new Error("catch failed")
         },
         try: async () => {
           await Promise.resolve()
           throw new Error("boom")
         },
       }
     )
 
-    try {
-      await result
-      throw new Error("Expected Panic rejection")
-    } catch (error) {
-      expect(error).toBeInstanceOf(Panic)
-    }
+    expect(result).rejects.toThrow(Panic)
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/runner.test.ts` around lines 84 - 105, Replace the manual
try/catch assertion with Jest's promise rejection matcher: call executeRun(...)
to get the promise (result) and use await
expect(result).rejects.toBeInstanceOf(Panic) so the test fails clearly if the
promise resolves or rejects with the wrong type; update the test around the
executeRun invocation and the Panic assertion accordingly.
src/__tests__/index.test.ts (1)

112-130: Same suggestion: prefer expect().rejects.toThrow() for the Panic rejection test.

Same pattern as noted in src/lib/__tests__/runner.test.ts — the manual try/catch can produce confusing failures if await result unexpectedly resolves.

♻️ Cleaner async Panic assertion
   it("throws Panic when async catch rejects", async () => {
     const result = run({
       catch: async () => {
         await Promise.resolve()
         throw new Error("catch failed")
       },
       try: async () => {
         await Promise.resolve()
         throw new Error("boom")
       },
     })
 
-    try {
-      await result
-      throw new Error("Expected Panic rejection")
-    } catch (error) {
-      expect(error).toBeInstanceOf(Panic)
-    }
+    expect(result).rejects.toThrow(Panic)
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/index.test.ts` around lines 112 - 130, The test "throws Panic
when async catch rejects" uses a manual try/catch that can mask unexpected
resolves; replace the manual await + try/catch on the promise variable result
with Jest's async assertion: await expect(result).rejects.toBeInstanceOf(Panic).
Locate the test block that constructs result (the run call with try/catch async
functions) and swap the final manual assertion with the single-line rejects
assertion referencing Panic.
src/lib/runner.ts (2)

52-67: maxDelayMs cap only applies to exponential backoff.

Linear backoff (baseDelay * attempt) also grows without bound. If someone configures linear backoff with a high retry limit, delays can become unexpectedly large. Consider applying the maxDelayMs cap uniformly after the switch.

♻️ Proposed refactor to apply maxDelayMs to all strategies
     case "exponential":
       delay = baseDelay * 2 ** (attempt - 1)
-
-      if (policy.maxDelayMs !== undefined) {
-        delay = Math.min(delay, policy.maxDelayMs)
-      }
-
       break
   }
 
+  if (policy.maxDelayMs !== undefined) {
+    delay = Math.min(delay, policy.maxDelayMs)
+  }
+
   if (!policy.jitter || delay <= 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/runner.ts` around lines 52 - 67, The backoff logic only applies
policy.maxDelayMs for the "exponential" case; update the retry delay calculation
so the maxDelayMs cap is applied uniformly after the switch. Keep the existing
switch on policy.backoff (cases "constant", "linear", "exponential") that sets
delay from baseDelay and attempt, then after the switch check if
policy.maxDelayMs !== undefined and set delay = Math.min(delay,
policy.maxDelayMs) so linear and constant also get capped; modify the block
around the symbols delay, baseDelay, attempt, and policy.maxDelayMs in
src/lib/runner.ts accordingly.

91-95: shouldRetry receives a fresh context, not the live execution context.

createContext(config) builds a new TryCtx instead of reusing the one from executeRun. If the shouldRetry callback ever needs to inspect state that was attached to the running context by tryFn, it won't see it. Consider accepting the existing ctx as a parameter and snapshotting only the retry fields.

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

In `@src/lib/runner.ts` around lines 91 - 95, The current code creates a new
TryCtx via createContext(config) before calling policy.shouldRetry, which
prevents shouldRetry from seeing any state added to the running context by
executeRun/tryFn; instead, reuse the existing execution context (the live ctx
from executeRun) and only mutate or snapshot the retry-specific fields
(ctx.retry.attempt and ctx.retry.limit) before calling policy.shouldRetry so
shouldRetry receives the live TryCtx with up-to-date state; if you must avoid
mutating the original, create a shallow copy of the existing ctx and update only
the retry subfields, then pass that to policy.shouldRetry.
🤖 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/lib/builder.ts`:
- Around line 59-68: The overload ordering for run is causing incorrect
inference when async try functions are passed; reorder the overload declarations
so overloads that accept AsyncRunTryFn<T> and async object-form variants appear
before the SyncRunTryFn<T> and sync object-form variants (i.e., place the
Promise-returning overloads above the non-promise ones) so TypeScript will pick
the more specific async signatures first and produce the correct return types
(refer to the run function and types SyncRunTryFn, AsyncRunTryFn, RunCatchFn,
RunInput, and UnhandledException to locate and adjust the declarations).

In `@src/lib/runner.ts`:
- Around line 112-131: The overload signatures for executeRun do not include the
possible control/terminal error types returned by
handleAttemptErrorSync/handleAttemptErrorAsync (RetryExhaustedError,
CancellationError, TimeoutError), so callers may receive those types
unexpectedly; update the executeRun overload return unions (all exported
overloads) to include RetryExhaustedError | CancellationError | TimeoutError
wherever the implementation may return those (e.g., the T | E and Promise<T | E>
variants) or alternatively change the implementation paths in
handleAttemptErrorSync/handleAttemptErrorAsync to call finalizeError so those
control errors are routed into the catch mapper; locate symbols executeRun,
handleAttemptErrorSync, handleAttemptErrorAsync, and finalizeError to apply the
chosen fix consistently across sync and async overloads.
- Around line 148-171: handleAttemptErrorSync currently re-evaluates
shouldAttemptRetry when delegating to handleAttemptErrorAsync and also recurses
synchronously via executeAttemptSync when delay === 0; to fix, hoist the retry
decision and exhaustion checks so they run exactly once per attempt (calculate
shouldAttemptRetry(attempt,error,config) and isRetryExhausted once in
handleAttemptErrorSync) and pass the decision/result into
handleAttemptErrorAsync (e.g., add a skipChecks or decision parameter) to avoid
double-calling shouldAttemptRetry, and replace the tail recursion path where
delay <= 0 with an iterative loop (increment attempt and continue) instead of
calling executeAttemptSync recursively to prevent unbounded sync recursion;
update references in handleAttemptErrorSync, handleAttemptErrorAsync,
executeAttemptSync, shouldAttemptRetry, calculateRetryDelay, isRetryExhausted,
and finalizeError accordingly.

---

Nitpick comments:
In @.context/plan.md:
- Line 10: Phase 0's exit state still mentions the core type MaybePromise<T>
even though the PR removed it from types.ts; update the plan text for "Phase 0"
to reflect that MaybePromise<T> was removed (or mark it as deprecated/removed)
so the plan's documented exit state matches the code changes — locate the "Phase
0" section and replace or annotate the MaybePromise<T> mention accordingly.

In `@src/__tests__/index.test.ts`:
- Around line 112-130: The test "throws Panic when async catch rejects" uses a
manual try/catch that can mask unexpected resolves; replace the manual await +
try/catch on the promise variable result with Jest's async assertion: await
expect(result).rejects.toBeInstanceOf(Panic). Locate the test block that
constructs result (the run call with try/catch async functions) and swap the
final manual assertion with the single-line rejects assertion referencing Panic.

In `@src/lib/__tests__/runner.test.ts`:
- Around line 84-105: Replace the manual try/catch assertion with Jest's promise
rejection matcher: call executeRun(...) to get the promise (result) and use
await expect(result).rejects.toBeInstanceOf(Panic) so the test fails clearly if
the promise resolves or rejects with the wrong type; update the test around the
executeRun invocation and the Panic assertion accordingly.

In `@src/lib/__tests__/utils.test.ts`:
- Around line 9-14: Add a unit test that exercises the typeof value ===
"function" branch of isPromiseLike by creating a callable thenable (e.g., const
thenable = () => {}; thenable.then = (resolve: (v: string) => void) => null) and
asserting isPromiseLike(thenable) === true; replace Reflect.set usage with
simple property assignment to keep the test concise and clearly target the
isPromiseLike function's callable-path.

In `@src/lib/runner.ts`:
- Around line 52-67: The backoff logic only applies policy.maxDelayMs for the
"exponential" case; update the retry delay calculation so the maxDelayMs cap is
applied uniformly after the switch. Keep the existing switch on policy.backoff
(cases "constant", "linear", "exponential") that sets delay from baseDelay and
attempt, then after the switch check if policy.maxDelayMs !== undefined and set
delay = Math.min(delay, policy.maxDelayMs) so linear and constant also get
capped; modify the block around the symbols delay, baseDelay, attempt, and
policy.maxDelayMs in src/lib/runner.ts accordingly.
- Around line 91-95: The current code creates a new TryCtx via
createContext(config) before calling policy.shouldRetry, which prevents
shouldRetry from seeing any state added to the running context by
executeRun/tryFn; instead, reuse the existing execution context (the live ctx
from executeRun) and only mutate or snapshot the retry-specific fields
(ctx.retry.attempt and ctx.retry.limit) before calling policy.shouldRetry so
shouldRetry receives the live TryCtx with up-to-date state; if you must avoid
mutating the original, create a shallow copy of the existing ctx and update only
the retry subfields, then pass that to policy.shouldRetry.

@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch 3 times, most recently from 1ad5ea0 to 35b5bdb Compare February 23, 2026 00:48
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

🧹 Nitpick comments (6)
src/lib/__tests__/runner.test.ts (1)

106-127: Same Panic assertion pattern as index.test.ts — consider expect(...).rejects.toThrow(Panic) here too.

Same optional suggestion as in src/__tests__/index.test.ts line 124: the manual try/catch with sentinel throw could be replaced with a more declarative .rejects.toThrow(Panic) assertion.

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

In `@src/lib/__tests__/runner.test.ts` around lines 106 - 127, The test uses a
manual try/catch with a sentinel throw to assert that executeRun(...) rejects
with a Panic; replace that pattern by asserting the returned promise rejects
declaratively, e.g. use expect(result).rejects.toThrow(Panic) (ensure you don't
await the promise before passing it to expect so Jest can handle the rejection),
referencing the executeRun invocation and the Panic class in the assertion.
src/lib/__tests__/retry.test.ts (1)

289-320: Consider extracting the setTimeout mock into a shared helper.

The setTimeout mock pattern is duplicated across the linear (lines 290-301) and exponential (lines 323-334) backoff tests. A small helper (e.g., withMockedTimers(fn)) that captures delays and auto-restores the original would reduce duplication and make future backoff tests easier to add.

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

In `@src/lib/__tests__/retry.test.ts` around lines 289 - 320, Extract the
duplicated setTimeout mocking into a shared test helper (e.g., withMockedTimers)
that accepts a callback and returns captured delays while auto-restoring
globalThis.setTimeout; update the tests that call executeRun (the linear and
exponential backoff cases) to use this helper and assert on the returned delays
and result (RetryExhaustedError) instead of duplicating the mock logic; ensure
the helper preserves original setTimeout, pushes timeout values into an array,
invokes the handler immediately, and restores the original in a finally block.
src/lib/__tests__/utils.test.ts (1)

1-65: Missing test coverage for sleep.

The sleep utility is exported from utils.ts but has no tests here. Consider adding basic cases: sleep(0) resolves immediately, sleep(-1) resolves immediately, and sleep(positive) resolves after a delay (using a fake timer or similar approach).

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

In `@src/lib/__tests__/utils.test.ts` around lines 1 - 65, Tests are missing for
the sleep utility; add a new describe/it block in
src/lib/__tests__/utils.test.ts that imports sleep and verifies sleep(0) and
sleep(-1) resolve immediately and that sleep(positiveMs) delays resolution (use
a fake timer or time-measurement strategy to assert the delay). Specifically,
add tests referencing the sleep function: one asserting immediate resolution for
0, one for negative values, and one asserting a measurable pause for a positive
millisecond value (use Bun's test timer utilities or manual Date.now checks to
avoid flaky timing).
src/__tests__/index.test.ts (1)

112-130: Nit: consider using expect(...).rejects.toThrow(Panic) for consistency.

The manual try/catch with a sentinel throw on line 126 works, but if the rejection never happens, the test could pass silently if the throw new Error("Expected Panic rejection") line is somehow skipped. Bun's test runner supports expect(promise).rejects.toThrow(Panic) which is more declarative and avoids the sentinel pattern.

That said, if you've found this pattern more reliable for your Panic flow, feel free to keep it.

♻️ Proposed refactor
-    try {
-      await result
-      throw new Error("Expected Panic rejection")
-    } catch (error) {
-      expect(error).toBeInstanceOf(Panic)
-    }
+    expect(result).rejects.toThrow(Panic)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/index.test.ts` around lines 112 - 130, Test uses a manual
try/catch sentinel pattern; replace it with Bun's promise assertion to be more
declarative: update the "throws Panic when async catch rejects" test to assert
that the promise returned by run({...}) rejects with Panic by using
expect(result).rejects.toThrow(Panic) (referencing the run function and Panic
class) instead of the try { await result; throw new Error(...) } catch (...)
pattern so the test fails correctly if the promise does not reject.
src/lib/retry.ts (2)

59-74: Add default: assertUnreachable to match the exhaustiveness guard in normalizeRetryPolicy.

Both normalizeRetryPolicy and calculateRetryDelay switch over policy.backoff, but only the former has a default: return assertUnreachable(policy). A future backoff variant added to the discriminated union would silently produce a delay of 0 here instead of a compile-time error.

♻️ Proposed fix
     case "exponential":
       delay = baseDelay * 2 ** (attempt - 1)
       if (policy.maxDelayMs !== undefined) {
         delay = Math.min(delay, policy.maxDelayMs)
       }
       break
+    default:
+      return assertUnreachable(policy.backoff)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/retry.ts` around lines 59 - 74, The switch in calculateRetryDelay
over policy.backoff is missing an exhaustiveness guard; add a default case that
calls assertUnreachable(policy) (matching normalizeRetryPolicy) so any future
backoff variant triggers a compile-time/typecheck error instead of silently
leaving delay unset; keep the existing cases (constant, linear, exponential) and
put default: return assertUnreachable(policy) (or throw/assert) to satisfy the
exhaustiveness check for policy.backoff.

102-104: ctx.retry.limit = policy.limit is redundant.

createContext(config) already initialises ctx.retry.limit to config.retry?.limit ?? 1. Since policy is config.retry (verified non-null at line 88), the reassignment on line 104 is a no-op.

♻️ Proposed fix
   const ctx = createContext(config)
   ctx.retry.attempt = attempt
-  ctx.retry.limit = policy.limit

   return policy.shouldRetry(error, ctx)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/retry.ts` around lines 102 - 104, The assignment ctx.retry.limit =
policy.limit is redundant because createContext(config) already sets
ctx.retry.limit from config.retry?.limit ?? 1 and policy is the same
config.retry; remove the redundant line to avoid a no-op. Locate the
createContext call and the surrounding retry initialization (symbols:
createContext, ctx, config, policy, ctx.retry.limit) and delete the reassignment
so ctx.retry.limit remains sourced only from createContext.
🤖 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/lib/runner.ts`:
- Around line 42-56: The overloads for executeRun (the synchronous overload
signatures returning T | UnhandledException and T | E) are incorrect because
synchronous paths can return a Promise when config.retry.delayMs > 0 (see
executeAttemptSync, handleAttemptErrorSync delegating to async
handleAttemptErrorAsync); update the affected overload signatures (the first and
third executeRun overload declarations) to include the Promise variant (e.g., T
| UnhandledException | Promise<T | UnhandledException> and T | E | Promise<T |
E>) so callers get the correct widened return type, and apply the same widening
to the corresponding run overloads in builder.ts (the run overloads referenced
in the review) to keep signatures consistent across modules.

---

Duplicate comments:
In `@src/lib/builder.ts`:
- Around line 66-73: The third overload for run (run<T, C>(options: { try:
SyncRunTryFn<T>; catch: SyncRunCatchFn<C> }): T | C | E) incorrectly forces a
synchronous return while executeRun (and .retry with delayMs > 0) can produce a
Promise; update that overload to mirror the async/sync widening used elsewhere
(e.g., change its return to Promise<T | C | E> or to a union including Promise)
so run and executeRun signatures stay consistent when .retry is used; adjust the
overload for the run method in builder.ts that references SyncRunTryFn and
SyncRunCatchFn and ensure any related types/consumers compile against the
widened return.

In `@src/lib/runner.ts`:
- Around line 98-163: handleAttemptErrorSync currently calls
checkShouldAttemptRetry and calculateRetryDelay, then delegates to
handleAttemptErrorAsync which recomputes both (causing double side-effects and
different jitter) and uses recursion for zero-delay retries (risking stack
overflow). Fix by: compute shouldRetry = checkShouldAttemptRetry(error, attempt,
config) and delay = calculateRetryDelay(attempt, config) once in
handleAttemptErrorSync; if !shouldRetry handle the exhausted/finalize cases; if
delay <= 0 perform retries iteratively (replace the recursive executeAttemptSync
chain with a while loop that updates attempt, ctx.retry.attempt and re-invokes
executeAttemptSync) to avoid growing the stack; if delay > 0 call a revised
handleAttemptErrorAsync(error, attempt, delay) that awaits the provided delay
(remove its own calculateRetryDelay/checkShouldAttemptRetry calls) and then
tryFn/recursively call handleAttemptErrorAsync only with the next attempt.
Ensure references to checkShouldAttemptRetry, calculateRetryDelay,
executeAttemptSync, handleAttemptErrorAsync, handleAttemptErrorSync, and
ctx.retry.attempt are used to locate the changes.

---

Nitpick comments:
In `@src/__tests__/index.test.ts`:
- Around line 112-130: Test uses a manual try/catch sentinel pattern; replace it
with Bun's promise assertion to be more declarative: update the "throws Panic
when async catch rejects" test to assert that the promise returned by run({...})
rejects with Panic by using expect(result).rejects.toThrow(Panic) (referencing
the run function and Panic class) instead of the try { await result; throw new
Error(...) } catch (...) pattern so the test fails correctly if the promise does
not reject.

In `@src/lib/__tests__/retry.test.ts`:
- Around line 289-320: Extract the duplicated setTimeout mocking into a shared
test helper (e.g., withMockedTimers) that accepts a callback and returns
captured delays while auto-restoring globalThis.setTimeout; update the tests
that call executeRun (the linear and exponential backoff cases) to use this
helper and assert on the returned delays and result (RetryExhaustedError)
instead of duplicating the mock logic; ensure the helper preserves original
setTimeout, pushes timeout values into an array, invokes the handler
immediately, and restores the original in a finally block.

In `@src/lib/__tests__/runner.test.ts`:
- Around line 106-127: The test uses a manual try/catch with a sentinel throw to
assert that executeRun(...) rejects with a Panic; replace that pattern by
asserting the returned promise rejects declaratively, e.g. use
expect(result).rejects.toThrow(Panic) (ensure you don't await the promise before
passing it to expect so Jest can handle the rejection), referencing the
executeRun invocation and the Panic class in the assertion.

In `@src/lib/__tests__/utils.test.ts`:
- Around line 1-65: Tests are missing for the sleep utility; add a new
describe/it block in src/lib/__tests__/utils.test.ts that imports sleep and
verifies sleep(0) and sleep(-1) resolve immediately and that sleep(positiveMs)
delays resolution (use a fake timer or time-measurement strategy to assert the
delay). Specifically, add tests referencing the sleep function: one asserting
immediate resolution for 0, one for negative values, and one asserting a
measurable pause for a positive millisecond value (use Bun's test timer
utilities or manual Date.now checks to avoid flaky timing).

In `@src/lib/retry.ts`:
- Around line 59-74: The switch in calculateRetryDelay over policy.backoff is
missing an exhaustiveness guard; add a default case that calls
assertUnreachable(policy) (matching normalizeRetryPolicy) so any future backoff
variant triggers a compile-time/typecheck error instead of silently leaving
delay unset; keep the existing cases (constant, linear, exponential) and put
default: return assertUnreachable(policy) (or throw/assert) to satisfy the
exhaustiveness check for policy.backoff.
- Around line 102-104: The assignment ctx.retry.limit = policy.limit is
redundant because createContext(config) already sets ctx.retry.limit from
config.retry?.limit ?? 1 and policy is the same config.retry; remove the
redundant line to avoid a no-op. Locate the createContext call and the
surrounding retry initialization (symbols: createContext, ctx, config, policy,
ctx.retry.limit) and delete the reassignment so ctx.retry.limit remains sourced
only from createContext.

@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch from 35b5bdb to a66a1d0 Compare February 23, 2026 01:14
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

19 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

return delay
}

return Math.floor(Math.random() * delay)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Full jitter returns random value from 0 to delay, which could result in zero delay. Consider documenting that jitter uses "full jitter" strategy (uniform random [0, delay]) rather than "equal jitter" (delay/2 + random [0, delay/2])

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/retry.ts
Line: 81

Comment:
Full jitter returns random value from 0 to `delay`, which could result in zero delay. Consider documenting that jitter uses "full jitter" strategy (uniform random [0, delay]) rather than "equal jitter" (delay/2 + random [0, delay/2])

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

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 (6)
src/lib/types/core.ts (1)

8-11: Consider readonly on RetryInfo fields to signal intent.

attempt and limit are semantically read-only from the user's perspective (mutations have no effect on retry counting since runner.ts tracks state via the closure attempt parameter). Marking them readonly makes the contract explicit and prevents confusing user-side mutations.

♻️ Suggested change
 export interface RetryInfo {
-  attempt: number
-  limit: number
+  readonly attempt: number
+  readonly limit: number
 }

This would require a cast in runner.ts at the mutation sites:

;(ctx.retry as { attempt: number }).attempt = attempt
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/types/core.ts` around lines 8 - 11, Mark the RetryInfo interface
fields as readonly to express intent (update RetryInfo: attempt and limit ->
readonly), then update all mutation sites that currently assign to
ctx.retry.attempt in runner.ts to cast before assignment (e.g., cast ctx.retry
to a mutable shape when writing the attempt) so the code compiles while
preserving the readonly contract for consumers; reference the RetryInfo
interface and the assignment sites in runner.ts where ctx.retry.attempt is set.
src/__tests__/types.test.ts (1)

78-121: Minor coverage gap — async try/catch variant missing for timeout and signal.

The "with retry" section tests all four combinations (sync fn, async fn, sync try/catch, async try/catch). Both "with timeout" and "with signal" stop at three, skipping the async try / sync catch → Promise<T | E | TimeoutError/CancellationError> variant.

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

In `@src/__tests__/types.test.ts` around lines 78 - 121, Add the missing async
try/catch type tests for both timeout and signal: call timeout(5000).run and
signal(ac.signal).run with an object where try is an async function (e.g. async
() => 42) and catch returns the error literal (e.g. () => "err" as const), and
assert the resulting type is Promise<number | "err" | TimeoutError> for timeout
and Promise<number | "err" | CancellationError> for signal using the same
Expect<Equal<...>> pattern as the other tests to match the async try/catch
coverage in the "with retry" section.
src/lib/__tests__/runner.test.ts (1)

106-127: Simplify async Panic assertion with .rejects.

Bun's expect supports .rejects directly on a Promise value, so the manual try/catch block can be replaced with a single line that also provides a clearer failure message.

♻️ Suggested refactor
-    it("throws Panic when async catch rejects", async () => {
-      const result = executeRun(
-        {},
-        {
-          catch: async () => {
-            await Promise.resolve()
-            throw new Error("catch failed")
-          },
-          try: async () => {
-            await Promise.resolve()
-            throw new Error("boom")
-          },
-        }
-      )
-
-      try {
-        await result
-        throw new Error("Expected Panic rejection")
-      } catch (error) {
-        expect(error).toBeInstanceOf(Panic)
-      }
-    })
+    it("throws Panic when async catch rejects", async () => {
+      const result = executeRun(
+        {},
+        {
+          catch: async () => {
+            await Promise.resolve()
+            throw new Error("catch failed")
+          },
+          try: async () => {
+            await Promise.resolve()
+            throw new Error("boom")
+          },
+        }
+      )
+
+      await expect(result).rejects.toBeInstanceOf(Panic)
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/runner.test.ts` around lines 106 - 127, The test "throws
Panic when async catch rejects" manually awaits the Promise from executeRun and
uses a try/catch to assert a Panic; replace that manual pattern with Bun's
promise assertion by calling expect(result).rejects.toBeInstanceOf(Panic) to
simplify and clarify the assertion for the Promise returned by executeRun,
updating the test case that constructs result with executeRun({},{ try: ...,
catch: ... }) and removing the try/catch/throw scaffolding.
src/lib/utils.ts (1)

7-15: sleep will block timeout and cancellation during backoff delays (Phase 4/5 concern).

handleAttemptErrorAsync calls sleep(delay) with no cancellation path. When Phase 4 (total-scope timeout) and Phase 5 (AbortSignal) are implemented, a backoff delay in progress will run to completion regardless of timeout expiry or abort, breaking the total-timeout and cancellation guarantees described in the design doc.

Consider accepting an optional AbortSignal now so the signature doesn't need a breaking change later:

⚠️ Suggested API change (forward-compatible)
-export function sleep(ms: number): Promise<void> {
+export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
   if (ms <= 0) {
     return Promise.resolve()
   }

   return new Promise((resolve, reject) => {
-    setTimeout(resolve, ms)
+    const id = setTimeout(resolve, ms)
+    signal?.addEventListener("abort", () => {
+      clearTimeout(id)
+      reject(signal.reason)
+    }, { once: true })
   })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/utils.ts` around lines 7 - 15, The sleep function (sleep) blocks
timeout/abort because it offers no cancellation; change its signature to accept
an optional AbortSignal (e.g., sleep(ms: number, signal?: AbortSignal):
Promise<void>) and implement the promise so it resolves after setTimeout but
also listens for signal.aborted to reject or resolve early and cleans up the
timeout and event listener; update callers such as handleAttemptErrorAsync to
pass through their AbortSignal so backoff can be interrupted without a breaking
API change later.
src/lib/retry.ts (1)

60-75: Consider adding an exhaustiveness guard to the calculateRetryDelay switch.

normalizeRetryPolicy has default: return assertUnreachable(policy) but calculateRetryDelay does not. If a fourth backoff type is added to RetryPolicy, calculateRetryDelay silently falls through and returns 0 delay instead of producing a compile-time error.

♻️ Proposed fix
     case "exponential":
       delay = baseDelay * 2 ** (attempt - 1)

       if (policy.maxDelayMs !== undefined) {
         delay = Math.min(delay, policy.maxDelayMs)
       }

       break
+    default:
+      return assertUnreachable(policy)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/retry.ts` around lines 60 - 75, The switch in calculateRetryDelay is
not exhaustive so new backoff kinds could silently return 0; update the switch
in calculateRetryDelay to include a default/exhaustiveness guard that calls the
existing assertUnreachable (or similar) with the policy.backoff (or the whole
policy) to force a compile-time error for unknown backoff types and ensure the
function always returns a valid delay; reference the calculateRetryDelay
function, the policy.backoff discriminant, and the existing assertUnreachable
utility used by normalizeRetryPolicy when adding this guard.
src/lib/builder.ts (1)

71-71: Rename implementation signature's E to avoid shadowing the class-level generic.

The implementation parameter run<T, E> uses E for the user's catch-result type, but E is already the class-level generic that tracks config-induced errors (ConfigRunErrors). The overloads themselves use C for the same catch-result role (lines 64–69). Renaming the implementation parameter to C is consistent with the overloads and avoids reader confusion.

♻️ Proposed fix
-  run<T, E>(input: RunInput<T, E>) {
+  run<T, C>(input: RunInput<T, C>) {
     return executeRun(this.#config, input)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/builder.ts` at line 71, The implementation signature for the run
method is shadowing the class-level generic ConfigRunErrors by using E; rename
the implementation generic from E to C to match the overloads and avoid
confusion: change run<T, E>(input: RunInput<T, E>) to run<T, C>(input:
RunInput<T, C>) and update all internal usages/annotations inside the run method
body from E to C (and ensure consistency with the existing overloads that
already use C and with the class-level generics like ConfigRunErrors).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/runner.ts`:
- Around line 120-138: The retry path currently re-evaluates
checkShouldAttemptRetry twice and risks unbounded synchronous recursion; to fix,
change the control flow so the "enter async loop" and "re-check then enter"
paths are distinct: add an optional flag (e.g., alreadyChecked) to
handleAttemptErrorAsync so handleAttemptErrorSync passes alreadyChecked=true
when it determines a positive delay (thus skipping the second check inside
handleAttemptErrorAsync), and convert the synchronous retry path
(executeAttemptSync / handleAttemptErrorSync) to an iterative loop (or a
tail-call style loop inside executeAttemptSync that increments attempt and
reuses calculateRetryDelay) instead of recursive calls to avoid growing the call
stack. Ensure all references use checkShouldAttemptRetry,
handleAttemptErrorSync, handleAttemptErrorAsync, executeAttemptSync, and
calculateRetryDelay.
- Around line 55-63: The current sync overloads for executeRun (the signatures
returning T | UnhandledException and T | E for SyncRunTryFn inputs) are unsafe
because executeRun can return a Promise at runtime when config.retry.delayMs > 0
(it delegates to handleAttemptErrorAsync), so change the public overloads to
consistently return Promise<...> (i.e. make executeRun always return Promise<T |
UnhandledException> / Promise<T | E>) or alternatively restrict the sync
overload to only apply when delayMs === 0 and document the trade-off with a NOTE
comment; update the overloads for executeRun, and any related type declarations
for SyncRunTryFn/AsyncRunTryFn usage to match the chosen approach so callers
won’t receive a silently dropped Promise.

---

Nitpick comments:
In `@src/__tests__/types.test.ts`:
- Around line 78-121: Add the missing async try/catch type tests for both
timeout and signal: call timeout(5000).run and signal(ac.signal).run with an
object where try is an async function (e.g. async () => 42) and catch returns
the error literal (e.g. () => "err" as const), and assert the resulting type is
Promise<number | "err" | TimeoutError> for timeout and Promise<number | "err" |
CancellationError> for signal using the same Expect<Equal<...>> pattern as the
other tests to match the async try/catch coverage in the "with retry" section.

In `@src/lib/__tests__/runner.test.ts`:
- Around line 106-127: The test "throws Panic when async catch rejects" manually
awaits the Promise from executeRun and uses a try/catch to assert a Panic;
replace that manual pattern with Bun's promise assertion by calling
expect(result).rejects.toBeInstanceOf(Panic) to simplify and clarify the
assertion for the Promise returned by executeRun, updating the test case that
constructs result with executeRun({},{ try: ..., catch: ... }) and removing the
try/catch/throw scaffolding.

In `@src/lib/builder.ts`:
- Line 71: The implementation signature for the run method is shadowing the
class-level generic ConfigRunErrors by using E; rename the implementation
generic from E to C to match the overloads and avoid confusion: change run<T,
E>(input: RunInput<T, E>) to run<T, C>(input: RunInput<T, C>) and update all
internal usages/annotations inside the run method body from E to C (and ensure
consistency with the existing overloads that already use C and with the
class-level generics like ConfigRunErrors).

In `@src/lib/retry.ts`:
- Around line 60-75: The switch in calculateRetryDelay is not exhaustive so new
backoff kinds could silently return 0; update the switch in calculateRetryDelay
to include a default/exhaustiveness guard that calls the existing
assertUnreachable (or similar) with the policy.backoff (or the whole policy) to
force a compile-time error for unknown backoff types and ensure the function
always returns a valid delay; reference the calculateRetryDelay function, the
policy.backoff discriminant, and the existing assertUnreachable utility used by
normalizeRetryPolicy when adding this guard.

In `@src/lib/types/core.ts`:
- Around line 8-11: Mark the RetryInfo interface fields as readonly to express
intent (update RetryInfo: attempt and limit -> readonly), then update all
mutation sites that currently assign to ctx.retry.attempt in runner.ts to cast
before assignment (e.g., cast ctx.retry to a mutable shape when writing the
attempt) so the code compiles while preserving the readonly contract for
consumers; reference the RetryInfo interface and the assignment sites in
runner.ts where ctx.retry.attempt is set.

In `@src/lib/utils.ts`:
- Around line 7-15: The sleep function (sleep) blocks timeout/abort because it
offers no cancellation; change its signature to accept an optional AbortSignal
(e.g., sleep(ms: number, signal?: AbortSignal): Promise<void>) and implement the
promise so it resolves after setTimeout but also listens for signal.aborted to
reject or resolve early and cleans up the timeout and event listener; update
callers such as handleAttemptErrorAsync to pass through their AbortSignal so
backoff can be interrupted without a breaking API change later.

@adelrodriguez adelrodriguez force-pushed the 02-22-add_async_support_to_run_method_with_error_mapping branch from a66a1d0 to f1a524d Compare February 23, 2026 01:26
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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.context/api-proposal.md (1)

74-74: ⚠️ Potential issue | 🟡 Minor

Inaccurate comment: numeric shorthand uses constant backoff, not linear.

try$.retry(3) normalizes to { backoff: "constant", delayMs: 0, limit: 3 } via normalizeRetryPolicy. The comment says "linear backoff" which is misleading.

📝 Proposed fix
-try$.retry(3) // Retry a specific number of times with linear backoff
+try$.retry(3) // Retry a specific number of times with constant backoff (no delay)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.context/api-proposal.md at line 74, The comment on the example using
try$.retry(3) is incorrect: normalizeRetryPolicy converts numeric shorthand into
a constant-backoff policy ({ backoff: "constant", delayMs: 0, limit: 3 }), not a
linear one; update the comment to state "constant backoff" (or explicitly show
the normalized policy) and/or revise the example to use the correct API if you
intended linear backoff—see try$.retry, normalizeRetryPolicy and the retry
policy shape for reference.
🧹 Nitpick comments (6)
src/lib/retry.ts (3)

60-75: Add a default case with assertUnreachable for consistency.

normalizeRetryPolicy (line 41-42) uses assertUnreachable in its switch over the same discriminant. calculateRetryDelay omits it, leaving delay silently at 0 if a new backoff variant is added but not handled here.

♻️ Proposed fix
       break
+    default:
+      assertUnreachable(policy)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/retry.ts` around lines 60 - 75, The switch in calculateRetryDelay
over policy.backoff can fall through silently for unknown variants; add a
default case that calls assertUnreachable(policy.backoff) (same helper used in
normalizeRetryPolicy) so any new backoff enum triggers a compile-time/runtime
assertion instead of leaving delay as 0; update the switch in
calculateRetryDelay to include this default path referencing policy.backoff and
assertUnreachable.

103-107: Context created then immediately mutated — consider passing attempt directly.

createContext(config) always sets retry.attempt = 1, which is then overwritten on line 104. This works but is slightly wasteful and obscures intent. A lighter approach would be to build the context inline or add an attempt parameter to createContext.

♻️ Possible simplification
-  const ctx = createContext(config)
-  ctx.retry.attempt = attempt
-  ctx.retry.limit = policy.limit
+  const ctx: TryCtx = {
+    signal: config.signal,
+    retry: { attempt, limit: policy.limit },
+  }

This would require importing TryCtx from ./types/core.

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

In `@src/lib/retry.ts` around lines 103 - 107, The code creates a context via
createContext(config) then immediately mutates ctx.retry.attempt — instead pass
the attempt into the context construction or build the TryCtx inline to avoid
the temporary incorrect default. Update createContext to accept an optional
attempt parameter (or add a helper overload) and call createContext(config,
attempt), or construct a TryCtx object with retry.attempt set to attempt and
pass that to policy.shouldRetry; ensure references to createContext,
ctx.retry.attempt, policy.shouldRetry (and TryCtx if you choose inline
construction) are updated accordingly.

77-82: Full jitter can reduce delay to zero — verify this is the intended behavior.

Math.floor(Math.random() * delay) yields a value in [0, delay), meaning the effective delay can be 0 on any retry attempt. This is the "full jitter" strategy (as recommended by AWS), but if the intent is to always have some minimum delay, consider "decorrelated jitter" or "equal jitter" (delay/2 + Math.random() * delay/2).

If full jitter is intentional, a brief comment documenting the choice would help future readers.

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

In `@src/lib/retry.ts` around lines 77 - 82, The current full-jitter
implementation (checking policy.jitter and returning Math.floor(Math.random() *
delay)) allows a zero delay; either change the jitter computation to enforce a
non-zero minimum (e.g., replace the return with an equal-jitter formula like
Math.floor(delay/2 + Math.random() * (delay/2)) so result is in [delay/2,
delay)) or, if full jitter (allowing zero) is intentional, add a short
explanatory comment next to the policy.jitter check and the return
Math.floor(Math.random() * delay) line documenting that full jitter (including
0ms) is expected per AWS guidance.
src/lib/builder.ts (1)

77-79: Implementation signature shadows the class generic E.

The class declares TryBuilder<E extends ConfigRunErrors>, and the overload signatures use C for the catch-handler error type. However, the implementation signature reintroduces E as run<T, E>(input: RunInput<T, E>), shadowing the class-level E with a completely different meaning. This won't cause a runtime issue (implementation signatures aren't visible to callers), but it can confuse readers.

♻️ Proposed fix — use `C` to match the overloads
-  run<T, E>(input: RunInput<T, E>) {
+  run<T, C>(input: RunInput<T, C>) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/builder.ts` around lines 77 - 79, The implementation signature for
TryBuilder.run currently redefines the generic E and shadows the class-level E;
change the implementation generic to match the overloads (use C) so the method
signature reads run<T, C>(input: RunInput<T, C>) and forwards to
executeRun(this.#config, input); update the generic name in the implementation
only (keep class declaration TryBuilder<E extends ConfigRunErrors> and the
overloads untouched) to avoid confusing shadowing.
src/lib/types/retry.ts (1)

22-31: Consider whether linear backoff should also support maxDelayMs.

Linear delay grows as baseDelay * attempt, which can become large for high attempt limits. Unlike exponential backoff which has maxDelayMs as a cap, linear backoff forbids it via never. This is a reasonable design choice if attempt limits are expected to be small, but worth confirming it's intentional since users might want to cap linear delays too.

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

In `@src/lib/types/retry.ts` around lines 22 - 31, The LinearBackoffRetryPolicy
currently forbids maxDelayMs via "maxDelayMs?: never"; change it to allow an
optional numeric cap (e.g., "maxDelayMs?: number") so linear backoff can be
capped like exponential backoff, and update the JSDoc for
LinearBackoffRetryPolicy to document that baseDelay * attempt is applied and
optionally capped by maxDelayMs; modify any usage/validation that checks
LinearBackoffRetryPolicy to accept and honor maxDelayMs (refer to the
LinearBackoffRetryPolicy and BaseRetryPolicy types and any retry logic that
reads maxDelayMs).
src/lib/__tests__/retry.test.ts (1)

289-353: setTimeout mock works but is fragile for concurrent/parallel test runs.

The direct globalThis.setTimeout replacement is acceptable given bun:test's sequential in-file execution and the try/finally cleanup. Just be aware that if the test runner ever parallelizes tests within a file, or if another async operation fires during the test, the mock could interfere.

Consider extracting the mock/restore pattern into a small helper if more delay-based tests are added in the future to reduce duplication between the two test cases.

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

In `@src/lib/__tests__/retry.test.ts` around lines 289 - 353, The tests directly
replace globalThis.setTimeout in two places which is fragile; extract a small
helper to install and restore a mocked setTimeout that records delays and
immediately invokes handlers, and reuse it in the tests around executeRun so
both cases (linear and exponential backoff) call the helper instead of
duplicating the mock/restore logic; reference the existing usages of
globalThis.setTimeout, the try/finally blocks, executeRun, and
RetryExhaustedError when implementing the helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In @.context/api-proposal.md:
- Line 74: The comment on the example using try$.retry(3) is incorrect:
normalizeRetryPolicy converts numeric shorthand into a constant-backoff policy
({ backoff: "constant", delayMs: 0, limit: 3 }), not a linear one; update the
comment to state "constant backoff" (or explicitly show the normalized policy)
and/or revise the example to use the correct API if you intended linear
backoff—see try$.retry, normalizeRetryPolicy and the retry policy shape for
reference.

---

Nitpick comments:
In `@src/lib/__tests__/retry.test.ts`:
- Around line 289-353: The tests directly replace globalThis.setTimeout in two
places which is fragile; extract a small helper to install and restore a mocked
setTimeout that records delays and immediately invokes handlers, and reuse it in
the tests around executeRun so both cases (linear and exponential backoff) call
the helper instead of duplicating the mock/restore logic; reference the existing
usages of globalThis.setTimeout, the try/finally blocks, executeRun, and
RetryExhaustedError when implementing the helper.

In `@src/lib/builder.ts`:
- Around line 77-79: The implementation signature for TryBuilder.run currently
redefines the generic E and shadows the class-level E; change the implementation
generic to match the overloads (use C) so the method signature reads run<T,
C>(input: RunInput<T, C>) and forwards to executeRun(this.#config, input);
update the generic name in the implementation only (keep class declaration
TryBuilder<E extends ConfigRunErrors> and the overloads untouched) to avoid
confusing shadowing.

In `@src/lib/retry.ts`:
- Around line 60-75: The switch in calculateRetryDelay over policy.backoff can
fall through silently for unknown variants; add a default case that calls
assertUnreachable(policy.backoff) (same helper used in normalizeRetryPolicy) so
any new backoff enum triggers a compile-time/runtime assertion instead of
leaving delay as 0; update the switch in calculateRetryDelay to include this
default path referencing policy.backoff and assertUnreachable.
- Around line 103-107: The code creates a context via createContext(config) then
immediately mutates ctx.retry.attempt — instead pass the attempt into the
context construction or build the TryCtx inline to avoid the temporary incorrect
default. Update createContext to accept an optional attempt parameter (or add a
helper overload) and call createContext(config, attempt), or construct a TryCtx
object with retry.attempt set to attempt and pass that to policy.shouldRetry;
ensure references to createContext, ctx.retry.attempt, policy.shouldRetry (and
TryCtx if you choose inline construction) are updated accordingly.
- Around line 77-82: The current full-jitter implementation (checking
policy.jitter and returning Math.floor(Math.random() * delay)) allows a zero
delay; either change the jitter computation to enforce a non-zero minimum (e.g.,
replace the return with an equal-jitter formula like Math.floor(delay/2 +
Math.random() * (delay/2)) so result is in [delay/2, delay)) or, if full jitter
(allowing zero) is intentional, add a short explanatory comment next to the
policy.jitter check and the return Math.floor(Math.random() * delay) line
documenting that full jitter (including 0ms) is expected per AWS guidance.

In `@src/lib/types/retry.ts`:
- Around line 22-31: The LinearBackoffRetryPolicy currently forbids maxDelayMs
via "maxDelayMs?: never"; change it to allow an optional numeric cap (e.g.,
"maxDelayMs?: number") so linear backoff can be capped like exponential backoff,
and update the JSDoc for LinearBackoffRetryPolicy to document that baseDelay *
attempt is applied and optionally capped by maxDelayMs; modify any
usage/validation that checks LinearBackoffRetryPolicy to accept and honor
maxDelayMs (refer to the LinearBackoffRetryPolicy and BaseRetryPolicy types and
any retry logic that reads maxDelayMs).

@adelrodriguez adelrodriguez merged commit c7c0529 into main Feb 23, 2026
8 checks passed
@adelrodriguez adelrodriguez deleted the 02-22-add_async_support_to_run_method_with_error_mapping branch February 23, 2026 02:09
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