fix(web): correct idempotent replay payload and serialize concurrent duplicates#1333
fix(web): correct idempotent replay payload and serialize concurrent duplicates#1333marcelo-maciel wants to merge 4 commits into
Conversation
…duplicates
Two defects in IdempotencyEndpointFilter:
API-01 — the filter cached JsonSerializer.SerializeToUtf8Bytes(result) where result
is the wrapped IResult (Ok<T>/Created<T>), so it stored {"value":...,"statusCode":200}
instead of the wire DTO, and it read Response.StatusCode before the IResult executed,
so a 201 Created replayed as 200. The handler result is now executed into a buffer to
capture the real wire body + status, which is what gets served and cached.
CONC-01 — probe->execute->write had no atomic reservation, so two concurrent requests
with the same key both missed the probe and both executed the handler. An atomic in-flight
reservation now serializes duplicates: Redis SET NX when an IConnectionMultiplexer is
registered (the multi-instance case — this stack already requires Redis there for the
shared Data Protection key ring), an in-process set otherwise (single instance). A
duplicate that arrives while the original is still running gets 409 Conflict.
Redis stays optional: without it the app falls back to the in-memory reservation, correct
for a single instance where a cross-container race cannot occur.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…ore) The write went through HybridCache.SetAsync while the probe read IDistributedCache by the raw key. HybridCache keys its L2 entries under its own scheme, so the probe never found the entry and replay silently never engaged — even in production. Proven by un-skipping ChatSendMessageTests.SendMessage_Should_Replay_Same_Response_When_Idempotency_Key_Reused, which now passes. Write to the same IDistributedCache, key and serializer the probe uses. Idempotency entries are short-lived (TTL) and their HybridCache tag-purge path was unused, so dropping HybridCache here loses nothing.
iammukeshm
left a comment
There was a problem hiding this comment.
The three fixes are the right idea — same-store/same-key symmetry, buffer-and-capture for the true wire body+status, and an atomic SET NX / TryAdd reservation with re-probe-on-race. Tenant isolation is preserved (reservation key derives from the tenant-scoped cache key). Two things to fix before merge:
🔴 HIGH — reservation TTL is the 24h response TTL; a crash mid-request strands the lock for a day. The in-flight reservation uses options.DefaultTtl (24h). If the process is killed between reserving and the finally release (OOM, pod eviction, SIGKILL), the Redis key survives 24h and every retry of that idempotency key 409s for a full day. Use a short, request-timeout-scale TTL for the in-flight reservation (seconds/minutes), decoupled from the 24h stored-response TTL.
StringSetAsync is treated as authoritative, so a transient Redis error throws and 500s the request. On main idempotency degraded gracefully (convenience, not correctness). Wrap the reserve/release in try/catch and fail open (proceed) to match the existing stance used for the response write.
nit — ReleaseReservationAsync's KeyDeleteAsync is unguarded; a Redis fault at release throws out of finally and (with the TTL issue) strands the lock. Make it best-effort too.
src/BuildingBlocks/Web (Golden Rule #4) — needs explicit maintainer sign-off.
Tests are good (replay shape + concurrent-once/409, integration un-skipped); note the concurrent path only exercises the in-process branch, not the Redis NX branch.
Address review on fullstackhero#1333: - Reservation used the 24h response TTL, so a crash between reserving and the finally-release stranded the Redis lock for a day (every retry 409s). Add IdempotencyOptions.ReservationTtl (default 1m), decoupled from DefaultTtl. - Reserve now fails open on a transient Redis error instead of 500ing the request, matching the best-effort stance of the response write. - Guard the release KeyDeleteAsync so a Redis fault can't throw out of the finally. Tests: reservation uses ReservationTtl not DefaultTtl; a faulting Redis on reserve/release proceeds without throwing (exercises the Redis NX branch the prior tests skipped).
|
Addressed in 🔴 HIGH — reservation TTL. Split out
nit — unguarded release. Redis Golden Rule #4 — |
Fixes three defects in
IdempotencyEndpointFilter(audit findings API-01 and CONC-01, plus a replay path that never engaged).API-03 (root) — replay never engaged, even in production
The write went through
HybridCache.SetAsyncwhile the probe readIDistributedCacheby the raw key. HybridCache keys its L2 entries under its own scheme, so the raw-key probe never found the entry and idempotency replay silently never engaged — not just in tests. The repo'sChatSendMessageTestsreplay test was skipped with this exact symptom attributed to a "test-env cache split"; un-skipping it proves it was the filter.Fix: write to the same
IDistributedCache, key and serializer the probe uses. Idempotency entries are short-lived (TTL) and their HybridCache tag-purge path was unused, so dropping HybridCache here loses nothing.API-01 — replay served the wrong shape and status
The filter cached
SerializeToUtf8Bytes(result)whereresultis the wrappedIResult(Ok<T>/Created<T>), storing{"value":{...},"statusCode":200}instead of the wire DTO, and readResponse.StatusCodebefore theIResultexecuted, so a 201 replayed as 200. The handler result is now executed into a buffer to capture the real wire body and status.CONC-01 — concurrent duplicates both executed
probe -> execute -> writehad no atomic reservation, so two concurrent same-key requests both ran the handler. An atomic in-flight reservation now serializes duplicates: RedisSET NXwhen anIConnectionMultiplexeris registered (the multi-instance case — this stack already requires Redis there for the shared Data Protection key ring), an in-process set otherwise (single instance). A duplicate in flight gets 409 Conflict. Redis stays optional.Tests
IdempotencyEndpointFilterReplayTests(unit): replay preserves 201, replay body is the plain DTO, concurrent duplicates execute once (second gets 409).ChatSendMessageTests.SendMessage_Should_Replay_Same_Response_When_Idempotency_Key_Reusedis un-skipped and passes. Framework + Integration suites green.Notes
Docs changelog entry to follow in the docs repo.