Skip to content

docs(idempotency): rewrite for the corrected replay behaviour (#1333) - #240

Open
marcelo-maciel wants to merge 5 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/idempotency-correctness
Open

docs(idempotency): rewrite for the corrected replay behaviour (#1333)#240
marcelo-maciel wants to merge 5 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/idempotency-correctness

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Documents the corrected idempotency filter from fullstackhero/dotnet-starter-kit#1333. Please merge this alongside that PR: the current page describes behaviour the code no longer has, and in a few places behaviour it never had.

Why the page needed a rewrite, not an edit

.WithIdempotency() probed the response cache through IDistributedCache on the raw key but stored through HybridCache, which keys its backing entries under its own scheme. The probe never found what the store wrote, so replay silently never engaged, in production as much as in the test suite. Everything the page said about replay was therefore aspirational, and several details it promised were wrong once replay started working: the cached payload was the serialized Ok<T>/Created<T> wrapper rather than the wire DTO, a 201 replayed as 200, and Location/ETag were dropped.

What changed on the page

  • Key composition. The entry is keyed on the resolved tenant (not the caller's tenant claim, so a root operator acting on two tenants no longer shares one bucket), plus the caller, the HTTP method, the route pattern and the resolved route values. PUT /tickets/1 and PUT /tickets/2 are no longer one operation, and two users of one tenant reusing a low-entropy key no longer receive each other's response bodies.
  • Client disconnect. The handler now runs with HttpContext.RequestAborted detached, and the page states the trade-off plainly: on an idempotent endpoint a disconnect no longer aborts the handler. Without that, a client hanging up right after the side effect committed cancelled whatever the handler awaited next and the filter had nothing to store, so the retry re-executed the side effect, the exact duplicate the feature exists to absorb.
  • Failure modes. Probe, reservation and store all fail open, and the page now says so per link. The probe was the one that still hard-failed, which meant a Redis blip returned 500 for exactly the clients that send an idempotency key.
  • Concurrency. The in-flight reservation, the 409 Conflict with Retry-After: 1, and IdempotencyOptions.ReservationTtl (new, default 1 minute) are documented, including why the lock TTL is deliberately decoupled from the 24 h response TTL.
  • Limits, stated explicitly. The response is buffered in memory with no size cap, so .WithIdempotency() must not go on a streaming or large-file endpoint; there is no lease renewal, so a handler slower than ReservationTtl can let a duplicate through; cross-instance deduplication depends on the cache being on Valkey/Redis, not on the reservation. Previously the page implied none of these.
  • Startup validation. IdempotencyOptions is validated on start. A zero DefaultTtl used to throw inside the best-effort store, log a warning and carry on, so nothing was ever cached.

building-blocks/web.mdx gets the one-line summary corrected to match, and changelog/index.mdx gains the entry for the fix.

Verification

npm run build, exit 0, 92 pages indexed, no broken internal links.

Tracks fullstackhero/dotnet-starter-kit#1333. The concept page described a
HybridCache-backed store, a key scoped to the tenant only, and a replay of
"status + body" - none of which is what the filter does now.

Rewrites the flow section around the same-store/same-key symmetry, what is
captured (wire body, real status, allow-listed Location/ETag), the 2xx-only
rule, the store happening before the client write on an uncancellable token,
and the in-flight reservation with its 409 and the new ReservationTtl option.
Replaces the "the cache key does NOT include the route" gotcha, which is no
longer true, with the body and anonymous-tenant caveats that are. Adds the
changelog entry and the ReservationTtl mention on the Web building-block page.
… chosen

The key follows the resolved tenant (a root operator's target included), the
claim is only a fallback and an unresolved `tenant` header is never used. Both
reservation branches expire on `ReservationTtl`, and releasing is a
compare-and-delete so a request that failed open frees nothing.
…s new scopes

The handler runs with the abort token detached, the probe fails open like the
rest, a handler that writes the response itself is passed through, and the key
covers the caller and the resolved route values. Adds the `Retry-After` on the
409, the startup validation of the options, and the ceilings worth knowing:
buffered responses, and cross-instance dedup depending on the cache being on
Valkey rather than on the reservation.
The page framed the shared "anon" caller bucket as a caveat to work around with
fresh UUIDs. It is a defect: two self-registrations on one tenant with the same
low-entropy key made the second replay the first registrant's 201, and no account
was created. Documents the fix instead - the endpoint dropped .WithIdempotency(),
and an integration test now fails the build if an anonymous endpoint carries it.
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