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.
For multi-instance deployments, a ready-to-use Redis-backed store is available:
dotnet add package Sharkable.Cache.Redis
builder.Services.AddSharkableRedis("localhost:6379");
builder.Services.AddShark(opt =>
{
opt.EnableIdempotency = true;
});
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.
Distributed Store
The IIdempotencyStore interface enables Redis, PostgreSQL, or any KV store as the idempotency backend.
Built-in: MemoryIdempotencyStore
The default MemoryIdempotencyStore uses IMemoryCache and is suitable for single-instance deployments.
Custom Store via Factory
Plug in a Redis-backed store inside the AddShark() callback:
builder.Services.AddShark(opt =>
{
opt.IdempotencyStoreFactory = sp =>
{
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisIdempotencyStore(multiplexer);
};
opt.EnableIdempotency = true;
});
Custom Store via Plugin (NuGet)
Register your implementation before AddShark(). The TryAddSingleton pattern ensures your implementation takes precedence:
services.AddSingleton<IIdempotencyStore, MyCustomStore>();
builder.Services.AddShark(opt =>
{
opt.EnableIdempotency = true;
});
Priority
| Method | Priority |
|---|---|
opt.IdempotencyStoreFactory | Highest (explicit opt-in) |
services.AddSingleton<IIdempotencyStore, T>() before AddShark | Medium (plugin) |
Default MemoryIdempotencyStore | Lowest (fallback) |
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
- 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.