Skip to content

fix(core): an abort surfaces the caller's reason; a cancel is never a retry - #674

Open
rejifald wants to merge 2 commits into
mainfrom
claude/frosty-goodall-5de390
Open

fix(core): an abort surfaces the caller's reason; a cancel is never a retry#674
rejifald wants to merge 2 commits into
mainfrom
claude/frosty-goodall-5de390

Conversation

@rejifald

@rejifald rejifald commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

Two abort-semantics bugs in the resilience chain, plus the mocking kit's mirrors of them:

  • systemClock.sleep swallowed the abort reason. It rejected every abort with a minted Error('aborted'), so a caller who aborted a call parked in a retry-backoff sleep lost their AbortError / custom abort(reason). The same prefer-the-signal's-reason logic already existed twice — abortReason() in engine.ts and abortError() in resilience.ts — three hand-copies, one drifted. util.ts now exports the one abortReason helper and sleep, the engine's abort paths, and withTimeout's signal link all share it.
  • A mid-flight abort read as a retry. When a caller aborted during a non-final attempt, the attempt-loop catch still emitted a retry progress event and fired the onRetry hook before dying in the backoff sleep — a phantom retry for a deliberately cancelled call. The catch now rethrows immediately when baseReq.signal is aborted (after onError, which legitimately observed the attempt ending).
  • Mocking-kit fidelity: manualClock.sleep now rejects with the reason exactly like systemClock.sleep (the clock seam's whole contract), and mockAdapter's abortable delay / pre-aborted check reject like real fetch does (which surfaces signal.reason). Without these, neither fix is observable under injected-clock tests.

Tests

New test/abort-reason.spec.ts plus one case in manual-clock.spec.ts:

  • abort with a custom reason during backoff surfaces that reason via safe() — one attempt only, backoff timer dropped, not leaked (manualClock, fully virtual);
  • abort mid-flight emits zero retry events, never fires onRetry, and the error event carries the reason;
  • direct pins that both clocks' sleep reject with the same Error instance the caller aborted with, plus the non-Error-reason fallback.

All six fail against the pre-fix code (verified by reverting src and running). Full core suite: 1494 passed; eslint and tsc --noEmit clean; pre-push gauntlet green.

Reviewer notes

  • Bundle budget raised 24.10→24.30 / 21.50→21.70 KB (measured 24.08 / 21.51 against a main at 24.08 / 21.50). The fix is minified-smaller (−0.09 KB from the dedup) but gzip charges +0.01 KB — the deleted duplicate copies were near-free backreferences. Keeping them duplicated measured no better (21.51 either way, +0.19 KB minified), so the dedup stays; main had run down to 0.00 KB of stitch-scenario headroom, so the conventional ~0.2 KB step applies (the Implement ADR 0023 — fix the store-backed cold-start burst, then widen the rate denominator #620 shape: sized to the headroom the gate holds, not to this change). Full rationale is with the budgets in bundle-size.mjs.
  • Advertised figure moves ~21 → ~22 kB: 21.51 crosses the rounding boundary, so the six managed sites under the bundle-advertised-size tether are updated (both READMEs, installation + principles pages, home metrics, docs source blurb; the whole entry stays ~24). The core README's whole-entry brotli figure moves to ~22 as well — 21.57 KB always rounded there.
  • Scope note: only the transport-error catch path gets the abort guard, as the narrow status-driven race (abort landing between a completed response and the status check) can still emit one legitimate retry event — but the subsequent backoff now rejects promptly with the caller's reason.

🤖 Generated with Claude Code

rejifald and others added 2 commits August 6, 2026 15:44
… retry

systemClock.sleep rejected every abort with a minted Error('aborted'),
so a caller who aborted a call parked in a retry backoff lost their
AbortError / custom abort(reason). The same logic already existed as
engine.ts abortReason and resilience.ts abortError — three hand-copies,
one drifted. util.ts now exports the one abortReason helper (reject with
signal.reason when it is an Error, else a generic abort Error) and
sleep, the engine, and withTimeout's link path all share it.

Second half: when a caller aborted mid-flight on a non-final attempt,
the attempt-loop catch still emitted a 'retry' progress event and fired
onRetry before the backoff sleep rejected — a phantom retry for a
deliberately cancelled call. The catch now rethrows immediately when
baseReq.signal is aborted (after onError, which legitimately observed
the attempt ending).

The mocking kit gets the same treatment for seam fidelity: manualClock
must behave like systemClock through the clock seam, and mockAdapter's
abortable delay must reject like real fetch (which surfaces
signal.reason) — without that, neither fix is observable under
injected-clock tests.

Tests: abort with a custom reason during backoff surfaces that reason
(one attempt, no leaked timer); abort mid-flight emits no retry event
and skips onRetry; direct pins that both clocks' sleep reject with the
same Error instance. All six fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ised ~21 → ~22 kB

The abort-reason fix measures 24.08 / 21.51 KB gzip against a main at
24.08 / 21.50 — a +0.01 KB change against a stitch-scenario budget main
had run down to 0.00 KB of headroom on. The conventional ~0.2 KB step
(24.10→24.30 / 21.50→21.70), sized to the headroom the gate is meant to
hold rather than to this change, so the next small core-path fix is not
gated on a budget PR of its own. Full rationale lives with the budgets
in bundle-size.mjs, including why the shared-helper dedup stays despite
gzip charging for it (keeping the duplicated copies measured no better,
with +0.19 KB minified).

21.51 crosses the advertised figure's rounding boundary, so the six
managed sites under the bundle-advertised-size tether move ~21 → ~22 kB
(the whole entry stays ~24; the core README's whole-entry brotli figure
also moves to ~22, which 21.57 KB always rounded to). All nine yakir
tethers check ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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