From 71c99979747a59aa051b794c72e025cfb0015d15 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 11:46:21 +0300 Subject: [PATCH 1/6] test(retry): pin one retryability answer per I/O error class `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 --- packages/core/src/retry/classify.test.ts | 89 ++++++++++++++++++++++-- packages/core/src/retry/classify.ts | 17 +++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/core/src/retry/classify.test.ts b/packages/core/src/retry/classify.test.ts index 521fd5a..03ee970 100644 --- a/packages/core/src/retry/classify.test.ts +++ b/packages/core/src/retry/classify.test.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT // packages/core/src/retry/classify.test.ts // Exercises: RETRY-1 (single-sourced status set, 501/505 excluded), RETRY-2 (iterative -// identity-tracking cause walk, cycle-safe), RETRY-3 (retryability derived from status, not a stored -// flag), RETRY-4 (transport failures always retryable), RETRY-5/6/7 (re-sendability), RETRY-8 (both -// axes required), RETRY-23/24 (cancellation vs timeout), RETRY-25 (allow-list makes the fatal -// exclusion vacuous), RETRY-37 (configured set is authoritative -- widens AND narrows), +// identity-tracking cause walk, cycle-safe; and the I/O boundary the walk tests -- one case per +// error class in `io/errors.ts`, see the block below), RETRY-3 (retryability derived from status, +// not a stored flag), RETRY-4 (transport failures always retryable), RETRY-5/6/7 (re-sendability), +// RETRY-8 (both axes required), RETRY-23/24 (cancellation vs timeout), RETRY-25 (allow-list makes +// the fatal exclusion vacuous), RETRY-37 (configured set is authoritative -- widens AND narrows), +// TRANSPORT-20 (a no-response send surfaces as a retryable I/O subtype), // XCUT-5 (the baked retryability flag comes from ONE shared status classifier covering 408/429/all // 5xx except 501 and 505 -- asserted below. This port has no separately-cached boolean field: the // classifier is a pure function of HttpStatusError.status, which never changes post-construction @@ -16,7 +18,15 @@ import {stringBody} from '../body/simple-bodies.js'; import {streamBody} from '../body/stream-body.js'; import type {Body} from '../body/body.js'; import {Request} from '../http/request.js'; -import {IoError} from '../io/errors.js'; +import { + AllocationLimitError, + ClosedResourceError, + EndOfStreamError, + IoError, + isIoError, + SourceContractViolationError, + TransportFailureError, +} from '../io/errors.js'; import {CancellationError} from '../seams/transport.js'; import { RETRYABLE_STATUSES, @@ -119,6 +129,75 @@ describe('isRetryableFailure', () => { }); }); +/** + * One case per class in `io/errors.ts`, pinning the boundary RETRY-2's "an I/O error" is read as. + * + * `isIoError` accepts all six classes the file declares; `classify.ts`'s walk tests + * `instanceof IoError`, which two of them satisfy. That gap was undecided until audit #67 / #78 + * decided it (`docs/deviations.md` item 17): the branch means "the wire failed", so `IoError` and + * `TransportFailureError` retry and the four flat leaves do not. Each leaf case asserts BOTH halves + * -- that `isIoError` accepts the value, and what the classifier answers for it -- because the two + * disagreeing is the decision, and a test that only asserted the classifier would read as an + * oversight rather than a choice. + * + * These are the guard on re-parenting: moving any leaf back under `IoError`, or switching the branch + * to `isIoError`, turns four of them red instead of quietly making a deterministic failure retryable. + * Measured 2026-09-05 by making that one-line change: exactly these four fail. + */ +describe('the I/O boundary the cause-walk tests (RETRY-2/RETRY-4, TRANSPORT-20)', () => { + test('IoError itself is retryable', () => { + const error = new IoError('connection refused'); + expect(isIoError(error)).toBe(true); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(true); + }); + + test('TransportFailureError is retryable (TRANSPORT-20)', () => { + // The one class TRANSPORT-20 requires to BE an IoError. A send that produced no response is the + // canonical retryable condition (RETRY-4), and the `extends` is what carries it here. + const error = new TransportFailureError('ECONNREFUSED'); + expect(error).toBeInstanceOf(IoError); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(true); + }); + + test('EndOfStreamError is NOT retryable, buried in a cause chain either', () => { + // The exact-length-copy contract inside io/, not a wire truncation: a short copy repeats on the + // next attempt. A truncated response is the transport's to report, as TransportFailureError. + const error = new EndOfStreamError(3, 8); + expect(isIoError(error)).toBe(true); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(false); + // Asserted through a wrapper too: the walk is what would rescue it if the branch widened, so the + // shallow case alone would not catch a change made one hop up. + expect( + isRetryableFailure( + new Error('read failed', {cause: error}), + RETRYABLE_STATUSES, + ), + ).toBe(false); + }); + + test('SourceContractViolationError is NOT retryable', () => { + // A foreign source that returned zero bytes for a positive read (IO-17) is a programming error + // in the source, deterministic on re-send. + const error = new SourceContractViolationError('source returned 0 bytes'); + expect(isIoError(error)).toBe(true); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(false); + }); + + test('ClosedResourceError is NOT retryable', () => { + // Using a closed resource (IO-42) is a caller lifecycle error; the resource stays closed. + const error = new ClosedResourceError('response body'); + expect(isIoError(error)).toBe(true); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(false); + }); + + test('AllocationLimitError is NOT retryable', () => { + // A cap the same request hits again (IO-9); retrying spends the budget to fail identically. + const error = new AllocationLimitError(2 ** 32, 2 ** 31 - 1); + expect(isIoError(error)).toBe(true); + expect(isRetryableFailure(error, RETRYABLE_STATUSES)).toBe(false); + }); +}); + describe('isRetryableFailure -- cancellation, timeouts, and the allow-list', () => { test('a user abort is never retryable (RETRY-23)', () => { const controller = new AbortController(); diff --git a/packages/core/src/retry/classify.ts b/packages/core/src/retry/classify.ts index 7073395..f911e70 100644 --- a/packages/core/src/retry/classify.ts +++ b/packages/core/src/retry/classify.ts @@ -53,6 +53,22 @@ function causeOf(value: unknown): unknown { * surfaces as an `IoError` subclass and is therefore retryable unconditionally at this level * (RETRY-4). * + * **The I/O branch tests `instanceof IoError`, not `isIoError`, and the difference is the rule.** + * `io/errors.ts` groups six classes under `isIoError`, but only two of them descend from `IoError`: + * `IoError` itself, and `TransportFailureError` -- the class TRANSPORT-20 requires a send that + * produced no response to surface, and the reason that `extends` is a requirement rather than a + * modelling choice (`docs/deviations.md` item 17). Those two mean "the wire failed", and RETRY-2's + * "an I/O error" is read as exactly that boundary. The other four -- `EndOfStreamError`, + * `SourceContractViolationError`, `ClosedResourceError`, `AllocationLimitError` -- extend + * `DexpaceError` directly and are deliberately outside it: they are this package's own contract and + * lifecycle failures and are deterministic on re-send. A closed resource or a violated source + * contract is a caller programming error, an allocation cap is a limit the same request hits again, + * and `EndOfStreamError` is the exact-length-copy contract inside `io/` -- a *wire* truncation is the + * transport's to report, as a `TransportFailureError`. Widening this branch to `isIoError` would + * retry all four. Decided by audit #67 / #78; one case per class in `classify.test.ts` pins the + * answer, so a later re-parenting of any leaf under `IoError` changes a test rather than passing + * silently. + * * @param error - whatever was thrown; any value, not necessarily an `Error`. * @param statuses - the CONFIGURED set, authoritative on its own -- it both widens and narrows * relative to `RETRYABLE_STATUSES`, and the built-in classifier is not AND-ed in (RETRY-37). @@ -70,6 +86,7 @@ export function isRetryableFailure( seen.add(current); // RETRY-3: derived from the carried status at classification time, never a stored per-subclass flag. if (current instanceof HttpStatusError) return statuses.has(current.status); + // Deliberately `instanceof IoError`, never `isIoError` -- see the boundary paragraph above. if (current instanceof IoError) return true; if (isTimeoutAbort(current)) return true; current = causeOf(current); From b9371752728d2c9619e43d7b2f4cb104d473701b Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 11:47:31 +0300 Subject: [PATCH 2/6] fix(retry): a zero initial delay never yields a NaN backoff `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 --- packages/core/src/retry/backoff.test.ts | 53 +++++++++++++++++++++++++ packages/core/src/retry/backoff.ts | 13 ++++++ 2 files changed, 66 insertions(+) diff --git a/packages/core/src/retry/backoff.test.ts b/packages/core/src/retry/backoff.test.ts index 836ca61..0f309ae 100644 --- a/packages/core/src/retry/backoff.test.ts +++ b/packages/core/src/retry/backoff.test.ts @@ -39,6 +39,59 @@ describe('exponential schedule', () => { expect(() => computeDelay(0, SETTINGS, never)).toThrow(); expect(() => computeDelay(-1, SETTINGS, never)).toThrow(); }); + + test('a zero initial delay stays zero where the power overflows (RETRY-11)', () => { + // `0 * Infinity` is NaN, and NaN is the one value the saturation above cannot absorb: `Math.min` + // propagates it, `overshootsBudget` reads false for it, the engine's `delayMs <= 0` guard reads + // false for it, and it lands in `Clock.sleep` as a RangeError that replaces the real failure. + // `retrySettings` accepts both of these (`initialDelayMs >= 0`, finite `multiplier >= 1`), so + // "overflow-safe, saturating rather than throwing" has to hold for them too. + const hugeMultiplier: BackoffSettings = { + ...SETTINGS, + initialDelayMs: 0, + multiplier: 1e200, + }; + expect(computeDelay(3, hugeMultiplier, never)).toBe(0); + + const manyAttempts: BackoffSettings = {...SETTINGS, initialDelayMs: 0}; + // 2 ** 1099 is Infinity: the first attempt at which the doubling schedule overflows a double. + expect(computeDelay(1100, manyAttempts, never)).toBe(0); + }); + + test('a zero initial delay stays zero under jitter too (RETRY-10/11)', () => { + const jitteredZero: BackoffSettings = { + ...SETTINGS, + initialDelayMs: 0, + multiplier: 1e200, + jitter: 1, + }; + expect(computeDelay(4, jitteredZero, () => 0)).toBe(0); + expect(computeDelay(4, jitteredZero, () => 1)).toBe(0); + }); + + test('property: every accepted schedule is finite and non-negative (RETRY-11)', () => { + // The ranges are exactly what `retrySettings()` admits, so a passing property means no + // configuration a caller can build reaches the engine as a non-finite delay. `initialDelayMs` + // is drawn through an explicit `constant(0)` arm: the failing region needs a zero base AND an + // overflowing power together, and 100 runs of an unbiased double never produced the pair. + fc.assert( + fc.property( + fc.integer({min: 1, max: 5000}), + fc.oneof(fc.constant(0), fc.double({min: 0, max: 1e9, noNaN: true})), + fc.double({min: 1, max: 1e300, noNaN: true}), + fc.double({min: 0, max: 1, noNaN: true}), + (attempt, initialDelayMs, multiplier, jitter) => { + const delay = computeDelay( + attempt, + {initialDelayMs, multiplier, maxDelayMs: 8000, jitter}, + never, + ); + expect(Number.isFinite(delay)).toBe(true); + expect(delay).toBeGreaterThanOrEqual(0); + }, + ), + ); + }); }); describe('symmetric jitter', () => { diff --git a/packages/core/src/retry/backoff.ts b/packages/core/src/retry/backoff.ts index 63f8700..b505258 100644 --- a/packages/core/src/retry/backoff.ts +++ b/packages/core/src/retry/backoff.ts @@ -49,6 +49,17 @@ function applyJitter( * Overflow-safe by construction (RETRY-11): a large attempt makes `**` return `Infinity`, which * `Math.min` absorbs into the cap. It saturates; it never throws. * + * Except at a zero base, where the saturation does not hold and the guard below is what supplies it. + * `0 * Infinity` is `NaN`, and `Math.min` propagates `NaN` rather than clamping it -- so + * `initialDelayMs: 0` with any multiplier above 1 produced a `NaN` delay at the attempt where the + * power overflows (`multiplier: 2` reaches it at attempt 1100; `multiplier: 1e200` at attempt 3). + * `retrySettings()` accepts both configurations. Downstream, `NaN` is worse than a large number: it + * fails every comparison, so the engine's budget check, its overshoot check and its `delayMs <= 0` + * short-circuit all read false and it arrives at `Clock.sleep`, which rejects with a `RangeError` + * that replaces the failure being retried. Short-circuiting the zero base before the power is taken + * is exact rather than a repair: the schedule's value there is `0` at every attempt, and jitter + * around `0` is `0` for any sample (audit #67 / #78). + * * `random` is injected so jitter is assertable rather than statistical -- the same determinism seam * CFG-15 wants for the clock. * @@ -71,6 +82,8 @@ export function computeDelay( `retry attempt must be 1-indexed and >= 1, got ${String(attempt)}`, ); if (settings.fixedDelayMs !== undefined) return settings.fixedDelayMs; + // Before the power, not after: `0 * Infinity` is the one product `Math.min` cannot absorb. + if (settings.initialDelayMs === 0) return 0; const growth = settings.initialDelayMs * settings.multiplier ** (attempt - 1); return applyJitter( Math.min(growth, settings.maxDelayMs), From 426838b9e4539965810c0baadc6eed09d35d9e67 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 11:49:50 +0300 Subject: [PATCH 3/6] fix(retry): a non-finite delayOverride falls back like a throwing one 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 --- packages/core/src/retry/engine.test.ts | 149 ++++++++++++++++++++++++- packages/core/src/retry/engine.ts | 64 ++++++++--- 2 files changed, 199 insertions(+), 14 deletions(-) diff --git a/packages/core/src/retry/engine.test.ts b/packages/core/src/retry/engine.test.ts index 3405c96..542f1b4 100644 --- a/packages/core/src/retry/engine.test.ts +++ b/packages/core/src/retry/engine.test.ts @@ -8,7 +8,9 @@ // final attempt's own error is what the engine hands back, cancellation included), // RETRY-35/RECOV-16 (body released before the wait, bounded buffering), RETRY-36/RECOV-19 (503,503,200 // terminates on the 200; a surviving response is returned LIVE), RETRY-39/40 (delay precedence; a -// throwing override is non-fatal), RETRY-42/RECOV-28 (per-call state). +// throwing override is non-fatal -- and a non-finite RETURN from one is the same case, falling back +// to the schedule and logging through the same event; audit #67 / #78), RETRY-42/RECOV-28 (per-call +// state). import {describe, expect, test} from 'bun:test'; import {HttpStatusError} from '../body/http-status-error.js'; import type {Clock} from '../config/clock.js'; @@ -228,6 +230,115 @@ describe('delay resolution (RETRY-39/40)', () => { expect(outcome.kind).toBe('success'); expect(dispatch.calls).toHaveLength(2); }); + + test('a non-finite override falls back to the schedule, like a throwing one (RETRY-40)', async () => { + // RETRY-40 makes a bad override non-fatal. A throw was handled; a non-finite RETURN was not, and + // it is the worse of the two, because `NaN` fails every comparison downstream instead of failing + // loudly here. Audit #67 / #78 reads the two as one case: drop the value, use the computed + // schedule, keep going. Asserted on the delays the clock was ASKED for -- three sends alone would + // also pass on a clock that quietly slept for `NaN`. + for (const bad of [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ]) { + const slept: number[] = []; + const config: RetryConfig = { + settings: retrySettings({ + maxAttempts: 3, + initialDelayMs: 200, + multiplier: 2, + jitter: 0, + }), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: durationMs => { + slept.push(durationMs); + return Promise.resolve(); + }, + }, + random: () => 0.5, + delayOverride: () => bad, + }; + const dispatch = scriptedDispatch([ + failure(new TransportFailureError('connection refused')), + ]); + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(dispatch.calls).toHaveLength(3); + expect(slept).toEqual([200, 400]); + expect(outcome.kind).toBe('failure'); + } + }); + + test('a non-finite override never reaches Clock.sleep as a duration (RETRY-40)', async () => { + // The reported symptom, with a clock that guards its input the way `defaultClock` does: the + // rejection was folded into the terminal failure by RETRY-33's catch-all, so `delayOverride: + // () => NaN` with `maxAttempts: 3` gave ONE send and surfaced a `RangeError` about `durationMs` + // in place of the transport failure being retried -- with the real error demoted to the trail. + const config: RetryConfig = { + settings: retrySettings({maxAttempts: 3, fixedDelayMs: 0}), + clock: { + now: () => 0, + monotonic: () => 0, + sleep: durationMs => + Number.isFinite(durationMs) + ? Promise.resolve() + : Promise.reject( + new RangeError( + `Clock.sleep: durationMs must be a non-negative finite number, got ${String(durationMs)}`, + ), + ), + }, + random: () => 0.5, + delayOverride: () => Number.NaN, + }; + const dispatch = scriptedDispatch([ + failure(new TransportFailureError('first')), + failure(new TransportFailureError('second')), + failure(new TransportFailureError('third')), + ]); + + const outcome = await runWithRetry(GET, dispatch, config); + + expect(dispatch.calls).toHaveLength(3); + expect(outcome.kind).toBe('failure'); + if (outcome.kind !== 'failure') return; + expect(outcome.error).toBeInstanceOf(TransportFailureError); + expect((outcome.error as Error).message).toBe('third'); + expect(retryAttempts(outcome.error)).toHaveLength(2); + }); + + test('a finite override is honored unchanged, fractional and huge alike (RETRY-39)', async () => { + // The finiteness guard screens `NaN` and the two infinities and nothing else. A fractional or + // very large delay is still a delay, and RETRY-39 gives the caller precedence over the schedule. + const slept: number[] = []; + const clock: Clock = { + now: () => 0, + monotonic: () => 0, + sleep: durationMs => { + slept.push(durationMs); + return Promise.resolve(); + }, + }; + + for (const override of [0.5, Number.MAX_SAFE_INTEGER]) { + await runWithRetry( + GET, + scriptedDispatch([failure(new TransportFailureError('reset'))]), + { + settings: retrySettings({maxAttempts: 2, fixedDelayMs: 5000}), + clock, + random: () => 0.5, + delayOverride: () => override, + }, + ); + } + + expect(slept).toEqual([0.5, Number.MAX_SAFE_INTEGER]); + }); }); describe('server pacing hints (RETRY-20/22)', () => { @@ -1057,4 +1168,40 @@ describe('Phase 7b retrofit: structured retry logging', () => { setGlobalLogger(NOOP_LOGGER); } }); + + test('emits delayOverrideFailed when delayOverride returns a non-finite delay', async () => { + // "Treated exactly like one that throws" (RETRY-40) is a claim about the diagnostic too: a + // silently-ignored override is a schedule the operator cannot explain. Same event, same level, + // same emit path -- only the cause differs, and it names the value that was rejected. + const {createLogger, setGlobalLogger, NOOP_LOGGER} = + await import('../observability/logger.js'); + const events: Map[] = []; + const testLogger = createLogger((_level, fields) => { + events.push(new Map(fields)); + }); + setGlobalLogger(testLogger); + + try { + const config: RetryConfig = { + ...configOf({maxAttempts: 2, fixedDelayMs: 0}), + delayOverride: () => Number.NaN, + }; + const dispatch = scriptedDispatch([ + failure(new IoError('first')), + success(countingResponse(200).response), + ]); + + await runWithRetry(GET, dispatch, config); + + const overrideFailed = events.filter( + e => e.get('event') === 'http.retry.delayOverrideFailed', + ); + expect(overrideFailed).toHaveLength(1); + expect(overrideFailed[0]?.get('cause')).toBe( + 'delayOverride returned a non-finite delay: NaN', + ); + } finally { + setGlobalLogger(NOOP_LOGGER); + } + }); }); diff --git a/packages/core/src/retry/engine.ts b/packages/core/src/retry/engine.ts index edce8be..056a6f3 100644 --- a/packages/core/src/retry/engine.ts +++ b/packages/core/src/retry/engine.ts @@ -48,7 +48,7 @@ export interface RetryConfig { readonly clock: Clock; /** Injectable randomness -- jitter and the X-RateLimit-Reset spread both draw from it. */ readonly random: () => number; - /** Highest-precedence delay source (RETRY-39). A throw is non-fatal (RETRY-40). */ + /** Highest-precedence delay source (RETRY-39). A throw, or a non-finite result, is non-fatal (RETRY-40). */ readonly delayOverride?: ((attempt: number) => number | undefined) | undefined; } @@ -102,25 +102,61 @@ function overshootsBudget(delayMs: number, state: LoopState): boolean { } /** - * RETRY-40: a throwing user override is ignored, never fatal. Emits http.retry.delayOverrideFailed at warning level. + * RETRY-40's diagnostic half, shared by both ways an override can fail. An ignored override is a + * schedule the operator cannot explain from the configuration alone, so neither way is silent. + */ +function reportOverrideFailure(cause: unknown): void { + try { + getGlobalLogger() + .atLevel('warning') + .event('http.retry.delayOverrideFailed') + .cause(cause) + .emit(); + } catch { + // OBS-20: logger failure must never fail the request or retry loop + } +} + +/** + * RETRY-40: a misbehaving user override is ignored, never fatal. Emits + * http.retry.delayOverrideFailed at warning level. + * + * TWO ways to misbehave, one answer. A throw was always handled here. A non-finite RETURN was not, + * and it was the more damaging of the two: `NaN` and the infinities pass every guard downstream -- + * `overshootsBudget` and `budgetExhausted` compare false, {@link waitFor}'s `delayMs <= 0` + * short-circuit compares false -- and arrive at `Clock.sleep`, which rejects a non-finite duration + * with a `RangeError`. RETRY-33's catch-all then folds that rejection into the terminal failure, so + * a `delayOverride` returning `NaN` under `maxAttempts: 3` produced ONE send and surfaced a + * `RangeError` about `durationMs`, with the transport failure it was retrying demoted to the trail. + * Audit #67 / #78 reads the two as one case: drop the value, use the computed schedule, keep going. + * + * The screen is finiteness alone. A finite negative keeps its existing behaviour -- {@link waitFor} + * continues inline without a timer (RETRY-31), which is the same answer the budget clamp already + * produces -- and a fractional or very large delay is a delay RETRY-39 gives the caller precedence + * for. + * + * The check sits OUTSIDE the `try` on purpose: a logger that throws while reporting a non-finite + * result must not be re-reported as an override that threw. */ function callerOverride(state: LoopState): number | undefined { const {delayOverride} = state.config; if (delayOverride === undefined) return undefined; + let delayMs: number | undefined; try { - return delayOverride(state.attempt); + delayMs = delayOverride(state.attempt); } catch (error) { - try { - getGlobalLogger() - .atLevel('warning') - .event('http.retry.delayOverrideFailed') - .cause(error) - .emit(); - } catch { - // OBS-20: logger failure must never fail the request or retry loop - } + reportOverrideFailure(error); + return undefined; + } + if (delayMs !== undefined && !Number.isFinite(delayMs)) { + // A string cause, not a synthesized Error: nothing threw, and the value that was rejected is + // the whole diagnostic. + reportOverrideFailure( + `delayOverride returned a non-finite delay: ${String(delayMs)}`, + ); return undefined; } + return delayMs; } /** RETRY-39: caller override -> server pacing hint -> fixed delay -> exponential backoff. */ @@ -292,7 +328,9 @@ function attachTrail( * A non-positive delay short-circuits before `sleep` is reached: it continues inline with no timer * (RETRY-31), which is reachable after RETRY-17's past-instant hint and after the budget clamp, and * it is also what keeps a caller `delayOverride` returning a negative number out of `sleep`'s - * negative-duration rejection (RETRY-40 makes a bad override non-fatal). + * negative-duration rejection (RETRY-40 makes a bad override non-fatal). It does NOT catch a + * non-finite one -- `NaN <= 0` is false -- which is why {@link callerOverride} screens those at the + * source rather than here. * * Cancellation RESOLVES here rather than propagating: RETRY-26 wants the loop's next iteration to * observe the signal and stop through its own RETRY-32 path, so the abort rejection is the one From d8d3ad5a9ec579ccf4462f85e7b92088198836d2 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 11:51:12 +0300 Subject: [PATCH 4/6] docs: item 17 states what the cause-walk matches, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/deviations.md | 40 ++++++++++++++++++++++++----------- packages/core/src/io/index.ts | 12 ++++++++++- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/deviations.md b/docs/deviations.md index 6cd1179..2140ea2 100644 --- a/docs/deviations.md +++ b/docs/deviations.md @@ -433,22 +433,38 @@ extend `DexpaceError` **directly** (lines 29, 51, 67, 83) and are grouped by the three-level branch. **Why it cannot be corrected.** `TRANSPORT-20` requires `TransportFailureError` to *be* an `IoError` — the -subtyping is the requirement, not an artifact of modelling. It is also load-bearing: `classify.ts`'s -cause-walk tests `current instanceof IoError` (`packages/core/src/retry/classify.ts:73`), so the `extends` -is what makes a no-response transport failure retryable with zero edits to the retry layer. A flat sibling +subtyping is the requirement, not an artifact of modelling. It is also load-bearing, and **what it bears is +a boundary, not a category**: `classify.ts`'s cause-walk tests `current instanceof IoError` +(`packages/core/src/retry/classify.ts:90`), and because the tree is flat that branch matches exactly two of +the six classes in `io/errors.ts` — `IoError` itself and `TransportFailureError`. It does **not** match the +four leaves `isIoError` groups. That is the intended reading, decided by audit #67 / #78 and stated here +because it is this deviation's own consequence: the branch means "the wire failed". A send that produced no +response is `RETRY-4`'s unconditionally-retryable condition, `TRANSPORT-20` names `TransportFailureError` as +what carries it, and the `extends` is what routes it to the retry layer with no edit there. A flat sibling would have to be enumerated by hand in the retry classifier, and again for every transport added later — trading one level of depth for an open-ended maintenance obligation that the styleguide's own rule exists to prevent. Held at exactly three; a fourth level is not sanctioned by this entry. -> **Anchor correction 2026-09-04 (audit #67 / #68); the rule itself is not decided here.** The line -> numbers above were stale and are re-derived. The substantive point this paragraph used to make — "the -> cause-walk returns retryable for any `IoError`" — reads as covering all five I/O classes, and it does -> not: because the tree is flat, `instanceof IoError` at `classify.ts:73` matches `IoError` itself and -> `TransportFailureError` only. `EndOfStreamError`, `SourceContractViolationError`, -> `ClosedResourceError` and `AllocationLimitError` are **not** retryable through that branch. Whether -> they should be — and therefore whether the classifier tests `instanceof IoError` or the `isIoError` -> predicate — is decided by audit subtask #78, and this row's rationale is rewritten there. Nothing in -> the deviation itself (the three-level branch, and why it stays) turns on that answer. +**What the other four leaves are, and why they stay outside the branch.** `EndOfStreamError`, +`SourceContractViolationError`, `ClosedResourceError` and `AllocationLimitError` are this package's own +contract and lifecycle failures, and every one of them is deterministic on re-send: a closed resource +(`IO-42`) and a source that returned zero bytes for a positive read (`IO-17`) are caller programming errors, +an allocation cap (`IO-9`) is a limit the same request hits again, and `EndOfStreamError` is the +exact-length-copy contract inside `io/` — a *wire* truncation is the transport's to report, as a +`TransportFailureError`, which is the layer that can tell one from a complete short body. `RETRY-2`'s "an +I/O error" is read as that boundary. Widening the branch to `isIoError` would retry all four; only +`EndOfStreamError` was ever a candidate, and `io/` is the wrong layer to decide whether a stream ended early +because the wire broke. Six cases in `packages/core/src/retry/classify.test.ts` pin one answer per class, +each asserting both what `isIoError` says and what the classifier says, so the disagreement is recorded as a +decision rather than an oversight — and re-parenting any leaf under `IoError` turns four of them red. + +> **Anchor correction 2026-09-04 (audit #67 / #68), rule supplied 2026-09-05 (audit #67 / #78).** The line +> numbers in this section were stale on 2026-09-04 and were re-derived then; `classify.ts`'s branch moved +> from `:73` to `:90` on 2026-09-05 when the paragraph above it was written. The substantive point this +> section used to make — "the cause-walk returns retryable for any `IoError`" — read as covering all five +> classes `isIoError` names, and it never did. #68 recorded the gap and left the answer to #78; #78 chose to +> keep `instanceof IoError` and to say what it means, which is the two paragraphs above. Nothing in the +> deviation itself (the three-level branch, and why it stays) ever turned on that answer. ## Deviations recorded outside a phase diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index e099471..1358a5a 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -13,10 +13,20 @@ // U9 pass promoted `EndOfStreamError` — it was the subject of four `@throws` tags on public symbols // with no class a caller could catch. `1f48926` finished the set: `isIoError`, // `AllocationLimitError`, `ClosedResourceError` and `SourceContractViolationError` are exported as -// well, so every error symbol re-exported below is also on `packages/core/src/index.ts:39-47` and in +// well, so every error symbol re-exported below is also on `packages/core/src/index.ts:39-48` and in // `packages/core/etc/core.api.md`. Nothing in this file's error block is internal any more // (docs/work/mvp/2026-09-04-open-items-dissolution.md H8, whose remaining sub-item was the category // catch `isIoError` now provides). +// +// "Load-bearing on it" is narrower than it sounds, and the difference is a decision rather than an +// accident. The cause-walk at `../retry/classify.ts:90` tests `instanceof IoError`, so it matches +// `IoError` and `TransportFailureError` — and NOT the four leaves below, which extend `DexpaceError` +// directly and are grouped only by `isIoError`. That branch means "the wire failed": a send that +// produced no response is retryable (RETRY-4, TRANSPORT-20), while a violated source contract, a +// closed resource, an allocation cap and a short exact-length copy are this package's own failures +// and repeat identically on the next attempt. Audit #67 / #78 decided it; `docs/deviations.md` +// item 17 carries the rationale and `../retry/classify.test.ts` pins one answer per class. Do not +// re-parent a leaf under `IoError` to tidy the tree — that silently makes it retryable. export {BufferedSink} from './buffered-sink.js'; export {BufferedSource} from './buffered-source.js'; export {ByteQueue, copyBytes} from './byte-queue.js'; From 6655f9de41d6eb32bf96cd335e71c0928cae4722 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 11:55:46 +0300 Subject: [PATCH 5/6] test(retry): keep the new retry cases inside the lint caps `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 --- packages/core/src/retry/backoff.test.ts | 45 ++-- packages/core/src/retry/engine.test.ts | 313 ++++++++++++------------ 2 files changed, 184 insertions(+), 174 deletions(-) diff --git a/packages/core/src/retry/backoff.test.ts b/packages/core/src/retry/backoff.test.ts index 0f309ae..ed872eb 100644 --- a/packages/core/src/retry/backoff.test.ts +++ b/packages/core/src/retry/backoff.test.ts @@ -2,7 +2,8 @@ // packages/core/src/retry/backoff.test.ts // Exercises: RETRY-9 (initialDelay * multiplier^(attempt-1), 1-indexed, capped), RETRY-10 (symmetric // jitter bounds, midpoint, j=0 identity, negative floors to zero), RETRY-11 (attempt < 1 rejected, -// overflow saturates), RETRY-43 (fixed delay disables backoff AND jitter). +// overflow saturates -- INCLUDING at a zero initial delay, where `0 * Infinity` used to give NaN; +// audit #67 / #78), RETRY-43 (fixed delay disables backoff AND jitter). import {describe, expect, test} from 'bun:test'; import fc from 'fast-check'; import {computeDelay, type BackoffSettings} from './backoff.js'; @@ -39,13 +40,19 @@ describe('exponential schedule', () => { expect(() => computeDelay(0, SETTINGS, never)).toThrow(); expect(() => computeDelay(-1, SETTINGS, never)).toThrow(); }); +}); - test('a zero initial delay stays zero where the power overflows (RETRY-11)', () => { - // `0 * Infinity` is NaN, and NaN is the one value the saturation above cannot absorb: `Math.min` - // propagates it, `overshootsBudget` reads false for it, the engine's `delayMs <= 0` guard reads - // false for it, and it lands in `Clock.sleep` as a RangeError that replaces the real failure. - // `retrySettings` accepts both of these (`initialDelayMs >= 0`, finite `multiplier >= 1`), so - // "overflow-safe, saturating rather than throwing" has to hold for them too. +/** + * RETRY-11's "saturating rather than throwing" has one hole, and it is the zero base. Every other + * accepted setting overflows into `Math.min`'s cap; `0 * Infinity` overflows into `NaN`, which + * `Math.min` propagates. Audit #67 / #78. + */ +describe('a zero initial delay (RETRY-11)', () => { + test('stays zero where the power overflows', () => { + // Downstream, NaN is worse than a large number: `overshootsBudget` reads false for it, the + // engine's `delayMs <= 0` guard reads false for it, and it lands in `Clock.sleep` as a + // RangeError that replaces the failure being retried. `retrySettings()` accepts both settings + // below (`initialDelayMs >= 0`, finite `multiplier >= 1`), so RETRY-11 covers them. const hugeMultiplier: BackoffSettings = { ...SETTINGS, initialDelayMs: 0, @@ -58,7 +65,7 @@ describe('exponential schedule', () => { expect(computeDelay(1100, manyAttempts, never)).toBe(0); }); - test('a zero initial delay stays zero under jitter too (RETRY-10/11)', () => { + test('stays zero under jitter too (RETRY-10)', () => { const jitteredZero: BackoffSettings = { ...SETTINGS, initialDelayMs: 0, @@ -74,18 +81,22 @@ describe('exponential schedule', () => { // configuration a caller can build reaches the engine as a non-finite delay. `initialDelayMs` // is drawn through an explicit `constant(0)` arm: the failing region needs a zero base AND an // overflowing power together, and 100 runs of an unbiased double never produced the pair. + const accepted = fc.record({ + initialDelayMs: fc.oneof( + fc.constant(0), + fc.double({min: 0, max: 1e9, noNaN: true}), + ), + multiplier: fc.double({min: 1, max: 1e300, noNaN: true}), + maxDelayMs: fc.double({min: 0, max: 1e9, noNaN: true}), + jitter: fc.double({min: 0, max: 1, noNaN: true}), + }); + fc.assert( fc.property( fc.integer({min: 1, max: 5000}), - fc.oneof(fc.constant(0), fc.double({min: 0, max: 1e9, noNaN: true})), - fc.double({min: 1, max: 1e300, noNaN: true}), - fc.double({min: 0, max: 1, noNaN: true}), - (attempt, initialDelayMs, multiplier, jitter) => { - const delay = computeDelay( - attempt, - {initialDelayMs, multiplier, maxDelayMs: 8000, jitter}, - never, - ); + accepted, + (attempt, settings) => { + const delay = computeDelay(attempt, settings, never); expect(Number.isFinite(delay)).toBe(true); expect(delay).toBeGreaterThanOrEqual(0); }, diff --git a/packages/core/src/retry/engine.test.ts b/packages/core/src/retry/engine.test.ts index 542f1b4..b1fdd20 100644 --- a/packages/core/src/retry/engine.test.ts +++ b/packages/core/src/retry/engine.test.ts @@ -97,6 +97,66 @@ function scriptedDispatch( return Object.assign(dispatch, {calls}); } +/** A clock that records every duration it is asked to sleep for, and returns immediately. */ +function recordingClock(slept: number[]): Clock { + return { + now: () => 0, + monotonic: () => 0, + sleep: durationMs => { + slept.push(durationMs); + return Promise.resolve(); + }, + }; +} + +/** + * `defaultClock`'s own precondition, modelled: `Clock.sleep` rejects a non-finite duration with a + * `RangeError` (`config/clock.ts:148-157`). A fake that slept for any duration at all would hide the + * bug; this guard is what surfaces it. + */ +function guardingClock(): Clock { + return { + now: () => 0, + monotonic: () => 0, + sleep: durationMs => + Number.isFinite(durationMs) + ? Promise.resolve() + : Promise.reject( + new RangeError( + `Clock.sleep: durationMs must be a non-negative finite number, got ${String(durationMs)}`, + ), + ), + }; +} + +/** One retryable failure, then a 200: the shortest script that drives exactly one delay decision. */ +function oneFailureThenSuccess(): RetryDispatch { + return scriptedDispatch([ + failure(new IoError('first')), + success(countingResponse(200).response), + ]); +} + +/** Installs a capturing global logger for the duration of `body` and returns what it emitted. */ +async function captureLogEvents( + body: () => Promise, +): Promise[]> { + const {createLogger, setGlobalLogger, NOOP_LOGGER} = + await import('../observability/logger.js'); + const events: Map[] = []; + setGlobalLogger( + createLogger((_level, fields) => { + events.push(new Map(fields)); + }), + ); + try { + await body(); + } finally { + setGlobalLogger(NOOP_LOGGER); + } + return events; +} + describe('eligibility (RETRY-7/8)', () => { test('a non-retryable failure is surfaced after exactly one attempt', async () => { const dispatch = scriptedDispatch([failure(new TypeError('bad'))]); @@ -231,41 +291,61 @@ describe('delay resolution (RETRY-39/40)', () => { expect(dispatch.calls).toHaveLength(2); }); - test('a non-finite override falls back to the schedule, like a throwing one (RETRY-40)', async () => { - // RETRY-40 makes a bad override non-fatal. A throw was handled; a non-finite RETURN was not, and - // it is the worse of the two, because `NaN` fails every comparison downstream instead of failing - // loudly here. Audit #67 / #78 reads the two as one case: drop the value, use the computed - // schedule, keep going. Asserted on the delays the clock was ASKED for -- three sends alone would - // also pass on a clock that quietly slept for `NaN`. + test('a finite override is honored unchanged, fractional and huge alike (RETRY-39)', async () => { + // The finiteness guard below screens `NaN` and the two infinities and nothing else. A fractional + // or very large delay is still a delay, and RETRY-39 gives the caller precedence over the + // schedule -- 5000 ms of `fixedDelayMs` here, which neither run waits. + const slept: number[] = []; + const clock = recordingClock(slept); + + for (const override of [0.5, Number.MAX_SAFE_INTEGER]) { + await runWithRetry( + GET, + scriptedDispatch([failure(new TransportFailureError('reset'))]), + { + settings: retrySettings({maxAttempts: 2, fixedDelayMs: 5000}), + clock, + random: () => 0.5, + delayOverride: () => override, + }, + ); + } + + expect(slept).toEqual([0.5, Number.MAX_SAFE_INTEGER]); + }); +}); + +/** + * RETRY-40 makes a bad override non-fatal. A throw was handled; a non-finite RETURN was not, and it + * is the worse of the two, because `NaN` fails every comparison downstream instead of failing loudly + * at the override. Audit #67 / #78 reads the two as one case: drop the value, use the computed + * schedule, keep going. + */ +describe('a non-finite delayOverride is the throwing case (RETRY-40)', () => { + test('every non-finite value falls back to the computed schedule', async () => { + // Asserted on the delays the clock was ASKED for. Three sends alone would also pass against a + // fake that quietly slept for `NaN`, which is most of them. for (const bad of [ Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, ]) { const slept: number[] = []; - const config: RetryConfig = { + const dispatch = scriptedDispatch([ + failure(new TransportFailureError('connection refused')), + ]); + + const outcome = await runWithRetry(GET, dispatch, { settings: retrySettings({ maxAttempts: 3, initialDelayMs: 200, multiplier: 2, jitter: 0, }), - clock: { - now: () => 0, - monotonic: () => 0, - sleep: durationMs => { - slept.push(durationMs); - return Promise.resolve(); - }, - }, + clock: recordingClock(slept), random: () => 0.5, delayOverride: () => bad, - }; - const dispatch = scriptedDispatch([ - failure(new TransportFailureError('connection refused')), - ]); - - const outcome = await runWithRetry(GET, dispatch, config); + }); expect(dispatch.calls).toHaveLength(3); expect(slept).toEqual([200, 400]); @@ -273,35 +353,23 @@ describe('delay resolution (RETRY-39/40)', () => { } }); - test('a non-finite override never reaches Clock.sleep as a duration (RETRY-40)', async () => { - // The reported symptom, with a clock that guards its input the way `defaultClock` does: the - // rejection was folded into the terminal failure by RETRY-33's catch-all, so `delayOverride: - // () => NaN` with `maxAttempts: 3` gave ONE send and surfaced a `RangeError` about `durationMs` - // in place of the transport failure being retried -- with the real error demoted to the trail. - const config: RetryConfig = { - settings: retrySettings({maxAttempts: 3, fixedDelayMs: 0}), - clock: { - now: () => 0, - monotonic: () => 0, - sleep: durationMs => - Number.isFinite(durationMs) - ? Promise.resolve() - : Promise.reject( - new RangeError( - `Clock.sleep: durationMs must be a non-negative finite number, got ${String(durationMs)}`, - ), - ), - }, - random: () => 0.5, - delayOverride: () => Number.NaN, - }; + test('it never reaches Clock.sleep as a duration, so the real failure survives', async () => { + // The reported symptom, against a clock that guards its input the way `defaultClock` does: the + // rejection was folded into the terminal failure by RETRY-33's catch-all, so `() => NaN` with + // `maxAttempts: 3` gave ONE send and surfaced a `RangeError` about `durationMs` in place of the + // transport failure being retried -- with the real error demoted to the trail. const dispatch = scriptedDispatch([ failure(new TransportFailureError('first')), failure(new TransportFailureError('second')), failure(new TransportFailureError('third')), ]); - const outcome = await runWithRetry(GET, dispatch, config); + const outcome = await runWithRetry(GET, dispatch, { + settings: retrySettings({maxAttempts: 3, fixedDelayMs: 0}), + clock: guardingClock(), + random: () => 0.5, + delayOverride: () => Number.NaN, + }); expect(dispatch.calls).toHaveLength(3); expect(outcome.kind).toBe('failure'); @@ -310,35 +378,6 @@ describe('delay resolution (RETRY-39/40)', () => { expect((outcome.error as Error).message).toBe('third'); expect(retryAttempts(outcome.error)).toHaveLength(2); }); - - test('a finite override is honored unchanged, fractional and huge alike (RETRY-39)', async () => { - // The finiteness guard screens `NaN` and the two infinities and nothing else. A fractional or - // very large delay is still a delay, and RETRY-39 gives the caller precedence over the schedule. - const slept: number[] = []; - const clock: Clock = { - now: () => 0, - monotonic: () => 0, - sleep: durationMs => { - slept.push(durationMs); - return Promise.resolve(); - }, - }; - - for (const override of [0.5, Number.MAX_SAFE_INTEGER]) { - await runWithRetry( - GET, - scriptedDispatch([failure(new TransportFailureError('reset'))]), - { - settings: retrySettings({maxAttempts: 2, fixedDelayMs: 5000}), - clock, - random: () => 0.5, - delayOverride: () => override, - }, - ); - } - - expect(slept).toEqual([0.5, Number.MAX_SAFE_INTEGER]); - }); }); describe('server pacing hints (RETRY-20/22)', () => { @@ -1102,106 +1141,66 @@ describe('per-call state (RETRY-42, RECOV-28)', () => { describe('Phase 7b retrofit: structured retry logging', () => { test('emits attemptFailed per retry and exhausted when attempts run out', async () => { - const {createLogger, setGlobalLogger, NOOP_LOGGER} = - await import('../observability/logger.js'); - const events: Map[] = []; - const testLogger = createLogger((_level, fields) => { - events.push(new Map(fields)); + const events = await captureLogEvents(async () => { + await runWithRetry( + GET, + scriptedDispatch([ + failure(new IoError('first')), + failure(new IoError('second')), + failure(new IoError('third')), + ]), + configOf({maxAttempts: 3, fixedDelayMs: 0}), + ); }); - setGlobalLogger(testLogger); - - try { - const config = configOf({maxAttempts: 3, fixedDelayMs: 0}); - const dispatch = scriptedDispatch([ - failure(new IoError('first')), - failure(new IoError('second')), - failure(new IoError('third')), - ]); - await runWithRetry(GET, dispatch, config); - - const failedEvents = events.filter( - e => e.get('event') === 'http.retry.attemptFailed', - ); - expect(failedEvents).toHaveLength(2); - expect(failedEvents[0]?.get('attempt')).toBe(1); - expect(failedEvents[1]?.get('attempt')).toBe(2); + const failedEvents = events.filter( + e => e.get('event') === 'http.retry.attemptFailed', + ); + expect(failedEvents).toHaveLength(2); + expect(failedEvents[0]?.get('attempt')).toBe(1); + expect(failedEvents[1]?.get('attempt')).toBe(2); - const exhaustedEvents = events.filter( - e => e.get('event') === 'http.retry.exhausted', - ); - expect(exhaustedEvents).toHaveLength(1); - expect(exhaustedEvents[0]?.get('attempts')).toBe(3); - } finally { - setGlobalLogger(NOOP_LOGGER); - } + const exhaustedEvents = events.filter( + e => e.get('event') === 'http.retry.exhausted', + ); + expect(exhaustedEvents).toHaveLength(1); + expect(exhaustedEvents[0]?.get('attempts')).toBe(3); }); test('emits delayOverrideFailed when delayOverride throws', async () => { - const {createLogger, setGlobalLogger, NOOP_LOGGER} = - await import('../observability/logger.js'); - const events: Map[] = []; - const testLogger = createLogger((_level, fields) => { - events.push(new Map(fields)); - }); - setGlobalLogger(testLogger); - - try { - const config: RetryConfig = { + const events = await captureLogEvents(async () => { + await runWithRetry(GET, oneFailureThenSuccess(), { ...configOf({maxAttempts: 2, fixedDelayMs: 0}), delayOverride: () => { throw new Error('bad override'); }, - }; - const dispatch = scriptedDispatch([ - failure(new IoError('first')), - success(countingResponse(200).response), - ]); - - await runWithRetry(GET, dispatch, config); + }); + }); - const overrideFailed = events.filter( - e => e.get('event') === 'http.retry.delayOverrideFailed', - ); - expect(overrideFailed).toHaveLength(1); - } finally { - setGlobalLogger(NOOP_LOGGER); - } + const overrideFailed = events.filter( + e => e.get('event') === 'http.retry.delayOverrideFailed', + ); + expect(overrideFailed).toHaveLength(1); + expect(overrideFailed[0]?.get('cause')).toBe('Error: bad override'); }); test('emits delayOverrideFailed when delayOverride returns a non-finite delay', async () => { - // "Treated exactly like one that throws" (RETRY-40) is a claim about the diagnostic too: a - // silently-ignored override is a schedule the operator cannot explain. Same event, same level, - // same emit path -- only the cause differs, and it names the value that was rejected. - const {createLogger, setGlobalLogger, NOOP_LOGGER} = - await import('../observability/logger.js'); - const events: Map[] = []; - const testLogger = createLogger((_level, fields) => { - events.push(new Map(fields)); - }); - setGlobalLogger(testLogger); - - try { - const config: RetryConfig = { + // "Treated exactly like one that throws" (RETRY-40) is a claim about the diagnostic too: an + // override dropped in silence is a schedule the operator cannot explain from the configuration. + // Same event, same level, same emit path -- only the cause differs, and it names the value. + const events = await captureLogEvents(async () => { + await runWithRetry(GET, oneFailureThenSuccess(), { ...configOf({maxAttempts: 2, fixedDelayMs: 0}), delayOverride: () => Number.NaN, - }; - const dispatch = scriptedDispatch([ - failure(new IoError('first')), - success(countingResponse(200).response), - ]); - - await runWithRetry(GET, dispatch, config); + }); + }); - const overrideFailed = events.filter( - e => e.get('event') === 'http.retry.delayOverrideFailed', - ); - expect(overrideFailed).toHaveLength(1); - expect(overrideFailed[0]?.get('cause')).toBe( - 'delayOverride returned a non-finite delay: NaN', - ); - } finally { - setGlobalLogger(NOOP_LOGGER); - } + const overrideFailed = events.filter( + e => e.get('event') === 'http.retry.delayOverrideFailed', + ); + expect(overrideFailed).toHaveLength(1); + expect(overrideFailed[0]?.get('cause')).toBe( + 'delayOverride returned a non-finite delay: NaN', + ); }); }); From 0bcc6f2646eeeb06238029e311035b21c1ad8d5f Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 5 Sep 2026 12:02:30 +0300 Subject: [PATCH 6/6] docs(retry): RetryStepOptions.delayOverride states the failure semantics `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 --- packages/core/src/retry/retry-step.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/retry/retry-step.ts b/packages/core/src/retry/retry-step.ts index 9a58392..2680959 100644 --- a/packages/core/src/retry/retry-step.ts +++ b/packages/core/src/retry/retry-step.ts @@ -44,6 +44,11 @@ export interface RetryStepOptions { * RETRY-39's caller override: returns the delay in milliseconds to use for `attempt`, or * `undefined` to fall through to the configured schedule for that attempt. * + * A throw, or a non-finite result, is ignored: the configured schedule is used for that attempt + * and `http.retry.delayOverrideFailed` is logged at warning level (RETRY-40). Neither aborts the + * retry loop. A finite negative is honored as a delay and continues inline without a timer + * (RETRY-31). + * * @defaultValue absent, so every attempt uses the configured schedule */ readonly delayOverride?: