Olawale Lawal
Writingtransmissions / 024

IdempotencyGuard: Idempotency Keys for ASP.NET Core

26 min readarchitectureC#concurrencyDistributed Systemsopen sourcepayment

A single key stamped "Duplication Prohibited" on a dark background
Photo by Kelly Sikkema / Unsplash

A client sends POST /payments and your API charges the card. The response dies somewhere on the way back, and the client is left holding a timeout with no idea which side of the charge it’s on.

It has two options. Give up, and a payment that may have succeeded looks like a failure. Retry, and the customer may pay twice. Retrying is usually the right call, which means the server has to make it safe, and the standard way to do that is an idempotency key: the client generates a unique value, sends it in a header, and reuses it on every retry of the same operation. The first request with that key does the work. Every later one gets the first one’s response.

The questions come quickly after that, such as:

  • What happens when the retry arrives while the first request is still running?
  • What if the process holding the key crashes?
  • What if it doesn’t crash, and is merely slow?
  • What if another customer sends the same key?

IdempotencyGuard is my answer to those questions: Idempotency-Key middleware for ASP.NET Core, with claims that stay atomic across instances on Redis, PostgreSQL or SQL Server. It started with a need for it in production, and I built it as something I could share publicly. The guard goes in the pipeline, and your handlers don’t change:

builder.Services.AddIdempotencyGuard(options =>
{
    options.KeyScope = context => context.User.FindFirstValue("sub");
});
builder.Services.AddIdempotencyGuardRedisStore("localhost:6379");

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseIdempotencyGuard();

This post covers the architecture, the design decisions and the problems I had to solve along the way, including one I shipped, missed with a stress suite, and only fixed in 2.0.


Why the Simple Version Fails

Nearly every implementation starts here:

var cached = await store.GetAsync(key);
if (cached is not null)
    return cached;

var response = await handler();
await store.SetAsync(key, response);
return response;

Two concurrent requests both read nothing, and both run the handler. That’s the failure everyone spots. A lock fixes it on one instance and does nothing across two, so you move the check into a shared store, and the harder problems start to show.

A real implementation has to answer all of these:

Question What goes wrong if you don’t answer it
Who owns this key right now? Concurrent duplicates both run the handler
What if the owner crashes? The key stays locked, and every retry fails forever
What if the owner is only slow? Two handlers run, and one overwrites the other’s result
Is this retry the same request? A new payload under an old key gets the old response, marked as a success
What if the client disconnects mid-request? The work happens, the record of it doesn’t, and the retry repeats it
Whose key is this? One caller receives another caller’s stored response
What about the provider’s side? Your API deduplicates perfectly and the card still gets charged twice

Most of these only appear under timing you won’t hit in development. They also don’t fail loudly. A double charge looks like two successful requests.


System Overview

The library ships as five NuGet packages, targeting .NET 8, 9 and 10:

Package What it holds
IdempotencyGuard The store contract, fingerprinting, downstream keys, telemetry and an in-memory store
IdempotencyGuard.AspNetCore Middleware, options, policies, attributes, startup validation and the cleanup service
IdempotencyGuard.Redis Store built on Lua scripts, with native key expiry
IdempotencyGuard.PostgreSql Store built on INSERT ... ON CONFLICT
IdempotencyGuard.SqlServer Store built on MERGE with HOLDLOCK

Everything hangs off one model. An entry sits under a key in one of two states, claimed or completed, and every request tries to claim the key before doing anything else. What comes back decides what happens next:

flowchart TD
    A[Request with Idempotency-Key] --> B{Key and scope valid?}
    B -- No --> C[400 or 403]
    B -- Yes --> D[Fingerprint the request]
    D --> E{Try to claim the key}
    E -- Claimed --> F[Run the handler and buffer the response]
    F --> G[Store the response with the claim token]
    G --> H[Send the response]
    E -- Completed --> I[Replay the stored response]
    E -- Fingerprint mismatch --> J[422]
    E -- Already claimed --> K{Concurrency policy}
    K -- Return conflict --> L[409]
    K -- Wait then replay --> M[Back off and try to claim again]
    M --> E

Over HTTP, that comes out as:

Situation Response
New key Run the handler and store the response
Key completed, same payload The original status and body, plus X-Idempotent-Replayed: true
Key in progress, same payload Wait and replay by default, or a 409
Key used with a different payload 422
Key missing, on an endpoint that requires one 400
Key too long, or containing control characters 400
Caller can’t be identified, with scoping on 403
Original response was too large to store 410

These broadly follow the IETF’s Idempotency-Key header draft, which is still a draft, and I differ from it in two places. It says a concurrent retry should get a 409, while my default waits and replays. It also defines the key as a quoted Structured Field string, and I treat the header as plain text.


The Claim

Everything else rests on one operation: “if this key is free, it’s mine, and otherwise tell me who has it.” That has to be a single atomic step in the shared store. Check first and write second, and you’ve rebuilt the race you started with.

Here’s the whole contract a store has to meet:

public interface IIdempotencyStore
{
    Task<ClaimResult> TryClaimAsync(
        string key, string requestFingerprint, TimeSpan claimTtl, CancellationToken ct = default);

    Task<bool> SetResponseAsync(
        string key, string claimToken, IdempotentResponse response, TimeSpan responseTtl, CancellationToken ct = default);

    Task<bool> ReleaseClaimAsync(
        string key, string claimToken, CancellationToken ct = default);

    Task<IdempotentResponse?> GetResponseAsync(
        string key, CancellationToken ct = default);
}

Claiming returns one of four outcomes, and the private constructor closes the hierarchy so nobody can add a fifth:

public abstract record ClaimResult
{
    private ClaimResult() { }

    public sealed record Claimed(string ClaimToken) : ClaimResult;
    public sealed record AlreadyClaimed(IdempotencyEntry Entry) : ClaimResult;
    public sealed record Completed(IdempotencyEntry Entry) : ClaimResult;
    public sealed record FingerprintMismatch(string ExpectedFingerprint, string ActualFingerprint) : ClaimResult;
}

I wanted the claim and the comparison in one call. A loser needs the existing entry anyway, to compare fingerprints and see whether it finished, so a claim that only returned true or false would cost a second round trip on every duplicate. That’s why the Redis store uses a Lua script rather than a bare SET NX.

Redis

Redis runs a Lua script as one atomic unit, so nothing can claim the key between these two calls:

local existing = redis.call('GET', @key)
if existing then
    return existing
end

local entry = cjson.encode({
    state = 'claimed',
    fingerprint = @fingerprint,
    claimed_at = @current_timestamp,
    claim_token = @claim_token
})

redis.call('SET', @key, entry, 'PX', @claim_ttl_ms)
return nil

Expiry takes care of itself. Once the PX lapses the key is gone, and the next caller claims it like any new key.

PostgreSQL

INSERT ... ON CONFLICT DO NOTHING looks like the natural choice, but RETURNING gives you nothing back when the conflict fires. A no-op update makes it return the existing row, and xmax = 0 tells you whether this call inserted it:

INSERT INTO idempotency_entries (key, fingerprint, state, claimed_at, expires_at, claim_token)
VALUES (@key, @fingerprint, 'claimed', @now, @expires, @claim_token)
ON CONFLICT (key) DO UPDATE
    SET key = idempotency_entries.key  -- no-op update to return row
RETURNING key, fingerprint, state, claimed_at, completed_at, expires_at,
          status_code, headers_json, response_body,
          (xmax = 0) AS is_inserted;

SQL Server

MERGE needs HOLDLOCK. Without it, MERGE isn’t safe under concurrent inserts of the same key. The $action output does the job of xmax:

MERGE IdempotencyEntries WITH (HOLDLOCK) AS target
USING (SELECT @key AS [Key]) AS source
ON target.[Key] = source.[Key]
WHEN NOT MATCHED THEN
    INSERT ([Key], Fingerprint, State, ClaimedAtUtc, ExpiresAtUtc, ClaimToken)
    VALUES (@key, @fingerprint, 'claimed', @now, @expires, @claim_token)
WHEN MATCHED THEN
    UPDATE SET ClaimedAtUtc = target.ClaimedAtUtc
OUTPUT $action AS MergeAction, inserted.[Key], inserted.Fingerprint, inserted.State,
       inserted.ClaimedAtUtc, inserted.CompletedAtUtc, inserted.ExpiresAtUtc,
       inserted.StatusCode, inserted.HeadersJson, inserted.ResponseBody;

Expired Rows

SQL stores don’t get Redis’s expiry for free, because an expired row just sits there until something deletes it. The contract says an expired entry counts as absent, so when a claim finds one, the store takes it over with a compare-and-swap:

UPDATE idempotency_entries
SET fingerprint = @fingerprint, state = 'claimed',
    claimed_at = @now, expires_at = @expires,
    completed_at = NULL, status_code = NULL,
    headers_json = NULL, response_body = NULL,
    claim_token = @claim_token
WHERE key = @key AND state = @previous_state AND expires_at < @now

If two callers race for the same expired row, one update affects a row and the other affects none. The loser re-reads the winner’s entry and reports it like any other claim result. It also compares fingerprints on that re-read, because the winner might be carrying a different payload.

All four stores run the same contract tests. IdempotencyStoreContractTests is an abstract class with one derived class per store, so a behaviour either holds everywhere or a store fails its build. Redis, PostgreSQL and SQL Server run those tests against real servers in containers.


What Makes Two Requests the Same?

A key says “this is a retry”. A fingerprint checks that claim. Without one, a client that sends a new payload under an old key gets the old response back with a success status, and nobody finds out until the reconciliation run.

The fingerprint is a SHA-256 hash over a handful of fields:

using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);

AppendField(sha256, Encoding.UTF8.GetBytes(httpMethod.ToUpperInvariant()));
AppendField(sha256, Encoding.UTF8.GetBytes(path));
AppendField(sha256, body is { Length: > 0 } ? NormaliseJsonBody(body) : []);
AppendField(sha256, Encoding.UTF8.GetBytes(extraFingerprint ?? string.Empty));

Hashing is the easy part. Deciding what counts as “the same” is where the decisions are, and each of these came from a real bug:

  • Field boundaries. AppendField prefixes each value with its length. Join fields without a boundary and POST /pay:{} with no body hashes the same as POST /pay with the body {}.
  • Property order. {"a":1,"b":2} and {"b":2,"a":1} mean the same thing, so the body gets parsed and its properties sorted first. Array order stays as it is, because in an array it carries meaning.
  • Types. {"amount":1} and {"amount":"1"} are different payloads. Numbers stay JSON numbers, written with their original text, so 1 and 1.0 also count as different. I’ll accept a false mismatch over a false match: the first gets a client a 422, and the second gets it someone’s old response.
  • Duplicate properties. {"a":1,"a":2} hashes as its last value, because that’s what System.Text.Json and Json.NET hand to your handler.
  • The endpoint. The path includes PathBase, and the extra field includes any hosts the endpoint declares with RequireHost or [Host].

That last one is deliberate about what it leaves out. The Host header stays out of the hash, because proxies rewrite it, and a retry has to replay wherever it lands. If you tell tenants apart by hostname without declaring hosts, KeyPrefix or KeyScope keeps them separate.

A body that isn’t JSON hashes as raw bytes. The default doesn’t suit every API, though. Some requests carry fields that legitimately change between retries, like a timestamp or a correlation ID, and those would turn a genuine retry into a 422. So you can narrow identity down to the fields that define the request, and pull in query and route values:

app.MapPost("/merchants/{merchantId}/payments", (string merchantId, PaymentRequest request) =>
{
    // ...
}).RequireIdempotency(options =>
{
    options.FingerprintProperties = [nameof(PaymentRequest.Amount), nameof(PaymentRequest.Currency)];
    options.FingerprintQueryParameters = ["version"];
    options.FingerprintRouteValues = ["merchantId"];
});

Property matching ignores case, so nameof works whatever casing your serializer uses. If you need something else entirely, IFingerprintBuilder replaces the whole strategy.

The middleware reads the body with EnableBuffering() so the handler can still read it, and caps the read at MaxFingerprintBodySize, 1 MB by default. A larger body gets fingerprinted on its first megabyte only, so two payloads that differ after that point look identical. Setting the cap to 0 skips the body altogether, at the cost of no longer noticing a changed payload. That made one combination quietly useless: FingerprintProperties with a cap of 0 named fields from a body that was never read, so a retry with a changed amount replayed the first response. Since 2.1 it’s an error with the default fingerprint builder, at startup with endpoint validation on, and otherwise on each request to that endpoint before the handler runs.

Same Payload, Different Keys

The fingerprint only answers one question: is this request the same as the one that first used this key? It never searches other keys for a matching payload. So it can’t save you from a frontend that does this:

// Every call looks like a new payment
api.interceptors.request.use(config => {
  config.headers["Idempotency-Key"] = crypto.randomUUID();
  return config;
});

A key is present on every request, so even an endpoint that requires one accepts it. When someone double-clicks, the second request gets a new key, finds no record and runs the handler. DownstreamKey derives from that new key, so the provider sees a different value and charges again as well. The library sees two valid requests, and nothing in the logs looks wrong.

Whether a retry is protected depends on where it happens. If an HTTP client resends the same request below the layer that adds the key, the key stays the same and the guard catches it. A double click, a page reload or application code calling the API again all get a new key. Check your retry library too, because some run interceptors again on every attempt.

I could have matched on the fingerprint alone, but that swaps one bug for another. Two identical payloads can be two real payments, such as a customer buying the same item twice, and only the client knows which one it’s sending.

The fix belongs in the client, which should create one key per payment attempt and keep it until the outcome is definite:

// Created when the attempt starts, and reused through retries and reloads
const key = loadKey(checkoutId) ?? saveKey(checkoutId, crypto.randomUUID());

await api.post("/payments", payment, {
  headers: { "Idempotency-Key": key },
});

loadKey and saveKey stand in for whatever storage survives a reload. Replace the key only once the attempt has definitely failed and the user chooses to try again.

If you don’t fully control your clients, add a check that doesn’t depend on their keys: a unique constraint on the order or checkout ID, so an order can only be paid once.


Waiting, Taking Over and Replaying

The Request That Loses the Claim

A request that finds the key claimed might be a true duplicate that’s still in flight. ConcurrentRequestPolicy picks what happens next. ReturnConflict sends a 409 and lets the client retry. WaitThenReplay, the default, holds the connection and hands back the result once there is one, which is kinder to clients that handle a 409 badly and costs you an open connection in exchange.

The obvious way to wait is to poll for the response. That has a flaw: if the original request fails and releases its claim, no response will ever appear. So the guard polls by claiming:

var deadline = DateTime.UtcNow.Add(_options.ConcurrentRequestTimeout);
var delay = TimeSpan.FromMilliseconds(100);

while (DateTime.UtcNow < deadline)
{
    await Task.Delay(delay, requestAborted);
    delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 1.5, 2000));

    // Claim rather than only read: if the original request failed and released its
    // claim, no response will ever appear, and this request is free to take over.
    switch (await TryClaimAsync(request, requestAborted))
    {
        case ClaimResult.Claimed claimed:
            await HandleNewRequestAsync(httpContext, request, claimed.ClaimToken, requestAborted);
            return ClaimedResult;

        case ClaimResult.Completed:
            var response = await GetResponseAsync(request, requestAborted);
            if (response is not null)
                return await WriteStoredResponseAsync(httpContext, request, response);
            break;

        case ClaimResult.FingerprintMismatch mismatch:
            await WriteFingerprintMismatchAsync(httpContext, request, mismatch);
            return FingerprintMismatchResult;
    }
}

The delay starts at 100 ms and grows by half each time, capped at 2 seconds, until ConcurrentRequestTimeout (30 seconds by default) runs out and the waiter gets a 409. With a claim as the poll, a failed original hands over to the next waiter with no extra machinery, and exactly one waiter wins because claiming is atomic. The other waiters keep waiting on the new holder.

Capturing the Response

To replay a response you have to keep a copy of it. The guard swaps Response.Body for a buffer, runs the handler, and only writes to the real stream after storing:

using var responseBody = new MemoryStream();
httpContext.Response.Body = responseBody;
var bodyFeature = httpContext.Features.Get<IHttpResponseBodyFeature>()!;

await _next(httpContext);

// Bytes still in BodyWriter would be lost when Response.Body is restored, and the
// truncated response stored. Unlike a flush, this doesn't create a writer if the
// handler never used one.
await bodyFeature.CompleteAsync();

Storing a response means deciding what to keep. Hop-by-hop headers stay out, and so does anything specific to one response or caller, such as Set-Cookie, WWW-Authenticate and Date. Replaying somebody’s cookie to whoever holds their key is the sort of bug you only want to read about. You can extend that deny list or replace it with an allow list.

Then there’s the question of 5xx responses. Store a 500 and every retry replays that failure until the response TTL runs out. Skip it, and a retry runs the handler again, which is only safe if the handler didn’t get partway through its side effects. The library can’t know that about your handler, so by default it stores everything, and you can opt out per policy:

options.ShouldStoreResponse = status => status < 500;

A handler that throws is different: nothing was stored, so the claim gets released and a retry can start clean.

Responses Too Large to Store

Anything over MaxResponseBodySize, 1 MB by default, can’t be stored. The tempting move is to release the claim, but the handler has already run, so a retry would repeat its side effects. The guard stores a marker in place of the response, and a retry gets this:

HTTP/1.1 410 Gone
X-Idempotent-Original-Status: 201
X-Idempotent-Replayed: true

The marker is an ordinary stored response, with a couple of headers that identify it, so stores need no new column or code, and a store or older instance that’s never heard of it still serves a 410. A handler can’t forge one either, because the guard strips those header names from anything it stores. For an endpoint that’s safe to rerun, like generating a report, OversizedResponse = OversizedResponseBehavior.RunHandlerAgain brings back the old behaviour.

The Client That Hangs Up

This is the exact scenario idempotency keys exist for, so the library had better get it right. The handler finishes, the charge has gone through, and the client has already disconnected. If the store write inherits RequestAborted, the disconnect cancels it, the response is lost, and the retry runs the handler again.

So the write takes no cancellation at all:

// Deliberately not cancellable. The handler's side effects have already happened,
// so a client hanging up now must not lose the response that makes retries safe.
stored = await _store.SetResponseAsync(
    request.StoreKey, claimToken, idempotentResponse, request.ResponseTtl, CancellationToken.None);

Releasing a claim gets the same treatment, so a disconnect can’t strand one until its TTL runs out.


Fencing Claims

This is the bug I’d most like you to take away from this post, because concurrency testing won’t find it.

Claims have to expire. If a process claims a key and dies, nothing will ever release it, and every retry would wait or get a 409 forever. So claims carry a TTL, 5 minutes by default, and after that the next request can take over.

But from the outside, a crashed process and a slow one look identical. Take a handler stuck on a slow downstream call:

Time Request A Request B (retry)
0:00 Claims the key, starts the handler
5:00 Claim expires, still running
5:01 Claims the key, starts the handler
5:30 Finishes, stores its response Still running
5:31 Client retries, gets A’s response

Nothing told A it had lost the key. This is how 1.x stored a response in PostgreSQL:

UPDATE idempotency_entries
SET state = 'completed',
    status_code = @status_code,
    response_body = @body
    -- ...
WHERE key = @key

WHERE key = @key is the whole bug. It checks which key it’s writing to, and never asks whether it still holds that key. Failure had the same gap: if A threw instead, its cleanup deleted the claim by key, and by then that claim was B’s.

Here’s the sequence against a real Redis on the published 1.3.0 package, with A and B using different payloads so that a wrong replay is easy to see:

A claims the key                : Claimed
Claim lapses, B claims the key  : Claimed
A finishes, stores its response : (no result to check)
B's client retries              : Completed
Stored response under the key   : A's response

B’s client sent B’s payload and got A’s response, marked as a successful replay. The fingerprint check can’t catch this one, because the entry kept B’s fingerprint while holding A’s response. Every safeguard I’d built passed.

The Token

The fix turned out to be small. Each claim generates a token, and both writes have to present it. In Redis:

local claimed = cjson.decode(existing)
if claimed.state ~= 'claimed' or claimed.claim_token ~= @claim_token then
    return 0
end

And in SQL:

UPDATE idempotency_entries
SET state = 'completed',
    -- ...
    claim_token = NULL
WHERE key = @key AND state = 'claimed' AND claim_token = @claim_token

A takeover writes a new token, so A’s stops matching the moment B claims the key. That’s why SetResponseAsync and ReleaseClaimAsync take a token and return a bool, and why Claimed carries the token: the store contract now makes this impossible to forget, which is why it was a breaking change. The same sequence on 2.0.0:

A claims the key                : Claimed
Claim lapses, B claims the key  : Claimed
A finishes, stores its response : applied=False
B's client retries              : AlreadyClaimed
Stored response under the key   : none

If you’ve read Martin Kleppmann’s post on distributed locking, this is his fencing token idea. His tokens increase so that a separate resource can reject older writers. Here the claim and the write live in the same store, so comparing a random token for equality does the job.

What the Token Doesn’t Fix

Both handlers still ran. The token protects the record of what happened, but if each handler charged a card, that card has been charged twice. No amount of work in the middleware can undo that, because the charge happens somewhere the middleware can’t see. Downstream keys, in a moment, are the real protection.

The other lever is a TTL longer than your slowest handler, including its downstream timeouts and retries. That has a price, since a crashed request blocks its retries for longer. To set it from evidence, watch idempotency.claims.lost, which counts responses dropped because a handler outlived its claim. Above zero means your TTL is too short for what your handlers do.


Keys Are a Security Boundary

An idempotency key is a credential, even though it doesn’t look like one. Anyone holding it can replay the response stored under it, and that response may well hold someone’s payment details.

Middleware Order

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseIdempotencyGuard();

Before authorization, the guard serves stored responses to callers who’d have been rejected, anonymous ones included. It also stores their 401s, so the rightful caller’s retry gets a 401 back. Nothing about that looks wrong in a code review, and 1.x didn’t stop it.

2.0 throws instead. ASP.NET Core’s AuthorizationMiddleware leaves a marker in HttpContext.Items when it runs, the same one EndpointMiddleware reads to catch unauthorized endpoints. If the matched endpoint requires authorization and the marker isn’t there, the guard fails the request with a message telling you what to move. The check has a blind spot, since it can’t see an endpoint if the guard runs before routing, and the README calls for that order explicitly.

Scoping Keys to a Caller

Correct ordering isn’t enough, because keys are global by default. Any authenticated caller who sends another caller’s key on the same endpoint with the same payload gets that caller’s response. So you tell the guard who a key belongs to:

options.KeyScope = context => context.User.FindFirstValue("sub");

A null scope gets a 403, never a fallback to the shared key space. With scoping on, a second user sending the same key lands on a different store key entirely, and can’t even learn that the key exists.

I left this opt-in, which was a real decision. The library can’t guess the identifier: sub means nothing for API keys, and sometimes a tenant is the right boundary rather than a user. The rule I settled on is to scope by whoever generates and retries the key, and when that varies by endpoint, named policies carry it:

options.AddPolicy("user",    p => p.KeyScope = ctx => ctx.User.FindFirstValue("sub"));
options.AddPolicy("service", p => p.KeyScope = ctx => ctx.User.FindFirstValue("client_id"));
options.AddPolicy("webhook", p => p.KeyScope = _ => "stripe");

Since it’s opt-in, it can’t be silent. The app logs a warning at startup until you set a scope, and if some routes have one and others don’t, startup validation names the ones that don’t.

Under this sits a small amount of careful construction. The scope gets hashed into a segment that’s tagged by source, with the policy name’s length included, so a global scope can’t imitate a policy’s and user abc stays apart from service abc:

internal static string ScopeSegment(string scope, string? policyName)
{
    var identity = policyName is null
        ? $"g\0{scope}"
        : $"p\0{policyName.Length}:{policyName}\0{scope}";
    return $"{IdempotencyKeyHash.Compute(identity)}{ScopeSeparator}";
}

That hash is unkeyed, so anyone can compute a segment. What stops a client forging a scoped key on an unscoped route is the separator, \u001F, because a control character in a client’s key gets a 400.

Stores Never See the Client’s Key

A client’s key is unbounded input, and databases have opinions about those. Long keys used to cause a 500 on SQL Server (over 256 characters) and PostgreSQL (over about 2.7 KB). SQL Server’s default collation also treated abc and ABC as one key, on that store and no other.

So an entry now sits under the prefix plus a 32-character digest, the first 16 bytes of a SHA-256:

prod:9f86d081884c7d659a2feaa0c55ad015

Fixed length and hex means no overflow and no collation surprises, and every store behaves alike. The prefix stays readable, so SCAN idempotency:prod:* still works. Logs and spans carry the same digest as KeyHash, so a log line leads straight to its row while the raw key, which could replay the response, stays out of your logging stack.


Downstream Keys

Middleware protects your service. The charge happens at your payment provider, so that call needs protecting too, and a stable key derived from the client’s is how you get it:

app.MapPost("/payments", async (
    PaymentRequest request,
    IIdempotencyContext idempotency,
    IPaymentProvider provider) =>
{
    var stripeKey = idempotency.DownstreamKey("stripe", "charge");
    var result = await provider.Charge(request.Amount, stripeKey);

    return Results.Created($"/payments/{result.Id}", result);
});

The key is a hash of the prefix, scope, client key, provider and operation. There’s no state behind it, so a retry, a takeover on another instance and a redeploy all produce the same value. When the fencing race above leaves two handlers running, the provider sees one key twice and treats the second call as a duplicate. This, far more than anything in the store, is what stops a double charge.

I also keep this derivation separate from the store key on purpose, so that a change to the storage format can’t change what a provider receives. That paid off immediately: 2.0 changed every store key, and downstream keys didn’t move, so a provider still catches a duplicate from a retry that spans the upgrade.

There’s more to it than one hash: forwarding the client’s key fails in several ways, and Stripe, Adyen and PayPal each expect different things from the key you send. I’ve written that up separately in how to propagate idempotency keys to downstream services. For providers with no idempotency support at all, you’re left designing for “payment status unknown” as a state of its own.


Running It in Production

Choosing Which Endpoints Are Guarded

Up to 2.0, the setting for this was MissingKeyPolicy, and it answered the wrong question. It only decided what happened to a request without a key: let it through, or reject it with a 400. But the guard ran on every POST, PUT and PATCH that carried a key, marked or not, and no value of MissingKeyPolicy could change that.

That matters as soon as a client sends a key on every request, which a shared HTTP interceptor does without anyone deciding it should. Responses from endpoints that never asked for idempotency got stored and replayed. A key reused on a different endpoint got a 422. Even a request to a route that doesn’t exist had its 404 stored.

2.1 replaces it with UnmarkedEndpoints, which asks the real question: what happens on endpoints without [Idempotent] or RequireIdempotency(), and on requests that match no route at all?

UnmarkedEndpoints Unmarked endpoint, key sent Unmarked endpoint, no key
GuardWhenKeyPresent (default) Guarded Passes through
RequireKey Guarded 400, including routes that don’t exist
Ignore Passes through, nothing stored Passes through

The default keeps 2.0’s behaviour, so upgrading changes nothing on its own. Ignore is the one that fixes the interceptor problem, by making the marker the only way in:

options.UnmarkedEndpoints = UnmarkedEndpointBehavior.Ignore;

app.MapPost("/payments", CreatePayment).RequireIdempotency();  // guarded
app.MapPost("/search", Search);                                // key ignored, nothing stored

A marked endpoint needs a key by default, and Required = false makes it optional for the stretch where existing clients are still moving over to sending one.

Ignore brings two risks of its own. It reads the endpoint’s metadata, so routing has to run before the guard, and calling UseRouting() after UseIdempotencyGuard() now fails at startup rather than silently treating every endpoint as unmarked. And an endpoint you forgot to mark loses its protection with nothing to report it, so check every endpoint you rely on before turning it on.

MissingKeyPolicy still works until 3.0, as does Enabled, which goes the same way because not calling UseIdempotencyGuard() already says it. Both raise an IDEMP001 build warning, and setting MissingKeyPolicy and UnmarkedEndpoints to behaviours that disagree fails at startup.

Configuration That Can’t Quietly Be Wrong

Attributes only accept constants, so anything that needs a delegate lives in a named policy, and anything a policy leaves unset falls back to the global value:

builder.Services.AddIdempotencyGuard(options =>
{
    options.ShouldStoreResponse = status => status < 500;   // global default
    options.AddPolicy("cache-all", p => p.ShouldStoreResponse = _ => true);
});

[Idempotent(Policy = "cache-all", ClaimTtlSeconds = 120)]           // controller or action
app.MapPost(...).RequireIdempotency(o => o.Policy = "cache-all");   // minimal API

Settings merge across a controller and its actions, and the most specific value wins, so an action can change a TTL and keep its controller’s policy. Until 2.1, Required broke that rule: an action’s [Idempotent] carried a default of true, which beat a controller’s explicit Required = false even when the action never mentioned it.

A named policy means a misspelled name is possible, and in a safety library a typo that disabled the guard would be awful. An unregistered name can’t do that. With ValidateEndpointsOnStartup on, it fails the host at startup. With it off, each request using that endpoint fails before the handler runs, so the worst case is that you find out later. The validation is on in Development and off elsewhere by default, because reading the endpoints makes ASP.NET Core build them, routing builds its own copy on the first request regardless, and the time grows with your endpoint count.

The validator itself runs in IHostedLifecycleService.StartedAsync, since endpoints aren’t complete until the web host has started, and an exception thrown there still takes the host down.

Option values get the same treatment, and those checks always run because they don’t need the endpoints. They exist because the failures were real: a zero ResponseTtl quietly removed duplicate protection altogether, and a negative Cleanup.Interval shut the app down. Zero or negative TTLs, sizes and cleanup settings, an empty header name and a null EnforcedMethods now stop the app from starting at all. Endpoint validation also warns when a marked endpoint’s method isn’t in EnforcedMethods, because the guard skips it. An [Idempotent] DELETE is the usual example.

Seeing What It’s Doing

The IdempotencyGuard meter exposes OpenTelemetry metrics, and an activity source traces the store operations and cleanup sweeps. A few of the counters tell you something you’d otherwise have no way to know:

Metric What it tells you
idempotency.claims.lost Handlers are outliving ClaimTtl, and two may have run
idempotency.claims.released Claims given up without a response, tagged failure, response_too_large or should_store_response
idempotency.responses.oversized Responses too large to store, which retries can’t replay
idempotency.fingerprint_mismatches.total A client is reusing keys for different requests
idempotency.store.latency What each store operation costs you, by operation

The idempotency.result span tag records how each request ended, including cancelled for a waiter whose client hung up. There’s also IdempotencyStoreHealthCheck, which registers like any other health check.

Errors follow your API’s shape rather than mine. ErrorResponseFactory receives the status, the error kind and the message, so you can return RFC 7807 Problem Details or whatever contract you already have.

Connections, Tables and Cleanup

The Redis package owns its connection. It connects lazily, forces AbortOnConnectFail=false, reconnects on demand behind a semaphore, and throttles attempts through MinimumReconnectInterval, so an outage doesn’t turn every request into a connection attempt.

AutoCreateTable on the SQL stores had a race of its own. Instances starting together could all see no table, and the losers failed creating it. CREATE TABLE IF NOT EXISTS doesn’t save you, because PostgreSQL can still fail on a unique index in its catalog. Both stores now take a database-level lock first, pg_advisory_xact_lock on PostgreSQL and sp_getapplock on SQL Server, so instances take turns. A test starts eight store instances against a fresh table, five rounds over, and expects every claim to succeed.

Expired rows need clearing on SQL, so a background service sweeps them in batches, 1,000 rows per round trip and at most 100 rounds per sweep, every 5 minutes by default. A single huge delete could hold locks long enough to slow the claims you’re trying to protect. PostgreSQL’s version uses FOR UPDATE SKIP LOCKED, so a sweep steps around rows that are busy and several instances can sweep at once. On Redis, the service notices that the store has nothing to purge and exits.


Testing the Guarantee

The repo carries four layers of tests: unit tests, middleware tests through the ASP.NET Core pipeline, contract tests against every store, and a stress suite built to break the guarantee under load.

The stress suite runs six scenarios against every store and six more through the middleware on the in-memory store and Redis, for 36 tests in total. Redis, PostgreSQL and SQL Server run in real containers:

  • Thundering herd. 128 callers claim one key at once, ten rounds, fresh key each round. Exactly one wins each time.
  • Mutual exclusion. Workers claim and release one key in a loop while a counter tracks how many hold it. The maximum has to stay at 1.
  • Racing fingerprints. Four payloads race for one key, and every loser has to report the same winning fingerprint, which catches half-written entries.
  • Read storm. Up to 2,000 reads of one stored response, each compared byte for byte.
  • Tampered herd. Half of a herd sends a changed amount under the same key. That half gets a 422, and the handler still runs once.

The release workflow runs these and won’t publish a package if they fail. A manual workflow reruns them at ten times the load.

That suite passed every test with the fencing race in place. It does check expired-claim takeover, and it asserts that exactly one caller wins, which was never broken. Nothing in it had the old holder wake up afterwards and write.

A stress test only breaks the invariants you thought to assert, and piling on load won’t reveal one you didn’t. Races like this need gates: a handler you can pause, held open past its TTL while a second request takes over, then released in each order you care about. It takes a handful of requests. The tests for the fencing bug read like the scenarios they cover:

[Fact]
public async Task Stale_holder_finishing_last_does_not_replace_the_replacement_response()
{
    var key = UniqueKey();
    var (holder, replacement) = await StartTakeoverAsync(key);

    Gate("B").TrySetResult(true);
    await replacement;

    Gate("A").TrySetResult(true);
    await holder;

    var retry = await _client!.SendAsync(Post("/gated", key, "B"));
    var body = await retry.Content.ReadAsStringAsync();

    body.Should().Contain("handled:B").And.NotContain("handled:A");
}

Its siblings cover the stale holder finishing first and the stale holder throwing. They live in the shared middleware contract, so they run against every store, alongside tests for client disconnects, oversized responses, long keys and case-sensitive keys.


What It Costs

These come from one GitHub Actions run on .NET 10 and a 4 vCPU runner, so treat them as rough and measure on your own hardware if the margin matters. End to end through a TestServer, with the in-memory store and a 57-byte JSON body:

Path Mean Allocated
No guard 13.78 µs 9.09 KB
Guard, new key 62.89 µs 26.77 KB
Guard, replayed response 51.96 µs 25.3 KB

So about 49 µs of added work on a first request and 38 µs on a replay. TestServer skips the socket, TLS and the network, which makes that baseline about as cheap as an HTTP request gets. Against a handler that talks to a database or a payment provider, those microseconds disappear. Against a 200 µs in-memory endpoint, they don’t.

The store is the bigger number. Single-claim latency at ten times the stress load, with each store in a container on the same machine:

Store Mixed load p50 p99 1,280 callers on one key p50 p99
Redis 7 0.97 ms 4.5 ms 17.5 ms 29 ms
PostgreSQL 16 4.3 ms 13.5 ms 454 ms 765 ms
SQL Server 2022 3.1 ms 7.9 ms 645 ms 1,016 ms

Mixed load is 10,000 claims across 2,500 keys from 16 workers. The right-hand columns are a deliberate worst case, with every caller released at the same instant on 4 vCPUs shared with the database and the test itself, so most of that time is queueing. Container-local stores also skip real network latency, so add your own round trip.

Fingerprinting is the one cost that scales with your payload, and it scales worse than you’d guess:

Body size Time Allocated
256 B 7.39 µs 5.56 KB
4 KB 83.4 µs 75.31 KB
64 KB 1.38 ms 1.16 MB

SHA-256 isn’t the problem. Parsing the body, rebuilding it as a sorted object graph and serializing it again is. For a 64 KB body, FingerprintProperties brings that down to about 255 µs and 1.2 KB of managed allocation. For payloads of a couple of KB or less, I wouldn’t bother.


What It Doesn’t Do

A library like this is only worth trusting if it’s upfront about where the guarantee stops.

Store failover is untested. The stress suite shows the guarantees hold on one machine against a single store node. It says nothing about a Redis failover mid-claim, a PostgreSQL primary promotion or a partition between your app and the store. A failover that loses a claim could let a retry through, which is one more reason to put a downstream key on anything with side effects.

Whole responses sit in memory. The size limit protects the store, not the process, because the check runs after the handler finishes. Responses also don’t reach the client until then, so streaming endpoints shouldn’t go through the guard.

Unmarked endpoints don’t require a key. Under the default UnmarkedEndpoints, a request without a key on an unmarked endpoint passes straight through. Mark every endpoint that moves money, which makes the key mandatory, or set RequireKey, or a client that forgets the header gets no protection and no warning.

It can’t fix a client that sends a new key on every retry. The guard treats a new key as a new request, whatever the payload. Your database has to enforce that an order is only paid once.

Retries can’t outlive the window. Once ResponseTtl passes, 24 hours by default, the same key counts as a new request. Your clients’ retry window has to be shorter than that.

Upgrading from 1.x needs care. Store keys changed, so a retry that spans the upgrade runs the handler again, and 1.x and 2.0 shouldn’t run side by side. The upgrade guide covers five things to check before you deploy. Going from 2.0 to 2.1 is gentler, but an invalid option value that used to be ignored now stops the app starting, and the IDEMP001 deprecation warnings fail any build that treats warnings as errors.


Try It

IdempotencyGuard is open source under MIT. Install the middleware and a store:

dotnet add package IdempotencyGuard.AspNetCore
dotnet add package IdempotencyGuard.Redis

The README has the full configuration and a quick start, and the stability and performance doc has the complete scenario list, the benchmark method and its limits. Source and issues are on GitHub.