Skip to main content
Version: 0.3.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.

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

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.

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