One idempotency key per logical request across retry attempts (#73) - #90
Merged
Wahbeh-Mohammad merged 2 commits intoSep 5, 2026
Merged
Conversation
`dispatchWithRetry` re-ran the ENTIRE recovery chain per attempt, request chain included, so `idempotencyKeyStep`'s `generate()` fired once per attempt and three attempts of one logical request reached the server under three different keys — defeating exactly what the header is bought for (RECOV-32) and contradicting the `@public` TSDoc that has claimed "runs ONCE per call, upstream of retry" since 1f48926. Split `dispatchWithRecovery` into its two halves and let the retry adapter compose them differently: - `prepareRequest(request, chain)` — the request chain, once per LOGICAL request, with RECOV-2's throw-to-Failure conversion already applied. - `dispatchPrepared(prepared, config)` — the transport hop, the response chain and RECOV-10's unwrap, once per WIRE SEND. It takes an `Outcome<Request>` so a request-chain failure still gets its one trip through the response and recovery hooks with the transport untouched. `dispatchWithRecovery` is now the composition of the two and is behaviourally unchanged. `dispatchWithRetry` applies the chain once above `runWithRetry` and each attempt repeats only `dispatchPrepared` over `stampAttempt`'s fresh copy, so RETRY-38's ordinal varies while the key does not. RETRY-44 is satisfied, not traded away: its "downstream chain" is what sits below the retry point — transport plus response chain — and its "upstream steps MUST NOT mutate the shared in-flight request between attempts" clause now holds by construction, because upstream steps no longer run between attempts at all. The test that asserted the old layering by name (`re-runs the request recovery chain on every attempt (RETRY-44)`) is renamed to what the code now proves. Five new/changed cases in `retry-dispatch.test.ts`, all red before this change: chain applied once; one `generate()` across three attempts with the same key on all three sends; the same on the exhausting run; the ordinal varying while the key holds; and a request-chain throw that never reaches the transport, is not retried, and passes the recovery phase exactly once. Refs #73, #67.
…st chain D0 puts a remediation-run deviation in `docs/deviations.md` under "Deviations recorded outside a phase", not in a `docs/work/` phase ledger. One row: what `RETRY-44`'s "downstream chain" means in the recovery stack, why both of its clauses hold under the new layering, the two rejected alternatives, and the re-send-gate consequence — with the four pinning tests named by line. Also re-anchors item 3's `retry-dispatch.ts:55` citation, which the previous commit shifted to `:85`. Same file, same reason waves 1-3 re-anchored theirs. Refs #73, #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 #73. Part of the audit remediation umbrella #67, milestone 3, wave 4. Base:
audit/remediation-67.What changed
dispatchWithRetryre-ran the entire recovery chain per attempt — request chain included — soidempotencyKeyStep'sgenerate()fired once per attempt and three attempts of one logical request reachedthe server under three different keys. That defeats the only thing the header is bought for (
RECOV-32) andcontradicts the
@publicTSDoc that has claimed "runs ONCE per call, upstream of retry" since1f48926.Per D13 the fix is layering (a), not memoization:
packages/core/src/recovery/orchestrator.ts—dispatchWithRecoveryis split into its two named halvesand is now their composition. Behaviour unchanged; it is still one wire send with one
try/catchperthrowing surface, and every throwable still becomes a
Failurebefore the response chain runs (RECOV-2).prepareRequest(request, chain)— the request chain, once per logical request, withRECOV-2'sthrow-to-
Failureconversion already applied. Never throws.dispatchPrepared(prepared, config)— the transport hop, the response chain andRECOV-10's unwrap, onceper wire send. It takes an
Outcome<Request>so a request-chain failure still gets its single tripthrough the response and recovery hooks with the transport never called.
@internaland stripped from the emitted.d.ts;core.api.mdis byte-identical.packages/core/src/retry/retry-dispatch.ts— applies the chain once aboverunWithRetry; each attemptrepeats only
dispatchPreparedoverstampAttempt's fresh copy of the one prepared request, soRETRY-38'sordinal varies while the key does not. A request-chain failure short-circuits the loop: it never reached the
wire, so
RETRY-5's re-send gate has nothing to judge and re-running the step that just threw would onlythrow again — it still passes the response and recovery chains exactly once.
dispatchWithRetry,dispatchWithRecovery,prepareRequest/dispatchPrepared,RequestRecoveryChain,idempotencyKeyStepandrunWithRetry's@param request.idempotencyKeyStep'sclaim is now true and qualified: the guarantee is a property of the composition, and a caller who
re-applies their own chain per attempt still gets a key per attempt, because on its own a step can only
promise
RECOV-32's letter ("at most once per applicable request").retryStepand the pillar stack are untouched: there "downstream" is the forked continuation (ctx.fork()),which is still re-driven per attempt.
runWithRetryitself is unchanged apart from a@paramdoc.Test rows added
All in
packages/core/src/retry/retry-dispatch.test.ts; the first four were red before the fix.applies the request recovery chain ONCE per logical request, not per attempt (RETRY-44)re-runs the request recovery chain on every attempt (RETRY-44), which asserted the bug by namegenerates ONE key for three attempts and sends it on all threegenerate()called once;Idempotency-Keyiskey-1on all three recorded sendsthe run that exhausts the budget sends the same key on every attempt toothe attempt ordinal varies per send while the key does not (RETRY-38)X-Attemptis1,2,3while the key stayskey-1does not retry it, never reaches the transport, and runs the recovery phase once (RECOV-2)sendCount0, one recovery-step application carryingfailure(boom), the throwable surfaces by identitya recovery step may still convert that failure into a successRECOV-5/RECOV-13still reachable on the pre-wire failure pathThe key strategy in these cases returns a distinct value per call and is counted. A constant-returning
strategy would pass every header assertion even under the old layering, which is the trap they exist to avoid.
No
tests/node-conformance/case: the change is control flow only. It touches no Web Streams,AbortSignalor
Uint8Arraysurface, which is what that tree exists for.Deviation rows added
One row appended at the end of
docs/deviations.md's "Deviations recorded outside a phase" table (D0):RETRY-44's "downstream chain" is read as everything BELOW the retry point, which in the recovery stackexcludes the request chain. Records why both of
RETRY-44's clauses hold under the new layering — thesecond one now by construction, since upstream steps no longer run between attempts — the two rejected
alternatives (
WeakMapmemoization on the template; re-running the chain over its own output), and theconsequence that the re-send gate now judges the prepared request. Ten
file:linecitations, all verifiedmechanically.
Also re-anchored item 3's
retry-dispatch.ts:55citation to:85, which this PR's own first commit shifted —the same maintenance waves 1–3 did on that file.
Shipped
RequestSteps, and whether any needs per-attempt re-executionD13 asked for the enumeration. Derived from
grep -rn "Promise<Request>" packages/*/src:RequestStepidempotencyKeyStep(recovery/idempotency-key.ts)That is the whole list. The steps the issue's "notes and leads" suspected — client identity, auth stamps — are
not
RequestSteps at all:clientIdentityStep,authStep,loggingStep,redirectStep,stripCrossOriginMarkerStepandretryStepall return aStepDescriptorand live in the pipeline stack,below which
retryStep'sctx.fork()already re-drives them per attempt asRETRY-44requires. Nothing inrecovery/other thanidempotencyKeyStepproduces aRequestStep, and no shipped step needs per-attemptre-execution.
Gate
node .claude/skills/ci-preflight/run-ci.mjs --clean, from a swept tree on the pinned Bun 1.3.14 — all 20steps passed (
install,verify:knowledge-structure,typecheck,lint,build,test,test:scripts,api,lint:publish,verify:dual-consumption,verify:consumer-types,verify:seam-1,verify:sse-37,verify:runtime-floor,verify:test-partition,test:examples,verify:import-cycles,verify:reproducible-build,audit,test:node). Run without--node-floor; the 20.3.0 leg was notexercised locally.
Deferred — release machinery
Suspended under D1; nothing below was done here.
@dexpace/core:dispatchWithRetrynow applies the request recovery chain once perlogical request instead of once per attempt, so an
idempotencyKeyStepin that chain emits one key acrossevery retry. Behaviour change on an
@internalpath —dispatchWithRetryis not exported, so no consumercan build this composition today — plus shipped
.d.tsprose fordispatchWithRecovery,RequestRecoveryChainandidempotencyKeyStep.core.api.mdis byte-identical..changeset/2026-08-26-recovery-chain-primitives.md:14describesdispatchWithRecoveryas having a "singletry/catch". It is now two, in two functions. The guarantee thesentence states is still true and the file is a dated release note for what landed on 2026-08-26, so it was
left alone.