Architecture Decision Record
ADR-017: HTTP Request Idempotency via Client-Supplied Keys
Status
Accepted
Context
Write endpoints (POST / PUT / PATCH) are exposed to client retries and double-submits: a flaky
network, an impatient user double-clicking, or a resilience pipeline re-issuing a request can cause the
same logical operation to execute twice (e.g. two orders, two payments). The Result pattern (ADR-013)
and domain invariants stop invalid states, but they do not stop a valid operation from being applied
twice when the second call is a genuine duplicate of the first.
This is a different concern from the handler idempotency of ADR-003: that is about domain-event consumers tolerating at-least-once delivery from the outbox. ADR-017 is about the inbound HTTP edge deduplicating client requests before the use case runs at all.
Decision
Provide opt-in, client-driven request idempotency as an MVC action filter in MMCA.Common.API.
- Opt-in per action.
[Idempotent](IdempotentAttribute, aServiceFilterAttributeresolvingIdempotencyFilterfrom DI) marks an action. Nothing is deduplicated unless the action declares it. - Client supplies the key. The caller sends an
Idempotency-Keyheader. If the header is absent or blank, the action runs normally with no deduplication — the key is the client's assertion that "two requests with this key are the same operation." - Cache-backed replay. The first response (status code + serialized body) is stored via
ICacheServiceunderidempotency:{key}for a bounded window (default 24h, configurable viaIdempotencySettings.CacheExpirationHours).ICacheServiceresolves to the distributed (Redis) store when the host wires one and otherwise to an in-process memory cache (ADR-026), so cross-instance / cross-restart replay holds only when a distributed backing is configured. A later request with the same key replays the cached response and adds anX-Idempotent-Replay: trueheader so clients can tell a replay from a fresh execution. - Concurrency-safe within an instance. The filter uses a fast-path cache read (no lock), then a
per-key
SemaphoreSlim(double-check locking) so concurrent duplicates that arrive before the first completes are serialized rather than both executing. Per-key semaphores are removed once no waiters remain, so the lock table does not grow unbounded. - Only cache deterministic success shapes. Only
ObjectResultresponses are cached; redirects, file results, and the like are not replayed.
Rationale
- Safety at the edge, not in every handler. Deduplication lives in one filter, so a handler stays a thin use case (ADR-014) and does not grow ad-hoc "did I already do this?" checks.
- Client owns the identity of an operation. Only the caller knows that a retry is the same operation; a server-generated key cannot distinguish a retry from a legitimately-similar new request.
- Distributed-cache backing (when configured) makes replays work across instances and across a service restart within the window, matching the database-per-service / multi-host deployment (ADR-006, ADR-008); with the in-memory fallback (ADR-026) the replay is per-instance and lost on restart.
- Invariant-friendly default. Absent header ⇒ no behavior change, so adding
[Idempotent]is a safe, additive annotation.
Trade-offs
- The in-process semaphore only serializes duplicates landing on the same instance. Two simultaneous identical requests routed to different instances can both miss the cache and execute; a configured distributed cache makes the replay consistent afterward but does not provide cross-instance mutual exclusion. For exactly-once across instances, the operation itself must also be naturally idempotent or guarded by a unique constraint.
- Bounded window. Replays only work within the retention window (default 24h); a duplicate after expiry re-executes.
- Response-shape coupling. Only
ObjectResultis cached, and the cached body is the serialized value, so an endpoint whose response depends on per-request state (other than the body) will replay the original, not a freshly-computed response. - Opt-in. An action that should be idempotent but is missing
[Idempotent]gets no protection — the same audit-the-inventory caveat as ADR-005'sIAnonymizable.
Related
ADR-003 (handler idempotency for outbox/event consumers, a distinct concern), ADR-013 (Result is the
response the filter caches/replays), ADR-014 (the filter keeps the handler thin), ADR-009 (the resilience
pipeline that re-issues requests is the main source of the duplicates this filter absorbs), ADR-026 (the
ICacheService substrate whose distributed-vs-memory backing determines cross-instance replay).