Skip to content

Async utilities count swallowed retryer failures as successes and call onSuccess(undefined) #247

Description

@KevinVandy

Describe the bug

AsyncRetryer.execute resolves undefined in three non-success cases: the retryer is disabled (enabled: false), all attempts failed with retryer-level throwOnError: false, or the execution was aborted.

Every async utility that wraps an internal retryer (AsyncDebouncer, AsyncThrottler, AsyncRateLimiter, AsyncQueuer, AsyncBatcher) treats that resolved undefined as a success: successCount increments, lastResult is overwritten with undefined, and onSuccess fires with undefined as the result — e.g. onSuccess: (result: string) => ... receives undefined at runtime.

Repro sketch:

const debouncer = new AsyncDebouncer(
  async () => { throw new Error('always fails') },
  {
    wait: 100,
    asyncRetryerOptions: { maxAttempts: 2, throwOnError: false },
    onSuccess: (result) => console.log('success?', result), // fires with undefined
  },
)

Design questions to settle

  • Should a swallowed final failure settle as an error at the parent level (increment errorCount, fire onError) even though the retryer didn't throw?
  • Should an aborted execution count as neither success nor error (probably — it already returns early in some paths)?
  • AsyncRetryer.execute's undefined return is ambiguous by design; distinguishing outcomes may need an internal result envelope or reading the retryer's final state (lastError) after execute resolves.

Noted during review of #246 (see CodeRabbit feedback there) — split out because the fix changes observable semantics across all five utilities rather than being a bugfix in one.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions