Idempotency
Sharkable provides an opt-in middleware that lets clients safely retry non-idempotent HTTP requests (POST / PUT / PATCH / DELETE) without risk of duplicate execution. The first response is cached and replayed for subsequent requests carrying the same Idempotency-Key header.
Quick Start
builder.Services.AddShark([typeof(Program).Assembly], opt =>
{
opt.EnableIdempotency = true;
});
var app = builder.Build();
app.UseShark();
Clients now send an Idempotency-Key header on unsafe requests:
POST /api/payments
Idempotency-Key: 8c0a6f4e-9b2d-4f1a-b3c7-2e5d8a1f0b6c
Content-Type: application/json
{ "amount": 100, "userId": 42 }
A retry with the same key replays the original response (with header X-Idempotent-Replayed: true). A retry with a different payload returns 422. A concurrent request with the same key returns 409 with Retry-After: 1.
Configuration
opt.ConfigureIdempotency(o =>
{
// How long completed responses are kept. Default 24h.
o.Ttl = TimeSpan.FromHours(24);
// Auto-eviction for in-flight placeholders. Default 30s.
// Protects against permanent deadlocks if a process crashes mid-request.
o.InFlightTtl = TimeSpan.FromSeconds(30);
// Max key length. Default 255 (IETF draft max).
o.MaxKeyLength = 255;
// Responses larger than this are rejected with 500 and not cached.
// Default 1 MiB.
o.MaxResponseSize = 1_048_576;
// Methods that activate the middleware when the header is present.
o.UnsafeMethods = new HashSet<HttpMethod>
{
HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch, HttpMethod.Delete
};
// Header names (rarely changed).
o.HeaderName = "Idempotency-Key";
o.ReplayedHeaderName = "X-Idempotent-Replayed";
});
Behavior
| Situation | Result |
|---|---|
No Idempotency-Key header | Pass through; business logic runs |
GET / HEAD with header | Pass through; header ignored |
| First request with key | Execute; cache the response (24h) |
| Replay with same key + same body | Replay cached response; X-Idempotent-Replayed: true |
| Replay with same key + different body | 422 idempotency_key_conflict |
| Concurrent same-key request (first still in-flight) | 409 idempotency_in_progress + Retry-After: 1 |
| First response is 5xx | NOT cached; retry re-executes |
| First response is 429 | NOT cached; client should honor Retry-After |
| First response is 2xx / 3xx / 4xx (other) | Cached and replayed |
| Response body > 1 MiB | 500 idempotency_response_too_large; in-flight released |
| Malformed key (empty, > 255 chars, control chars) | 400 invalid_idempotency_key |
The "different body" check uses a SHA-256 fingerprint over method + "\n" + path + "\n" + body. The first request's fingerprint is stored alongside the response and compared on every replay.
Generating Keys on the Client
The Idempotency-Key is always generated by the client before sending the request — never issued by the server. The recommended pattern:
- Generate a UUID (v4) at the moment the user takes a business action ("click Pay" button), not at the HTTP call site
- Persist it to UI state (React state, button state, etc.) so retries reuse the same value
- Reuse the same key for all retries of that one logical operation
- Use a different key for each new logical operation, even if the user repeats the action
// React example
const handlePay = () => {
const idempotencyKey = useRef(crypto.randomUUID()).current; // generated once
fetch('/api/payments', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
};
Error Response Shape
All idempotency errors follow the framework's standard UnifiedResult<T> envelope. The machine-readable code is embedded in errorMessage as a [code] prefix:
{
"statusCode": 409,
"data": null,
"errorMessage": "[idempotency_in_progress] An identical request is already in progress; retry after 1 second.",
"extra": null,
"timeStamp": 1750934400000
}
Clients can route on statusCode and inspect the bracketed prefix in errorMessage to distinguish the failure mode.
AOT Support
The middleware is AOT-safe. No reflection, no Create() factories on user types, no dynamic. Sharkable.AotSample exercises the feature end-to-end at build time.
Limitations
- Single-instance only. The in-memory store is per-process. Multi-instance deployments must plug in a distributed
IIdempotencyStoreimplementation (Redis etc.) — not provided in v1. - No streaming responses. Responses > 1 MiB are rejected with 500 and not cached.
- Request body fingerprinting. The fingerprint over the request body requires the body to be readable when the middleware runs. Endpoints that have already consumed the body (e.g.,
[FromBody]model binding withoutEnableBufferingupstream) will produce fingerprints over empty bytes, defeating the 422 check.