Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/2026-08-26-max-retries-range-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@dexpace/core": patch
---

Tighten `RequestOptionsBuilder.maxRetries` validation: a defined value must now be a non-negative
integer. `Infinity`, `NaN`, and fractional values were previously accepted and now throw
`RequestOptionsValidationError`, the same way a negative value already did.

A retry ceiling is a count of wire sends, so a non-finite one is as out of range as a negative one —
and worse in effect: a negative value still fails a downstream `>= 1` guard, while `Infinity` or
`NaN` makes a retry driver's `attempt >= ceiling` test permanently false and its loop unbounded.
HTTP-35's requirement is that an out-of-range retry count is a loud error at the call site that
supplied it, never a value reinterpreted somewhere downstream; this closes the half of that
requirement the setter did not implement.
118 changes: 118 additions & 0 deletions .changeset/2026-08-26-retry-pillar-and-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
'@dexpace/core': patch
---

Add the retry pillar for product-spec §9 (`RETRY-1`–`RETRY-45`) and appendix C's `RECOV-17`–`RECOV-34`, plus
the Phase 7a `config/` prerequisite slice and the shared `FakeTransport`. No public API change.

Everything this adds lives under `packages/core/src/{retry,config,testing}/` and none of it is re-exported
from `src/index.ts` — `packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than
an empty changeset because files under `packages/` did change: the published tarball carries the new
`dist/retry/*.js`, `dist/config/*.js`, and `dist/testing/*.js`, and a consumer stepping through the package in
a debugger will see them. (The one behavior change a caller can observe from outside — tightening
`RequestOptionsBuilder.maxRetries` to a non-negative integer — ships under its own changeset.)

Public-barrel promotion of `retryStep` and the step-authoring surface is deliberately **not** in this release.
A caller cannot assemble a working pipeline until the standard-resilience preset exists, and publishing
`retryStep` alone would freeze `StepDescriptor`/`Stage`/`PipelineBuilder` shapes that still had latitude to
move. Phase 5c owns that promotion.

## What landed

`packages/core/src/retry/`, eight files, no folder barrel:

- **`classify.ts`** — the two orthogonal axes (`RETRY-1`–`RETRY-8`, `RETRY-37`). Retryability is an
ALLOW-list over an iterative, identity-tracking cause walk; `isResendable` is the second axis over
`Body.replayable` and Phase 1's `isIdempotent`.
- **`backoff.ts`, `pacing.ts`** — the pure math and the server-hint parser, split away from the imperative
loop.
- **`settings.ts`** — `RETRY-12`'s defaults, `RECOV-34`'s construction validation, and `totalTimeoutMs` as an
opt-in.
- **`engine.ts`** — the one attempt loop both adapters reach.
- **`attempt-stamp.ts`, `retry-step.ts`, `retry-dispatch.ts`** — per-attempt stamping and the two thin
adapters: the `RETRY` pillar step and the recovery-chain wrapper.

Plus `recovery/idempotency-key.ts` (`RECOV-32`) and `testing/fake-transport.ts`, which closes the roadmap's
twice-punted `FakeTransport` deferral.

Two files outside those folders changed, both additively. `StepContext` gains `signal` and `options`
(`PIPE-13`/`PIPE-17`): `Cursor` already carried both and threaded them into terminal dispatch, but no step
could read either, so `RETRY-26`'s cancellable wait and `RETRY-32` were unimplementable and `PIPE-17`'s
"readable by any step" MUST was unsatisfied outright — which is also the wire `RETRY-41`'s per-call
`maxRetries` override (`HTTP-35`) had been missing since Phase 1 designed the knob.

## Executed out of numeric order: the Phase 7a prerequisite slice

`config/clock.ts` (`CFG-15`–`CFG-17`), `config/http-date.ts` (`CFG-29`–`CFG-31`), and `config/retryable.ts`
(`CFG-35`) are built here, verbatim from Phase 7a's plan Tasks 1–3, because 5a's Global Constraints ban
shipping the private copies that would otherwise be needed: Task 8 consumes the `Clock` seam, Task 4 imports
the shared RFC 1123 parser, and Task 2 re-exports the shared retryable-status set instead of defining it a
second time. Phase 7a's Tasks 4–10 are untouched, and none of the three enters the public barrel — 7a's Task
10 still owns that decision.

## Design calls worth recording

- **One retry loop, reached by both adapters.** `RETRY-13`/`RETRY-14` and `RECOV-30` require the pillar stack
and the recovery-chain stack not to drift. `runWithRetry` is the single choke point both call, so the
schedule, the classifier, and the budget cannot diverge — structural, not a discipline. Every piece of
per-call state is a local (`RETRY-42`/`RECOV-28`), so concurrent invocations sharing one config cannot
clobber each other's attempt count or start instant.
- **`RETRY-25`'s fatal-error exclusion needs no code.** Because classification is an allow-list, a
stack-overflow `RangeError` is non-retryable for never having been opted in, not for having been screened
out. A caller `AbortError` is likewise non-retryable for free (`RETRY-23`), while `TimeoutError` is
explicitly listed (`RETRY-24`) — keying off the abort reason's `name` draws that line more precisely than
the class hierarchy the reference describes.
- **The pacing parser is total, and a failure never maps to `0`.** `RETRY-16` makes never-throwing the
defining property; every malformed, negative, or out-of-range value maps to `null` ("no hint", fall back to
backoff). `0` is reserved for a validly-parsed instant already in the past (`RETRY-17`) — mapping a
malformed header to `0` would hammer a server that just asked for room. `X-RateLimit-Reset` receives
`RECOV-25`'s positive [100%, 120%] jitter so a fleet released at one reset instant does not stampede; a
literal `Retry-After` receives none (`RETRY-20`).
- **`RETRY-36`'s remap applies only to responses the engine DISCARDS.** A response surviving the gates is
returned live and unread: `toHttpError()` drains the body and drops the headers irreversibly, and 4c's
pillar signature must return a `Response`. This is also why the pacing hint is read BEFORE the retire step
— that ordering is load-bearing, not stylistic.
- **`RETRY-27`'s budget clause is implemented as three separate checks, deliberately.** A delay that would
push cumulative elapsed time past the budget SUPPRESSES the retry and surfaces the last failure; the
`Math.min` clamp beside it is the requirement's separately-listed belt-and-braces clause and narrows
nothing except across clock drift between two `elapsed()` reads. It ships because the requirement lists it
separately, not because a test can drive it.
- **A non-finite retry ceiling is guarded at three layers.** Unlike a negative value, which still fails a
downstream `>= 1` guard, `Infinity` or `NaN` makes `attempt >= ceiling` permanently false and the loop
unbounded. The setter, the step's per-call derivation, and a `runWithRetry` precondition each reject it —
the precondition being the one choke point both adapters pass through.
- **`RETRY-41`'s "clamp a negative retry count to the default" is implemented as a REJECTION.** It collides
head-on with `HTTP-35`, also a MUST, which rejects precisely so the value cannot be silently reinterpreted
downstream. The port takes `HTTP-35`'s line on both surfaces; recorded in the design's Deviation Ledger.
- **The inter-attempt wait delegates to `Clock.sleep`.** `CFG-17` already races the timer against the signal,
clears it on both exits (`RETRY-45`'s scheduler hygiene, which has no scheduler object to own in this
port), and rejects promptly for a signal that aborted earlier. Hand-rolling a second `setTimeout`-plus-
listener would put the wait outside the injected seam and force real timers into a suite that must stay
deterministic. Cancellation RESOLVES rather than propagates, so the loop's next iteration observes the
signal and stops through its own `RETRY-32` path.
- **`RETRY-33`'s "every terminal path returns an Outcome" is honored literally.** An attempt that throws is
folded into a failure outcome carrying the trail rather than left to surface as a bare rejected promise,
which would drop `RETRY-34`'s suppressed attempts on the floor. The trail folds through Phase 4b's
`suppress()` helper, not `new SuppressedError(...)`: the native class reached Node only in 24.0.0 and this
package's floor is `>=20.3`. Argument order is controlled explicitly — native `using` disposal builds the
pair the other way round, making the LATER error primary.
- **`RETRY-30`'s trampoline requirement is satisfied by the language.** An `await` loop is already iterative,
so N retries build no continuation chain and no stack growth.
- **`PIPE-36` is satisfied structurally.** `retryStep()` is a factory returning a descriptor with
`stage: 'RETRY'` baked in — no class to subclass, no way for a caller to relocate a shipped pillar family
out of its pillar. 4c deferred this to "whichever future phase ships the first real pillar step family";
this is that phase.
- **`countingResponse()` counts release by BOTH routes it can happen** — `cancel()` for an abandoned
response, `pull()`-to-EOF for one `toHttpError()` drained. A helper counting `cancel()` alone reads zero on
exactly the `RETRY-35` path it exists to prove.

## Known gaps, each recorded rather than left silent

- **`RETRY-29`** (opt-in server-driven retry-classification override) is a `MAY` and is unscheduled: it
widens the classifier's input surface to server-controlled values and wants an explicit trust decision, not
a default.
- **`RECOV-33`** (client-identity header step) belongs with the `CFG-*` work and is Phase 7a's Task 9.
- **`RETRY-40`'s "log the failure" clause and the two SHOULD-level structured events** (`retry.attemptFailed`,
`retry.exhausted`) are not implemented here. 5a executes before 7b, so an `observability/logger.js` import
would not resolve; 7b in turn needs this phase's `FakeTransport`, so the cycle only breaks in this
direction. Phase 7b's Task 9 owns them, named in `engine.ts`'s retrofit note.
75 changes: 75 additions & 0 deletions .changeset/2026-08-27-redirect-pillar-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
'@dexpace/core': patch
---

Add the redirect-following pillar step for product-spec §10 (`REDIR-1`–`REDIR-27`) and close `PIPE-40`. No
public API change.

Everything this adds lives under `packages/core/src/redirect/` and none of it is re-exported from
`src/index.ts` — `packages/core/etc/core.api.md` is byte-identical before and after. `patch` rather than an
empty changeset because files under `packages/` did change: the published tarball carries the new
`dist/redirect/*.js`, and a consumer stepping through the package in a debugger will see them.

One file landed outside `redirect/`: `packages/core/src/recovery/release.ts`, which is
`releaseQuietly`/`withReleaseFailure` extracted unchanged from `retry/engine.ts`. The redirect step needs
the same "a teardown failure never becomes primary" discipline `RECOV-12` already required of retry, and
the helper's identity guard is subtle enough that a second copy would drift. `engine.ts` now imports what
it used to define; its behavior and its suite are unchanged.

What landed: `codes.ts` (the recognized `{301,302,303,307,308}` set and per-code method eligibility),
`cross-origin.ts` (the RFC 6454 origin tuple compared against the seed, plus the credential-suppression
marker header), `settings.ts` (validated, frozen policy with a defensively copied allowed-method set),
`decide.ts` (the pure per-hop decision), `redirect-step.ts` (the `REDIRECT` pillar adapter), and
`strip-marker-step.ts` (a `POST_AUTH` guard plus `withRedirect()`). Two new operational error leaves,
`NonReplayableBodyError` and `SchemeDowngradeError`, both `@internal` for now.

Four design calls worth recording:

- **The cross-origin suppression signal is a real header, not an in-process marker.** A `WeakSet<Request>`
keyed by object identity is unforgeable and never touches the wire, but stage order is
`REDIRECT → RETRY → AUTH` and 5a's attempt-stamping builds a fresh per-attempt `Request` copy when
enabled — an identity-keyed signal would silently stop matching exactly when a retry sits between
redirect and auth, which is when cross-origin credential suppression matters most. Stamping preserves
headers, so a header survives the intermediate copy.
- **A second, always-bundled step strips that marker independently of whether an auth step exists.**
`REDIR-11` itself names the porter caveat: in the reference only the auth step strips the signal, so a
pipeline with none forwards it to the transport. 5b ships before 5c, so that is not a future concern
here — it is a live leak this phase would otherwise ship. `stripCrossOriginMarkerStep()` occupies 4c's
inert `POST_AUTH` extension slot, so nothing in 4c or 5c had to change.
- **Two origin-shaped checks, two deliberately different reference points.** Cross-origin classification
compares against the **seed** origin for the whole chain (`REDIR-8`), so a foreign host cannot hand the
credential back by redirecting to the seed's own origin. The scheme-downgrade guard compares the
**current hop** against its target (`REDIR-15`), so an HTTPS→HTTP→HTTPS chain flags only the hop that
actually downgraded. Conflating them silently breaks one or the other.
- **A failing release never replaces the error it was supposed to let through.** `Response.close()`
rethrows whatever cancelling the body raised, so the two error paths that close before propagating
(`decideOrClose`, and the `'fail'` branch's `SchemeDowngradeError`) route through
`withReleaseFailure`: the decision error stays primary and the release failure rides along as
`suppressed`. The third close — releasing a superseded hop before the next drive — is deliberately
left bare, because there is no primary error to preserve and `PIPE-40` makes the release itself part
of the contract.
- **Location resolution ends with an explicit `http:`/`https:` gate.** WHATWG `URL` parses
`javascript:`, `data:`, `file:`, and `mailto:` without complaint, and the downgrade guard waves all of
them through (none is `http:`). Without the gate the step would dispatch a server-supplied
`javascript:` target. The `catch` around `new URL(raw, base)` is a genuinely narrow path, not the
general garbage guard it looks like: with a base supplied, a non-URL string resolves as a relative
reference rather than throwing.

One normative conflict, resolved and recorded rather than silently picked: **`PIPE-40` and `REDIR-22`
disagree, both at `MUST`, about the non-replayable-body path.** `PIPE-40` lists it among the paths whose
in-flight response is "returned unclosed"; `REDIR-22`(b) lists the same trigger among those "closed before
the error propagates". `REDIR-6` settles the control flow — that path "MUST fail with a clear error" — so it
throws, and a response never returned cannot be returned unclosed. 5b closes and throws; the contradiction
is in the design's Deviation Ledger and deferred to Phase 10, which owns the erratum either way.

Two known gaps, both recorded in the phase checklist:

- **`REDIR-28`'s structured hop/loop/downgrade log events, and `REDIR-15`'s "surface it observably" clause
on a permitted downgrade, are not implemented here.** Phase 5b executes before Phase 7b, so
`redirect-step.ts` cannot import `observability/`, and 7b needs this step for its own retrofit test —
the dependency cannot run the other way. Phase 7b's Task 9 owns them, named in `redirectStep()`'s TSDoc.
- **`REDIR-20`'s predicate override is read as scoped to code/method eligibility only.** A configured
predicate replaces the built-in follow decision; it does not bypass userinfo stripping, credential
hygiene, the downgrade guard, the replayability gate, or loop/cap detection, all of which the same spec
document states as unconditional `MUST`s. Logged in the design's Deviation Ledger for Phase 10 and
flagged for re-confirmation at Phase 9's conformance sweep.
Loading
Loading