Idempotency
[Idempotent] makes an unsafe endpoint (POST/PUT/PATCH/DELETE) safe to retry: requests must
carry an idempotency-key header, and the first successful response is replayed for
retries with the same key and body: the handler runs once.
[Endpoint(HttpVerb.Post, "/api/booking-tokens")][Idempotent(DurationSeconds = 300)] // replay window (default 1h)public partial class CreateBookingTokenEndpoint : Endpoint<string>{ public required string Purpose { get; set; } // ...}Behavior
Section titled “Behavior”| Scenario | Result |
|---|---|
| Header missing | 400 ProblemDetails (Idempotency-Key by default) |
| First request | Handler runs; a 2xx response is captured (status, content type, Location, body) and cached |
| Retry, same key + same body | Captured response replayed; handler not re-executed |
| Same key, different body | Treated as a distinct request (the body hash is part of the cache key) |
| Concurrent same-key requests | The first runs; the others get 409 while it is in flight, and replay once it lands |
| 4xx/5xx | Not cached: the client can retry with the same key |
Configuration
Section titled “Configuration”Per endpoint via the attribute (DurationSeconds, HeaderName), globally via
PragmaticEndpointsOptions.Idempotency (HeaderName, DefaultDurationSeconds).
Requirements and notes
Section titled “Requirements and notes”- Requires Pragmatic.Caching (
AddPragmaticCaching()), categoryCacheCategories.Idempotency(falls back to the default stack). The filter fails fast when the cache is missing: silently losing the guarantee would be a correctness bug. With a distributed backend (Redis) the guarantee spans instances. - The body hash is computed from the bound body DTO serialized with the host JSON options (endpoint filters run after binding). Form/multipart endpoints hash key + route only.
[Idempotent]on GET/HEAD/OPTIONS warns (PRAG0513) and emits nothing: safe verbs are idempotent by definition; use[ResponseCache]for caching semantics.- The required header is documented automatically in the manifest and OpenAPI.
- Responses are buffered for capture: keep idempotent endpoints’ payloads reasonably small.
- The endpoint runs in the caller’s own execution context, never inside a cache factory. This is
what makes
[Idempotent]usable together with[RequirePermission]: inside aHybridCachefactoryIHttpContextAccessor.HttpContextis null (dotnet/extensions#5648), soICurrentUsersees no principal and every authenticated request is refused with401. Sharing one execution across concurrent callers would be worse than the refusal (one caller’s response delivered to another), so duplicates are answered with409instead of joined. - ⚠️ The in-flight reservation is atomic within a process. Across instances it is only as atomic
as the registered
ICacheStack: closing it requires a backend with a native atomic increment (RedisINCR). - The key is mandatory where the attribute is applied: there is no honour-if-present mode, so adopting it on an existing endpoint rewrites the contract for every caller at once.
