Idempotency Keys
Shiny.AspNet.Idempotency honours the Idempotency-Key request header on the endpoints that opt in: the
handler runs once, and every retry with the same key is answered with the stored original. It is trim and
Native AOT clean.
| GitHub | |
| Downloads |
dotnet add package Shiny.AspNet.Idempotencyusing Shiny.AspNet.Idempotency;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddIdempotency();
var app = builder.Build();app.UseAuthentication();app.UseAuthorization();app.UseIdempotency();
app.MapPost("/payments", Charge).RequireIdempotencyKey();app.Run();A phone on a train sends a POST. The request reaches the server and charges the card, and then the connection drops before the response gets back. The client cannot tell “never arrived” from “arrived and the reply was lost”, so it retries — and without a key, the retry charges again.
With one, the client sends the same Idempotency-Key on every attempt, the handler runs once, and every
retry is answered with the stored original. This is the header from the IETF draft
draft-ietf-httpapi-idempotency-key-header,
the one Stripe, Adyen and most payment APIs already use. ASP.NET Core has no built-in equivalent — output
caching only covers GET/HEAD and has no notion of a client-chosen key, an in-flight conflict or a reused key.
POST /payments HTTP/1.1Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"Content-Type: application/json
{"amount": 1000}The key is opaque — generate a UUID per logical operation, and reuse it only for retries of that operation. Both the draft’s quoted form and a bare value are accepted, and mean the same key.
What a request with a key gets
Section titled “What a request with a key gets”| The key… | Response |
|---|---|
| is new | The handler runs, and its response is stored |
| was used, and that request finished | The stored status, headers and body, plus Idempotent-Replayed: true |
| was used, and that request is still running | 409 problem details, with Retry-After: 1 |
| was used with a different body | 422 problem details — that is a client bug, and replaying the first answer would hide it |
| is missing on an endpoint that requires one | 400 problem details |
is empty or longer than MaxKeyLength |
400 problem details |
A 409 rather than waiting for the first request: running a second copy alongside it is the double charge this exists to prevent, and waiting would hold a connection open for as long as the first takes. Once it finishes, the same retry gets its response.
The request body is fingerprinted (SHA-256) to tell a retry from a reused key. Replayed headers are the
original’s minus the hop-by-hop ones, Date and Content-Length (which is set again from the stored body).
Problem responses go through Results.Problem, so if you call AddProblemDetails() your
IProblemDetailsService customisations apply to them too.
Opting endpoints in
Section titled “Opting endpoints in”Nothing is intercepted unless the endpoint asked. The conventions work on any IEndpointConventionBuilder
— a single endpoint, a RouteGroupBuilder, or MapControllers():
app.MapPost("/payments", Charge).RequireIdempotencyKey(); // no key → 400app.MapPost("/notes", Save).WithIdempotency(); // honoured if sentapp.MapPost("/transfers", Transfer).RequireIdempotencyKey(TimeSpan.FromHours(1)); // shorter expiryOn a group, every endpoint in it — nested groups included — and one endpoint can opt back out:
var payments = app.MapGroup("/api/payments").RequireIdempotencyKey();
payments.MapPost("/", Charge);payments.MapPost("/{id}/refund", Refund);payments.MapPost("/quote", Quote).DisableIdempotency();On MVC controllers the same thing is an attribute. On an action it replaces the controller’s settings:
[ApiController, Route("api/orders")][Idempotent] // Required = true by defaultpublic class OrdersController : ControllerBase{ [HttpPost] public IActionResult Place(Order order) => …;
[HttpPost("draft"), Idempotent(Required = false, ExpirationSeconds = 600)] public IActionResult Draft(Order order) => …;
[HttpPost("quote"), DisableIdempotency] public Quote Quote(Order order) => …;}The attributes are plain endpoint metadata, so .WithMetadata(new IdempotentAttribute()) works on a
minimal-API endpoint too. When an endpoint carries more than one setting the most specific wins
(group → endpoint, controller → action).
Only POST and PATCH are intercepted by default — GET, PUT and DELETE are idempotent already, so a
key on them is accepted and ignored. Change Methods to widen it.
Scope of a key
Section titled “Scope of a key”A key is scoped by method, path and caller — the authenticated user’s NameIdentifier (or name)
and authentication type, when there is one.
- By path, because the draft makes a key unique per resource: two endpoints may see the same key.
- By caller, because a key is chosen by a client, and two clients choosing the same one must never be able to read each other’s responses.
What is stored
Section titled “What is stored”- Responses with a status below 500. A 5xx is the server saying it failed, which is exactly the case
a retry should get to try again; a 4xx is a decision about the request, and replaying it keeps a
retry from getting a different answer. Change
ShouldStoreStatusCodeto narrow it. - Nothing, if the handler throws — the key is released and the retry runs.
- Nothing, if the response is larger than
MaxResponseBodyBytes. It is served, the key is released, a warning is logged, and a retry runs the handler again. Keep idempotent endpoints to small confirmations.
The response is copied as it is written, not held back: Response.WriteAsync, BodyWriter (JSON
results), SendFileAsync, StartAsync and flushes all go straight to the client, and the response is
framed exactly as the handler framed it. A replay always carries a Content-Length.
A request body larger than MaxRequestBodyBytes is refused with a 413 before any key is claimed:
the body has to be read in full to fingerprint it, and held so the handler can read it too. A body that
fails to read — a Content-Digest that does not verify, a client that went away — claims nothing either,
so the intact retry is not locked out.
Options
Section titled “Options”| Property | Default | Notes |
|---|---|---|
HeaderName |
Idempotency-Key |
|
ReplayedHeaderName |
Idempotent-Replayed |
Null adds nothing to a replay |
Expiration |
24 hours | How long a completed response is kept |
InFlightTimeout |
2 minutes | When an unfinished claim counts as abandoned — matters for a shared store |
Methods |
POST, PATCH | |
ShouldStoreStatusCode |
status < 500 |
|
MaxRequestBodyBytes |
1MB | Larger → 413 |
MaxResponseBodyBytes |
256KB | Larger → served, not stored |
MaxKeyLength |
255 |
AddIdempotency(o => …) can be called more than once; the configurations compose.
Where keys live
Section titled “Where keys live”MemoryIdempotencyStore (the default) keeps them in process, bounded to 4MB of response bodies and
10,000 records: expired records go first, then the oldest completed ones. A claim still in flight is
never evicted — dropping one would let a concurrent duplicate through.
To share keys between instances behind a load balancer, use the registered IDistributedCache:
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost");builder.Services.AddIdempotency().AddDistributedCacheIdempotencyStore();Your own store is registered before or instead of the default:
builder.Services.AddSingleton<IIdempotencyStore, RedisIdempotencyStore>();builder.Services.AddIdempotency();The one hard requirement is that TryReserveAsync is atomic: two concurrent requests with the same
key must not both be told they hold it.
Protocols
Section titled “Protocols”Everything above works the same over HTTP/1.1, HTTP/2 and HTTP/3 — including concurrent duplicates multiplexed on one HTTP/2 connection, which HTTP/1.1 cannot even produce.
It pairs with content digests: register UseContentDigest() first, and a body
that was truncated in transit is refused before it can claim a key.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


