Idempotency support for ASP.NET Core minimal API endpoints. The package wraps an IEndpointFilter that recognizes a
client-supplied idempotency key, blocks the same operation from running twice, and replays (or rejects) the retry —
protecting POST/PUT/PATCH handlers from network retries, double-clicks, and at-least-once message redelivery.
CachedResult) or an explicit 409 Conflict (ConflictResponse, the default).IIdempotencyKeyStore abstraction. Start on the built-in
in-process store with no infrastructure at all, or name an atomic store from the ecosystem (see
Choosing a store)..RequiredIdempotentKey() call is all that’s needed: on a
RouteHandlerBuilder it protects that one endpoint, on a RouteGroupBuilder it protects every matching
endpoint in the group.Reach for this package whenever a mutating endpoint must be safe to retry — order/payment creation, resource provisioning, or any handler triggered by a message consumer that might redeliver.
dotnet add package DKNet.AspCore.Idempotency
using DKNet.AspCore.Idempotency;
var builder = WebApplication.CreateBuilder(args);
// No store named: idempotency runs on the package's in-process store — no database, no cache, no Redis, no
// connection string. Keys are process-local, lost on restart, and not shared between instances, so this is for
// local development and unit tests only. For deployed traffic, call a provider package's own
// AddIdempotencyWithMsSqlStore/NpgsqlStore/RedisStore instead (see "Choosing a store" below).
builder.Services.AddIdempotentKey();
var app = builder.Build();
app.MapPost("/orders", CreateOrder)
.RequiredIdempotentKey();
await app.RunAsync();
Callers now must send an X-Idempotency-Key header on POST /orders. A retry with the same header value gets a
409 Conflict (default) instead of creating a second order.
Every shipped store type is
internal. The in-process default store used byAddIdempotentKey()above, andIdempotencySqlServerStore/IdempotencyPostgresStore/IdempotencyRedisStorein the sibling packages, are all internal implementation details — you never name one.AddIdempotentKey()(no type argument) selects the in-process default;AddIdempotencyWithXxxStore(...)on a provider package selects that package’s store; andAddIdempotentKey<TStore>()can only name a store you declare (TStoremust be accessible at your call site — see Pluggable store abstraction).The in-process default is process-local by design. Its reservations are genuinely atomic within one process, so two concurrent requests carrying the same key can never both reach the handler. But its keys live in that process’s own memory: they are lost on restart and are not shared between instances, and the memory it holds is bounded by the keys still inside the configured
Expirationwindow. That makes it right for local development and unit tests — never for production, where two instances would each keep their own idempotency ledger. While it is the store actually serving requests, the app logs one startup warning saying exactly that; naming any other store silences it.An explicitly named store always wins over the default, in either registration order. Calling
AddIdempotentKey()and thenAddIdempotencyWithMsSqlStore(...)(or the reverse) leaves the SQL Server store serving requests — so shared composition code can register the default without blocking a test fixture or a deployed environment from layering a real store on top. Between two explicitly named stores, first registration still wins.
RequiredIdempotentKey() adds the filter to a single route:
app.MapPost("/orders", CreateOrder)
.RequiredIdempotentKey();
It intercepts every request to that route, extracts and validates the idempotency key, checks the store for a prior result, runs the handler only for genuinely new requests, and caches the response afterward when applicable — all without touching the handler code itself.
The three exits matter: a malformed key and a duplicate key both stop before the handler, and only a genuinely new key
reaches it. Note the asymmetry in failure handling — IsKeyProcessedAsync is not wrapped in a try/catch, so a store
outage on the duplicate check fails the request, while a failure while caching the response afterwards is caught and
logged and the client still gets its result.
RequiredIdempotentKey() has a second overload that extends RouteGroupBuilder, so one declaration covers every
matching endpoint in the group instead of repeating the call per route:
public static RouteGroupBuilder RequiredIdempotentKey(
this RouteGroupBuilder group,
params string[] httpMethods); // omitted or empty => POST only
var orders = app.MapGroup("/api/orders").RequiredIdempotentKey(); // POST endpoints only
var admin = app.MapGroup("/api/admin").RequiredIdempotentKey("POST", "DELETE"); // explicit verb set
orders.MapPost("/", CreateOrder); // protected — no per-endpoint call needed
orders.MapGet("/{id}", GetOrder); // untouched: no header required, no duplicate lookup
orders.MapGroup("/{id}/lines") // nested group inherits the same verb selection
.MapPost("/", AddOrderLine); // protected
admin.MapDelete("/tenants/{id}", DeleteTenant); // protected too, because DELETE was named
What the declaration does and does not reach:
Endpoint in a group declared with RequiredIdempotentKey() (default) |
Covered? |
|---|---|
MapPost(...) — registered before or after the declaring call |
Yes |
MapPost(...) in a group nested under the declaring group, any depth |
Yes — at the same verb selection |
MapGet(...), MapPut(...), MapDelete(...) |
No — outside the covered verb set |
Map(...) with no verb constraint (also serves GET) |
No — no routed verb to match |
The default covered verb set is POST only. PUT and DELETE are idempotent by HTTP semantics already, so
they are opt-in: name them explicitly (RequiredIdempotentKey("POST", "DELETE")) when your handlers need the
protection anyway. Verbs are matched case-insensitively against the endpoint’s routed verb, so "post" and
"POST" are the same declaration.
Coverage is decided once at application start, from the declaration plus the endpoint’s routed verb — never
from anything the caller sends. A X-HTTP-Method-Override: GET on a covered POST route therefore cannot move
that endpoint out of coverage.
An endpoint whose verb is outside the set is left completely untouched: no header requirement, no 400, no
duplicate lookup, no response retained. Reads in a protected group keep working normally for clients that never
send an idempotency key, and a key sent to such an endpoint has no effect at all.
A covered endpoint behaves exactly like one declared individually — same header name, same key validation, same
duplicate handling, same retention, all still driven by IdempotencyOptions. The per-endpoint overload is
unchanged, and an endpoint covered by both a group declaration and its own RequiredIdempotentKey() is
protected exactly once: one handler invocation, one stored response, one duplicate report.
Before touching the store, the filter validates the incoming key against IdempotencyOptions:
X-Idempotency-Key by default) must be present and non-blank.MaxIdempotencyKeyLength (default 255).IdempotencyKeyPattern (default ^[a-zA-Z0-9\-_]+$, i.e. UUID-v4 compatible). The
match runs under a fixed 100 ms timeout; a pattern that exceeds it is treated the same as a mismatch — the
same 400, not a hang or a 500.Any failure short-circuits the pipeline with a 400 Bad Request problem response — the handler never runs.
A custom
IdempotencyKeyPatternwith nested quantifiers (^([a-zA-Z0-9]+)+$is the classic accident) can still burn CPU on a long key before it times out. The 100 ms timeout is a backstop against a hang, not a fix for a pathological pattern — write the pattern to be linear in the first place.
app.MapPost("/orders", CreateOrder)
.RequiredIdempotentKey(); // client omits the header, or sends 300 chars, or "bad key!" -> 400
IdempotencyOptions.ConflictHandling controls what a client sees when the same composite key is reused:
builder.Services.AddIdempotentKey(options =>
{
// Default: tell the client explicitly that this was already processed.
options.ConflictHandling = IdempotentConflictHandling.ConflictResponse; // 409 Conflict
// Or: silently replay the original response as if it just happened again.
// options.ConflictHandling = IdempotentConflictHandling.CachedResult;
});
With CachedResult, the second request gets the exact status code, body, and content type of the first — read back
from the store, not recomputed.
Only responses whose status code falls in [MinStatusCodeForCaching, MaxStatusCodeForCaching] (default 200–299)
or in AdditionalCacheableStatusCodes are cached; everything else (validation errors, 404s, etc.) is left
unrecorded so a genuinely failed attempt can be retried as a new request:
builder.Services.AddIdempotentKey(options =>
{
// Also remember 201-with-redirect-style responses outside the 2xx window, e.g. 226.
options.AdditionalCacheableStatusCodes.Add(226);
});
Two different callers sending the identical idempotency key to the identical endpoint must not collide. By default,
IdempotencyKeyScopeResolver resolves a scope using a fallback chain:
ClaimTypes.NameIdentifier → user:{id}.Authorization header, only when ScopeHmacSecret is configured → auth:{hash}.IncludeClientIpInScope is true → ip:{address}.builder.Services.AddIdempotentKey(options =>
{
options.ScopeHmacSecret = builder.Configuration["Idempotency:HmacSecret"];
options.IncludeClientIpInScope = true; // last-resort fallback for fully anonymous callers
});
The raw Authorization header and the HMAC secret are never logged or persisted — only the resulting digest is used.
Supply your own resolver to bypass the default chain entirely — useful for multi-tenant scoping by tenant ID, API key, or any other principal your app already tracks:
builder.Services.AddIdempotentKey(options =>
{
options.KeyScopeResolver = ctx => ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault();
});
When KeyScopeResolver is set, it is used verbatim and the default chain (user claim, HMAC, IP) is skipped.
The filter depends only on IIdempotencyKeyStore (namespace DKNet.AspCore.Idempotency.Store):
public interface IIdempotencyKeyStore
{
ValueTask<(bool processed, CachedResponse? response)> IsKeyProcessedAsync(IdempotentKeyInfo keyInfo);
ValueTask MarkKeyAsProcessedAsync(IdempotentKeyInfo keyInfo, CachedResponse cachedResponse);
}
This is the package’s one real extension point, and it carries a contract the compiler cannot enforce. A custom store must guarantee all four of the following:
IsKeyProcessedAsync returning (false, null) must have already durably recorded
that this composite key is in flight, in the same indivisible operation that observed it absent. A unique index
insert, a Redis SET NX, or a compare-and-swap all qualify; a Get followed by a separate Set does not.
If you get this wrong: two concurrent requests both observe (false, null), both run the handler, and the
side effect the filter exists to protect happens twice.(true, null) — “already in flight, no cached
response yet” — rather than (true, someResponse). Every shipped store uses HTTP 102 Processing as that
sentinel. If you get this wrong: the filter’s CachedResult strategy replays an empty or half-written body
as though it were the original response.IdempotencyOptions.InFlightReservationTimeout
(default 30 seconds), and an expired one must be reclaimable — again atomically. If you get this wrong: a
handler that crashes mid-flight blocks that key permanently, and the caller can never retry.IdempotentKeyInfo.CompositeKey is Scope:Method:Endpoint:Key and is free-form
caller input. Hash it (every shipped store uses SHA-256) rather than escaping or truncating it, so two
structurally different composite keys can never collapse onto one storage key.MarkKeyAsProcessedAsync has no atomicity requirement — the filter calls it once, from the caller that won the
reservation, and it should overwrite that caller’s placeholder with the completed response.
using DKNet.AspCore.Idempotency.Filtering;
using DKNet.AspCore.Idempotency.Store;
public sealed class MyIdempotencyKeyStore : IIdempotencyKeyStore
{
public ValueTask<(bool processed, CachedResponse? response)> IsKeyProcessedAsync(IdempotentKeyInfo keyInfo)
{
// Atomically: if no live entry exists for keyInfo.CompositeKey, write a reservation and return
// (false, null); otherwise return (true, null) while it is a reservation, or (true, response)
// once it holds a completed response.
throw new NotImplementedException();
}
public ValueTask MarkKeyAsProcessedAsync(IdempotentKeyInfo keyInfo, CachedResponse cachedResponse)
{
// Overwrite this caller's reservation with cachedResponse, expiring after IdempotencyOptions.Expiration.
throw new NotImplementedException();
}
}
// Register it in place of the in-process default store:
builder.Services.AddIdempotentKey<MyIdempotencyKeyStore>();
CachedResponse is what a store round-trips. Every member is required on construction:
| Member | Type | Meaning |
|---|---|---|
StatusCode |
int |
The original response’s status code, replayed verbatim. 102 is reserved for the in-flight sentinel. |
Body |
string? |
The serialized response body, or null for a body-less response. |
ContentType |
string |
The original content type; the filter falls back to "application/json" when the response did not set one. |
CreatedAt |
DateTimeOffset |
When the entry was written (UTC). |
ExpiresAt |
DateTimeOffset? |
When it stops being valid, or null for no expiry. |
IsExpired |
bool (derived) |
true once a non-null ExpiresAt has passed. Stores are expected to honour it on read. |
IdempotentKeyInfo is what a store receives. Endpoint and Method are required; both are already
upper-invariant when the filter builds one:
| Member | Type | Meaning |
|---|---|---|
IdempotentKey |
string? |
The raw header value, or null when the header was absent. |
Endpoint |
string |
Route template (RoutePattern.RawText, then IRouteDiagnosticsMetadata.Route, then the request path), upper-invariant. |
Method |
string |
HTTP method, upper-invariant. |
Scope |
string |
Caller scope; string.Empty for an unscoped anonymous caller. |
CompositeKey |
string (derived) |
$"{Scope}:{Method}:{Endpoint}:{IdempotentKey}" — the value to hash and store under. |
SafeKey |
string (derived) |
IdempotentKey with CR/LF/U+2028/U+2029 and every other control character stripped. Logging and display only — never use it as a storage key. |
IsValid(IdempotencyOptions) |
IResultBase |
The presence/length/pattern check the filter runs before touching the store. |
While a handler is still running for a brand-new key, every shipped store — the in-process default included —
writes a short-lived reservation record (HTTP 102 Processing sentinel) so a concurrent duplicate sees “already in
flight” instead of also slipping through as new. InFlightReservationTimeout (default 30 seconds) bounds how long
that reservation is honored before a crashed or hung handler stops blocking retries of the same key:
builder.Services.AddIdempotentKey(options =>
{
options.InFlightReservationTimeout = TimeSpan.FromSeconds(10); // fail fast for quick handlers
});
CachePrefix (default "idem") namespaces cache keys to avoid collisions with unrelated cached data, and
Expiration (default 4 hours) is the absolute lifetime of a cached idempotency result before the same key is treated
as brand-new again:
builder.Services.AddIdempotentKey(options =>
{
options.CachePrefix = "checkout-idem";
options.Expiration = TimeSpan.FromHours(24);
});
AddIdempotentKey() also registers an internal startup service (IdempotencyInMemoryStoreWarning, an
IHostedLifecycleService) that logs exactly one warning per application start — and only while the resolved
IIdempotencyKeyStore is the in-process default store:
warn: DKNet.AspCore.Idempotency.Store.IdempotencyInMemoryStoreWarning[0]
Idempotency keys are stored in the process's memory (IdempotencyInMemoryStore): they are lost on restart
and are not shared between instances. ...
The warning exists because those keys live in one process’s memory: they are lost on restart and never reach a
second instance, so an accidental multi-instance deployment on the default store is visible in the logs on the
first start instead of only when a duplicate slips through. Registering any other store —
AddIdempotentKey<TStore>() or a provider package’s AddIdempotencyWithXxxStore(...) — silences it, because the
resolved store is then no longer IdempotencyInMemoryStore.
The service checks the store in the start moment only; the remaining lifecycle moments (starting, started,
stopping, stop, stopped) do no work. It is internal, so there is nothing to register, configure, or suppress from
application code — pick a durable store and the warning is gone.
All options live on IdempotencyOptions, configured via the Action<IdempotencyOptions> passed to
AddIdempotentKey(), AddIdempotentKey<TStore>(), or a provider package’s AddIdempotencyWithXxxStore(...). The
in-process default store adds no options of its own — it reuses Expiration and InFlightReservationTimeout, and
holds no key past its Expiration.
| Option | Default | Purpose |
|---|---|---|
IdempotencyHeaderKey |
"X-Idempotency-Key" |
Header the filter reads the key from. |
IdempotencyKeyPattern |
^[a-zA-Z0-9\-_]+$ |
Regex a key must match to be accepted; matched under a fixed 100 ms timeout — a pattern that times out is treated as a mismatch (400). |
MaxIdempotencyKeyLength |
255 |
Maximum accepted key length. |
ConflictHandling |
ConflictResponse |
ConflictResponse (409) or CachedResult (replay). |
Expiration |
4 hours |
Absolute lifetime of a cached result. |
InFlightReservationTimeout |
30 seconds |
How long an in-flight reservation blocks a retry before expiring. |
MinStatusCodeForCaching / MaxStatusCodeForCaching |
200 / 299 |
Inclusive status-code range eligible for caching. |
AdditionalCacheableStatusCodes |
(empty) | Extra status codes to cache outside the min/max range. |
CachePrefix |
"idem" |
Prefix applied to every cache key. |
JsonSerializerOptions |
camelCase naming policy | Used to serialize/deserialize cached response bodies. |
KeyScopeResolver |
null |
Custom caller-scope resolver; bypasses the default chain when set. |
ScopeHmacSecret |
null |
Enables the Authorization-header HMAC fallback in the default scope chain. |
IncludeClientIpInScope |
false |
Enables the client-IP fallback in the default scope chain. |
Both AddIdempotentKey() overloads register IdempotencyOptions through the options pattern with ten .Validate(...)
rules (empty header key, empty cache prefix, non-positive expiration, an out-of-range status window, a null
JsonSerializerOptions, etc.) plus .ValidateOnStart(). That means misconfiguration no longer throws at
registration time — it throws OptionsValidationException when the host starts (before it begins serving
requests), which is still well before any request can observe a bad value, just later in the startup sequence than
before.
FluentResults – IdempotentKeyInfo.IsValid returns an IResultBase, following the same result pattern used
across DKNet.DKNet.AspCore.Tasks (start-up jobs) and other ASP.NET Core hardening utilities in the
same AspNet/ area to build resilient web APIs on top of DKNet’s DDD/Onion architecture.IIdempotencyKeyStore) that the store ecosystem below implements.This package owns the endpoint filter, options, and the store contract (IIdempotencyKeyStore). It ships exactly
one store implementation — an in-process store for local development and unit tests — and four sibling packages
provide alternatives that hold keys outside the process:
| Store | Package | How you select it | Atomicity | Infra cost | Best for |
|---|---|---|---|---|---|
| In-process (built-in default) | DKNet.AspCore.Idempotency |
AddIdempotentKey() — no type argument |
Atomic, but only within one process | None at all — no database, cache, Redis, or connection string | Local development and unit tests. Keys are process-local, lost on restart, and not shared between instances, so never production |
| Relational (base) | DKNet.AspCore.Idempotency.Relational |
Not selectable — every type in it is internal and its InternalsVisibleTo list is closed |
Atomic via a unique index / insert-or-query pattern | Shared EF Core building blocks for the two SQL stores below | Nothing to register; read it only to add a new relational provider inside this repo |
| SQL Server | DKNet.AspCore.Idempotency.MsSqlStore |
AddIdempotencyWithMsSqlStore(connectionString, options) |
Atomic (unique index) | A migrated table in an existing SQL Server database | Apps already running SQL Server that want an auditable, queryable idempotency table |
| PostgreSQL | DKNet.AspCore.Idempotency.NpgsqlStore |
AddIdempotencyWithNpgsqlStore(connectionString, options) |
Atomic (unique index) | A migrated table in an existing PostgreSQL database | Apps already running PostgreSQL, same trade-offs as the SQL Server store |
| Redis | DKNet.AspCore.Idempotency.RedisStore |
AddIdempotencyWithRedisStore(connectionString, options) |
Atomic (native Redis primitives, e.g. SET NX) |
A Redis instance/cluster | High-throughput APIs, multi-instance deployments, when you don’t want schema migrations |
| Your own | — | AddIdempotentKey<TStore>(options) |
Whatever you implement — see Pluggable store abstraction | Yours | A backing store none of the above covers |
Guidance:
AddIdempotentKey()) while you are developing or unit-testing: it
needs no infrastructure and reserves each key atomically, so the filter behaves exactly as it will in production
for a single process. What it does not do is survive a restart or reach a second instance — the keys live in that
process’s own memory. It logs one startup warning saying so while it is the store serving requests, which is what
makes an accidental multi-instance deployment on it visible; naming any other store silences the warning.DKNet.AspCore.Idempotency.Relational. Every type in it is internal, and its
InternalsVisibleTo list names only the two in-repo provider packages and their test projects. A store of your own
implements IIdempotencyKeyStore directly, the way the Redis store does.Expiration has elapsed and releases the memory with it,
so the same key is treated as brand-new again — and the memory it holds is bounded by the keys still inside that
window. Redis TTLs expire keys natively with the same “silently becomes new” behavior once the TTL elapses.
Relational stores need an explicit expiry column and a cleanup strategy (a scheduled sweep or query filter)
because SQL Server/PostgreSQL rows don’t expire themselves — see each store’s page for its approach.The four store pages link back to this section instead of repeating the comparison — update it here first.
409, not replay. If you expect a duplicate request to transparently get the
original response back, you must opt into ConflictHandling = IdempotentConflictHandling.CachedResult — the
default explicitly tells the caller the request was already processed.IsKeyProcessedAsync runs with no surrounding
try/catch in the filter, so a store outage (e.g. cache/DB unavailable) propagates as an unhandled exception and
the request fails — there is currently no configurable fail-open/fail-closed toggle for this path. By contrast,
a failure while caching the response after a successful handler run (MarkKeyAsProcessedAsync/serialization) is
caught and logged — the original response still reaches the client even if it couldn’t be cached for future
replay.RequiredIdempotentKey() extends RouteHandlerBuilder and RouteGroupBuilder, so it
wires up through app.MapPost(...)/MapPut(...)/app.MapGroup(...) etc. It is not something you attach to an
MVC controller action via attributes.app.Map(...) (or
group.Map(...)) carries no routed verb, so a group declaration — default or explicit — cannot match it and it
stays unprotected even though it accepts POST. Declare explicit verbs (MapPost, MapPut, …) on endpoints
you want a group to protect.MapPut in a group declared RequiredIdempotentKey() (POST-only default) is simply unprotected. That is an
explicit choice; check the verb set against the group’s mutating routes.AddIdempotentKey<TStore>() after a store package’s own AddIdempotencyWithXxxStore(...)), the first call’s
store registration sticks and only the first call’s config delegate ever runs — a second call’s config is
silently never invoked, not merged, not overridden. Validation failures surface via OptionsValidationException
when the host starts (ValidateOnStart()), not at the moment the registration call runs.AddIdempotentKey() replaces the default rather than being ignored, and it is that named registration’s config
delegate that decides any option both calls set. In the other direction, AddIdempotentKey() called when any
store is already registered is a complete no-op — including its config.CachePrefix + a SHA-256 hex
digest of the composite key — you cannot reconstruct the original key/scope/endpoint from the cache key alone;
rely on structured logs (which log the raw composite key) if you need to trace a specific request.AspNet/ area.