Skip to main content
Version: 0.5.x

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)

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 without EnableBuffering upstream) will produce fingerprints over empty bytes, defeating the 422 check.