Skip to main content
Version: Next

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.

Redis Plugin

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

SituationResult
No Idempotency-Key headerPass through; business logic runs
GET / HEAD with headerPass through; header ignored
First request with keyExecute; cache the response (24h)
Replay with same key + same bodyReplay cached response; X-Idempotent-Replayed: true
Replay with same key + different body422 idempotency_key_conflict
Concurrent same-key request (first still in-flight)409 idempotency_in_progress + Retry-After: 1
First response is 5xxNOT cached; retry re-executes
First response is 429NOT cached; client should honor Retry-After
First response is 2xx / 3xx / 4xx (other)Cached and replayed
Response body > 1 MiB500 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

MethodPriority
opt.IdempotencyStoreFactoryHighest (explicit opt-in)
services.AddSingleton<IIdempotencyStore, T>() before AddSharkMedium (plugin)
Default MemoryIdempotencyStoreLowest (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 without EnableBuffering upstream) will produce fingerprints over empty bytes, defeating the 422 check.