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;
});
GitHub → Sharkable.Cache.Redis
Configuration
services.AddSharkableRedis("localhost:6379", opt =>
{
opt.IdempotencyKeyPrefix = "myapp:idempotency:";
opt.Database = 1;
});
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";
// Retry-After header value (seconds). Default: 1.
o.RetryAfterSeconds = 1;
// Predicate for which status codes should be cached. Default: 2xx-4xx except 429.
o.ShouldCacheStatus = status => status >= 200 && status < 500 && status != 429;
});
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) |
Per-Endpoint Opt-In
You can opt individual endpoints into idempotency without setting EnableIdempotency = true globally. Apply [SharkIdempotent] on the ISharkEndpoint class:
[SharkIdempotent(ttlSeconds: 3600)]
public class PaymentEndpoint : ISharkEndpoint
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("charge", (ChargeRequest req) => Charge(req));
}
}
This requires ConfigureIdempotency() to be called (the middleware is registered when options are configured), but the global EnableIdempotency flag can remain false. Only endpoints decorated with [SharkIdempotent] are intercepted.
To explicitly exclude a streaming/SSE endpoint when the class is also decorated with [SharkIdempotent], use [SharkNoIdempotency] on the class to opt out.
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.
Streaming / SSE Endpoints
The idempotency middleware buffers the entire response body in memory for caching and replay. This silently breaks streaming endpoints (SSE, long-polling) — the client receives nothing until the stream ends.
To exclude a specific endpoint class from idempotency handling, apply [SharkNoIdempotency]:
[SharkNoIdempotency]
public class SseEndpoint : ISharkEndpoint
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("events", async (HttpContext ctx) =>
{
ctx.Response.ContentType = "text/event-stream";
// SSE streaming — idempotency middleware passes through without buffering
});
}
}
When the middleware sees [SharkNoIdempotency] on the endpoint's class, it releases the in-flight slot immediately and passes the request through without buffering. The Idempotency-Key header is ignored for that endpoint.
Limitations
- No streaming responses. Responses > 1 MiB are rejected with 500 and not cached. For streaming/SSE endpoints, use
[SharkNoIdempotency]to pass through without buffering. - 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.