DKNet

DKNet.AspCore.Idempotency

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.

✨ Why use it?

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.

🚀 Quick Start

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 by AddIdempotentKey() above, and IdempotencySqlServerStore/IdempotencyPostgresStore/IdempotencyRedisStore in 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; and AddIdempotentKey<TStore>() can only name a store you declare (TStore must 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 Expiration window. 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 then AddIdempotencyWithMsSqlStore(...) (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.

🧩 Features

Idempotency endpoint filter

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.

Workflow diagram of the idempotency filter: an invalid key exits to 400 Bad Request and a duplicate key to 409 Conflict or the cached replay, so only a genuinely new key reaches the endpoint handler. The response is recorded afterwards only when its status is cacheable.

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.

Group-level declaration

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.

Composite key validation (400 Bad Request)

Before touching the store, the filter validates the incoming key against IdempotencyOptions:

Any failure short-circuits the pipeline with a 400 Bad Request problem response — the handler never runs.

A custom IdempotencyKeyPattern with 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

Duplicate-request handling — two strategies

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.

Response caching (which results get remembered)

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);
});

Caller scope isolation

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:

  1. The authenticated user’s ClaimTypes.NameIdentifier → user:{id}.
  2. An HMAC-SHA256 digest of the Authorization header, only when ScopeHmacSecret is configured → auth:{hash}.
  3. The caller’s remote IP address, only when IncludeClientIpInScope is true → ip:{address}.
  4. Otherwise, an empty scope (all anonymous callers share one scope for that key/endpoint/method).
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.

Custom scope resolver

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.

Pluggable store abstraction

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:

  1. Atomic check-and-reserve. 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.
  2. An in-flight reservation placeholder. The reservation written in step 1 must be distinguishable from a completed response, so that a concurrent duplicate is answered (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.
  3. A bounded reservation lifetime. The placeholder must expire after 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.
  4. Distinct keys stay distinct. 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.

In-flight reservation window

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
});

Cache namespacing and expiration

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);
});

In-process store startup warning

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.

⚙️ Configuration reference

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.

🧱 Where it fits

🗄️ Choosing a store

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:

The four store pages link back to this section instead of repeating the comparison — update it here first.

⚠️ Gotchas & limits