Retry engine and error classification: IoError rule, non-finite delayOverride, backoff NaN (#78) - #93
Merged
Wahbeh-Mohammad merged 6 commits intoSep 5, 2026
Conversation
`classify.ts`'s cause-walk tests `current instanceof IoError`. After the 2026-09-04 tier flattening only two of `io/errors.ts`'s six classes satisfy that: `IoError` and `TransportFailureError`. `EndOfStreamError`, `SourceContractViolationError`, `ClosedResourceError` and `AllocationLimitError` extend `DexpaceError` directly and are grouped only by `isIoError`, so the walk does not match them -- undecided behaviour, asserted nowhere. Audit #67 / #78 (decision D16) keeps `instanceof IoError` and reads it as the boundary it already draws: the branch means "the wire failed". `TRANSPORT-20` makes `TransportFailureError` an `IoError` for exactly that reason; the four flat leaves are this package's own contract and lifecycle failures and are deterministic on re-send. Six cases, one per class, each asserting both what `isIoError` says and what the classifier says, so the disagreement reads as a decision rather than an oversight. Counterfactual measured: switching the branch to `isIoError(current)` turns exactly the four leaf cases red. Refs #78, #67
`computeDelay` took `initialDelayMs * multiplier ** (attempt - 1)` and relied on `Math.min` to
absorb an overflowing power into the cap. That holds for a positive base and not for a zero one:
`0 * Infinity` is `NaN`, and `Math.min` propagates `NaN` instead of clamping it. `retrySettings()`
accepts `initialDelayMs: 0` with any finite `multiplier >= 1`, so `{initialDelayMs: 0,
multiplier: 1e200}` produced `NaN` at attempt 3 and `{initialDelayMs: 0, multiplier: 2}` at
attempt 1100 -- against RETRY-11's "overflow-safe, saturating rather than throwing".
`NaN` is worse downstream than a large delay, because it fails every comparison: the budget check,
the overshoot check and the engine's `delayMs <= 0` short-circuit all read false and it reaches
`Clock.sleep`, which rejects with a `RangeError` that replaces the failure being retried.
Short-circuit the zero base before the power is taken. Exact, not a repair: the schedule is `0` at
every attempt there, and jitter around `0` is `0` for any sample. Three tests -- the two overflow
attempts, the jittered case at both sample extremes, and a property over the ranges
`retrySettings()` admits (its `initialDelayMs` arm draws an explicit `0`, because the failing
region needs a zero base and an overflowing power together and 100 unbiased runs never paired
them).
Refs #78, #67
RETRY-40 makes a bad caller delay-override non-fatal, and `callerOverride` implemented that for a throw only. A non-finite RETURN was the more damaging case: `NaN` and the infinities pass every guard downstream -- `overshootsBudget` and `budgetExhausted` compare false, `waitFor`'s `delayMs <= 0` short-circuit compares false -- and reach `Clock.sleep`, which rejects a non-finite duration with a `RangeError`. RETRY-33's catch-all folds that rejection into the terminal failure, so `delayOverride: () => NaN` with `maxAttempts: 3` gave ONE send and surfaced `RangeError: Clock.sleep: durationMs must be a non-negative finite number, got NaN`, with the real `TransportFailureError` demoted to the attempt trail. Screen the result for finiteness at the source and treat a non-finite one exactly as a throw: report `http.retry.delayOverrideFailed` at warning level through the same emit path, return `undefined`, let RETRY-39's precedence fall through to the computed schedule. The check sits outside the `try` so a logger that throws while reporting it is not re-reported as an override that threw. Finiteness alone is the screen -- a finite negative keeps continuing inline (RETRY-31), and a fractional or very large delay is one RETRY-39 gives the caller precedence for; both pinned. Four engine cases: the schedule the clock is actually asked to sleep for under all three non-finite values, the reported symptom against a clock that guards its input the way `defaultClock` does, the finite passthrough, and the log event with the cause naming the rejected value. Refs #78, #67
Audit #67 / #68 re-anchored `deviations.md` item 17 and recorded that its rationale said something false — "the cause-walk returns retryable for any `IoError`" reads as covering all five classes `isIoError` names, and the branch matches two. It left the decision to #78. #78 keeps `instanceof IoError` and writes the rule down in the three places that carry it: - `deviations.md` item 17: the rationale now says the branch is a boundary, not a category, and a second paragraph says what the four flat leaves are and why re-sending them is pointless. `classify.ts:73` re-anchored to `:90`, and the anchor-correction block records both dates. - `packages/core/src/io/index.ts`: "load-bearing on it" is qualified with what it actually matches, and with the instruction not to re-parent a leaf under `IoError` to tidy the tree. `packages/core/src/index.ts:39-47` re-anchored to `:39-48`. No behaviour change. `probe.mjs --only=citations` clean. Refs #78, #67
`max-lines-per-function` and `max-params` count a `describe` callback and a fast-check property's arrow like any other function, and the previous three commits pushed three of them over. - `backoff.test.ts`: the two zero-base cases and the totality property move to their own `a zero initial delay (RETRY-11)` describe; the property's four arbitraries become one `fc.record`, which is also closer to what `retrySettings()` actually validates. - `engine.test.ts`: the two non-finite-override cases move to their own describe; `recordingClock`, `guardingClock`, `oneFailureThenSuccess` and `captureLogEvents` are hoisted to module scope. The logger harness was copied verbatim in two existing tests and would have been a third -- extracting it shortens all three, and the throwing-override case gains the `cause` assertion its non-finite sibling has. No assertion weakened; `bun test packages/core/src/retry` is 173 pass, 0 fail. Refs #78, #67
`delayOverride` is the public entry point for RETRY-40's non-fatal-override behaviour, and its
TSDoc documented only the `undefined` fall-through. A caller had no way to learn from the shipped
`.d.ts` that a throw is ignored, and after this branch's engine fix, that a non-finite result is
ignored the same way.
Backticked prose, no `{@link}` -- `http.retry.delayOverrideFailed` is an event name, not a symbol,
and a link out of a `@public` block is what `api:ci` fails on where `api:local` warns.
`core.api.md` regenerated: byte-identical. The report records the declaration and its release tag,
not the prose body, and no signature moved. The sentence does reach the shipped artifact --
verified in `packages/core/dist/retry/retry-step.d.ts`.
Round 2 of #78; the maintainer added this file to the task partition.
Refs #78, #67
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #78. Part of the audit remediation umbrella #67, milestone 4, wave 5. Base
audit/remediation-67.Implements decision D16 (
docs/audit-67-decisions.md) in full: three findings, two behaviour fixes and onerule written down in the three places that carry it.
What changed
1. The classifier keeps
instanceof IoError, and item 17 now says what that meansD16 option (b).
retry/classify.ts's cause-walk matches two of the six classes inio/errors.ts—IoErrorand
TransportFailureError— and not the four leaves thatisIoErrorgroups. That was true before this PR andasserted nowhere; #68 recorded the gap and left the decision here.
The branch means "the wire failed".
TRANSPORT-20makesTransportFailureErroranIoErrorfor exactlythat reason, and
RETRY-2's "an I/O error" is read as that boundary. The four flat leaves are this package'sown contract and lifecycle failures and are deterministic on re-send: a closed resource (
IO-42) and a sourcereturning zero bytes for a positive read (
IO-17) are caller programming errors, an allocation cap (IO-9) isa limit the same request hits again, and
EndOfStreamErroris the exact-length-copy contract insideio/— awire truncation is the transport's to surface, as a
TransportFailureError.No code change to
classify.tsbeyond the TSDoc that states the rule and a one-line comment at the branch.2.
fix: a non-finitedelayOverridefalls back like a throwing one (retry/engine.ts)RETRY-40makes a bad override non-fatal, andcallerOverrideimplemented that for a throw only. A non-finitereturn was the more damaging case:
NaNand the infinities pass every guard downstream —overshootsBudgetand
budgetExhaustedcompare false,waitFor'sdelayMs <= 0short-circuit compares false — and reachClock.sleep, which rejects a non-finite duration with aRangeError.RETRY-33's catch-all folds that intothe terminal failure, so
delayOverride: () => NaNwithmaxAttempts: 3gave one send and surfacedRangeError: Clock.sleep: durationMs must be a non-negative finite number, got NaN, with the realTransportFailureErrordemoted to the attempt trail. Reproduced before the fix, exactly as the issue describes.Now: screen the result for finiteness at the source, report
http.retry.delayOverrideFailedat warning levelthrough the same emit path a throw uses, return
undefined, and letRETRY-39's precedence fall through to thecomputed schedule. Finiteness alone is the screen — a finite negative keeps continuing inline (
RETRY-31), anda fractional or very large delay is one
RETRY-39gives the caller precedence for. Both pinned.3.
fix: a zero initial delay never yields aNaNbackoff (retry/backoff.ts)computeDelayrelied onMath.minto absorb an overflowing power into the cap. That holds for a positive baseand not for a zero one:
0 * InfinityisNaN, andMath.minpropagates it.retrySettings()acceptsinitialDelayMs: 0with any finitemultiplier >= 1, so{initialDelayMs: 0, multiplier: 1e200}gaveNaNatattempt 3 and
{initialDelayMs: 0, multiplier: 2}at attempt 1100 — againstRETRY-11. Short-circuit the zerobase before the power is taken; exact rather than a repair, since the schedule is
0at every attempt there andjitter around
0is0for any sample.Test rows added
packages/core/src/retry/classify.test.ts— new describethe I/O boundary the cause-walk tests (RETRY-2/RETRY-4, TRANSPORT-20):IoError itself is retryableTransportFailureError is retryable (TRANSPORT-20)instanceof IoErrorholds, so the requirement routes with no retry-layer editEndOfStreamError is NOT retryable, buried in a cause chain eitherSourceContractViolationError is NOT retryableClosedResourceError is NOT retryableAllocationLimitError is NOT retryableEach leaf case asserts both that
isIoErroraccepts the value and what the classifier answers, so thedisagreement reads as a decision. Counterfactual measured: switching the branch to
isIoError(current)turnsexactly those four red.
packages/core/src/retry/backoff.test.ts— new describea zero initial delay (RETRY-11):stays zero where the power overflows(both overflow attempts),stays zero under jitter too (RETRY-10)(both sample extremes),and
property: every accepted schedule is finite and non-negative (RETRY-11)over the rangesretrySettings()admits. All three red before the fix.
packages/core/src/retry/engine.test.ts— new describea non-finite delayOverride is the throwing case (RETRY-40):every non-finite value falls back to the computed schedule(asserts the durations the clock wasasked for,
[200, 400], underNaN/+Infinity/-Infinity) andit never reaches Clock.sleep as a duration, so the real failure survives(the reported symptom, against a clock that guards its input the waydefaultClockdoes: three sends, the finalTransportFailureErrorsurfaced, two entries in the trail). Plusa finite override is honored unchanged, fractional and huge alike (RETRY-39)andemits delayOverrideFailed when delayOverride returns a non-finite delay. Three of the four red before the fix.A fifth commit keeps the new cases inside the lint caps (
max-lines-per-functioncounts adescribecallback;max-paramscounts a fast-check property's arrow).recordingClock,guardingClock,oneFailureThenSuccessand
captureLogEventsare hoisted to module scope inengine.test.ts; the logger harness was copied verbatimin two existing tests and would have been a third. No assertion weakened.
No
tests/node-conformance/case: nothing here touches a runtime-divergent surface. The engine's contact withthe runtime is
Clock.sleep, whose non-finite guard is this package's own code and is already covered byconfig-primitives.test.mjs; after this change no non-finite value reaches it.Deviation rows added
None — and that is D16's instruction. This is a section edit to item 17 of
docs/deviations.md, not arow in the "Deviations recorded outside a phase" table. The row already existed; #68 wrote it and left the rule
undecided. What changed is item 17's rationale paragraph (the branch is a boundary, not a category), a second
paragraph on why the four leaves stay outside it, and the anchor-correction block, which now records both dates
and both anchors.
packages/core/src/io/index.ts's comment says the same thing at the source. Re-anchored:classify.ts:73→:90;packages/core/src/index.ts:39-47→:39-48.node .claude/skills/housekeeping/probe.mjs --only=citations: no drift found.Gate
node .claude/skills/ci-preflight/run-ci.mjs --clean(no--node-floor; other agents were runningconcurrently), pinned Bun 1.3.14 from
.bun-version:bun run apiclean against all nine committed reports: no@publicTSDoc changed, so noapi:localwasneeded. Every symbol touched (
isRetryableFailure,computeDelay,callerOverride,RetryConfig) is@internal.Open question for the maintainer
RetryStepOptions.delayOverride(packages/core/src/retry/retry-step.ts:41-49) is the public entry pointfor this behaviour, and its TSDoc documents only the
undefinedfall-through. It is not in this task'spartition, so per contract item 4 it was not edited. Recommendation: add one sentence — "A throw, or a
non-finite result, is ignored: the configured schedule is used for that attempt and
http.retry.delayOverrideFailedis logged (RETRY-40)." That changes a@publicTSDoc, so it needsapi:localon core and a regenerated
core.api.md.Deferred — release machinery
Suspended for this run under D1; recover at the release pass.
@dexpace/core, covering two caller-visible behaviour changes:delayOverridereturningNaN,+Infinityor-Infinityis now ignored with a warning log(
http.retry.delayOverrideFailed) and the computed schedule is used, instead of the retry loop terminatingafter one send with a
RangeErrorfromClock.sleep;computeDelayreturns0rather thanNaNforinitialDelayMs: 0at the attempt where the poweroverflows, so a retry configured that way no longer fails on its first backoff.
docs/first-release.mdedit, no version bump.