Skip to content

One idempotency key per logical request across retry attempts (#73) - #90

Merged
Wahbeh-Mohammad merged 2 commits into
audit/remediation-67from
audit/67/73-idempotency-key-once
Sep 5, 2026
Merged

One idempotency key per logical request across retry attempts (#73)#90
Wahbeh-Mohammad merged 2 commits into
audit/remediation-67from
audit/67/73-idempotency-key-once

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #73. Part of the audit remediation umbrella #67, milestone 3, wave 4. Base: audit/remediation-67.

What changed

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. That defeats the only thing the header is bought for (RECOV-32) and
contradicts the @public TSDoc that has claimed "runs ONCE per call, upstream of retry" since 1f48926.

Per D13 the fix is layering (a), not memoization:

  • packages/core/src/recovery/orchestrator.tsdispatchWithRecovery is split into its two named halves
    and is now their composition. Behaviour unchanged; it is still one wire send with one try/catch per
    throwing surface, and every throwable still becomes a Failure before the response chain runs (RECOV-2).
    • prepareRequest(request, chain) — the request chain, once per logical request, with RECOV-2's
      throw-to-Failure conversion already applied. Never throws.
    • 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 single trip
      through the response and recovery hooks with the transport never called.
    • Both are @internal and stripped from the emitted .d.ts; core.api.md is byte-identical.
  • packages/core/src/retry/retry-dispatch.ts — applies the chain once above runWithRetry; each attempt
    repeats only dispatchPrepared over stampAttempt's fresh copy of the one prepared request, so RETRY-38's
    ordinal 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 only
    throw again — it still passes the response and recovery chains exactly once.
  • TSDoc on dispatchWithRetry, dispatchWithRecovery, prepareRequest/dispatchPrepared,
    RequestRecoveryChain, idempotencyKeyStep and runWithRetry's @param request. idempotencyKeyStep's
    claim 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").

retryStep and the pillar stack are untouched: there "downstream" is the forked continuation (ctx.fork()),
which is still re-driven per attempt. runWithRetry itself is unchanged apart from a @param doc.

Test rows added

All in packages/core/src/retry/retry-dispatch.test.ts; the first four were red before the fix.

Case Proves
applies the request recovery chain ONCE per logical request, not per attempt (RETRY-44) 2 wire sends, 1 chain application. Renamed from re-runs the request recovery chain on every attempt (RETRY-44), which asserted the bug by name
generates ONE key for three attempts and sends it on all three generate() called once; Idempotency-Key is key-1 on all three recorded sends
the run that exhausts the budget sends the same key on every attempt too the same on the failing path, where nothing observes the response
the attempt ordinal varies per send while the key does not (RETRY-38) X-Attempt is 1,2,3 while the key stays key-1
does not retry it, never reaches the transport, and runs the recovery phase once (RECOV-2) request-chain throw: sendCount 0, one recovery-step application carrying failure(boom), the throwable surfaces by identity
a recovery step may still convert that failure into a success RECOV-5/RECOV-13 still reachable on the pre-wire failure path

The 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, AbortSignal
or Uint8Array surface, 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 stack
excludes the request chain.
Records why both of RETRY-44's clauses hold under the new layering — the
second one now by construction, since upstream steps no longer run between attempts — the two rejected
alternatives (WeakMap memoization on the template; re-running the chain over its own output), and the
consequence that the re-send gate now judges the prepared request. Ten file:line citations, all verified
mechanically.

Also re-anchored item 3's retry-dispatch.ts:55 citation 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-execution

D13 asked for the enumeration. Derived from grep -rn "Promise<Request>" packages/*/src:

Shipped RequestStep Needs per-attempt re-run?
idempotencyKeyStep (recovery/idempotency-key.ts) No — the opposite; per-attempt re-run is this bug

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,
stripCrossOriginMarkerStep and retryStep all return a StepDescriptor and live in the pipeline stack,
below which retryStep's ctx.fork() already re-drives them per attempt as RETRY-44 requires. Nothing in
recovery/ other than idempotencyKeyStep produces a RequestStep, and no shipped step needs per-attempt
re-execution.

Gate

node .claude/skills/ci-preflight/run-ci.mjs --clean, from a swept tree on the pinned Bun 1.3.14 — all 20
steps 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 not
exercised locally.

Deferred — release machinery

Suspended under D1; nothing below was done here.

  • Patch changeset for @dexpace/core: dispatchWithRetry now applies the request recovery chain once per
    logical request instead of once per attempt, so an idempotencyKeyStep in that chain emits one key across
    every retry. Behaviour change on an @internal path — dispatchWithRetry is not exported, so no consumer
    can build this composition today — plus shipped .d.ts prose for dispatchWithRecovery,
    RequestRecoveryChain and idempotencyKeyStep. core.api.md is byte-identical.
  • Not edited, worth a release-pass look: .changeset/2026-08-26-recovery-chain-primitives.md:14 describes
    dispatchWithRecovery as having a "single try/catch". It is now two, in two functions. The guarantee the
    sentence states is still true and the file is a dated release note for what landed on 2026-08-26, so it was
    left alone.

`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.
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 10ebe03 into audit/remediation-67 Sep 5, 2026
3 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the audit/67/73-idempotency-key-once branch September 7, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant