Skip to content

Idempotency — Safe API Request Retries

Granit.Http.Idempotency provides Stripe-style HTTP idempotency middleware backed by its own first-class store contract, IIdempotencyStore. Ensures that retried POST/PUT/PATCH requests produce the same response without re-executing side effects. Uses SHA-256 composite keys; the Redis provider encrypts entries at rest with AES-256-GCM.

The [Idempotent] attribute and the IIdempotencyMetadata contract ship in a dedicated contracts package, Granit.Http.Idempotency.Abstractions (namespace Granit.Http.Idempotency). *.Endpoints packages that mark routes as idempotent reference the contracts only — never the middleware package.

[DependsOn(typeof(GranitHttpIdempotencyModule))]
public class AppModule : GranitModule { }
{
"Http:Idempotency": {
"HeaderName": "Idempotency-Key",
"KeyPrefix": "idp",
"CompletedTtl": "24:00:00",
"InProgressTtl": "00:00:30",
"ExecutionTimeout": "00:00:25",
"MaxBodySizeBytes": 1048576
}
}

In Program.cs (after authentication/authorization):

app.UseAuthentication();
app.UseAuthorization();
app.UseGranitIdempotency(); // After auth so ICurrentUserService is populated

The middleware persists its state machine through IIdempotencyStore — a first-class contract with atomic transition semantics, owned by the package itself (the former IConditionalCache / Granit.Caching dependency is gone):

public interface IIdempotencyStore
{
bool IsDistributed { get; }
string BackendName { get; }
// Create-if-absent — the lock acquisition (Redis SET NX PX)
Task<bool> TryAcquireAsync(string key, IdempotencyEntry entry, TimeSpan ttl, CancellationToken cancellationToken);
Task<IdempotencyEntry?> GetAsync(string key, CancellationToken cancellationToken);
// Set-if-present (SET XX PX) — never resurrect an expired key
Task<bool> CompleteAsync(string key, IdempotencyEntry entry, TimeSpan ttl, CancellationToken cancellationToken);
Task<bool> TombstoneAsync(string key, IdempotencyEntry entry, TimeSpan ttl, CancellationToken cancellationToken);
Task DeleteAsync(string key, CancellationToken cancellationToken);
}

Two implementations ship:

StorePackageIsDistributedUse
InMemoryIdempotencyStoreGranit.Http.Idempotency (default)falseDevelopment, single-replica hosts
RedisIdempotencyStoreGranit.Http.Idempotency.StackExchangeRedistrueProduction

Custom stores must not re-namespace keys by tenant — the middleware already partitions the composite key (see below).

Fail-loud guard: outside Development, the middleware refuses to start when the registered store reports IsDistributed = false. An in-memory idempotency store is per-replica: with more than one pod, a retry landing on another pod re-executes the handler — double payment, double email. If a single-replica deployment genuinely wants the in-memory store, opt in explicitly:

{ "Http:Idempotency": { "AllowInMemoryStore": true } }

Granit.Http.Idempotency.StackExchangeRedis replaces the default store with RedisIdempotencyStore. Transitions map to single atomic Redis commands (SET NX PX to acquire, SET XX PX to complete/tombstone — no Lua). It reuses an existing IConnectionMultiplexer when the host already registered one (e.g. via Granit.Caching.StackExchangeRedis).

[DependsOn(typeof(GranitHttpIdempotencyStackExchangeRedisModule))]
public class AppModule : GranitModule { }
{
"Http:Idempotency:Redis": {
"Configuration": "redis.internal:6380",
"InstanceName": "dd:",
"RequireTls": true
},
"Cache:Encryption:Key": "BASE64_ENCODED_32_BYTE_KEY"
}
PropertyDefaultDescription
IsEnabledtrueToggle the module
Configuration"localhost:6379"StackExchange.Redis configuration string
InstanceName"dd:"Application-scoped key prefix
RequireTlstrueForce Ssl = true on the connection

Unconditional at-rest encryption. Stored entries (status code, headers, response body) are always encrypted with AES-256-GCM via ICacheValueEncryptor. The base64 256-bit key comes from Cache:Encryption:Key — the same key Granit.Caching uses. Outside Development, startup fails closed when the key is missing (RedisIdempotencyEncryptionStartupValidator, no opt-out). In Development a null encryptor passes plaintext through with a warning.

Health check: AddGranitRedisIdempotencyHealthCheck() registers a redis-idempotency check (PING + latency, tags readiness/startup, degraded above 100 ms by default).

app.MapPost("/api/v1/invoices", CreateInvoice)
.WithMetadata(new IdempotentAttribute { Required = true });
// Optional key — middleware is bypassed when header is absent
app.MapPut("/api/v1/invoices/{id}", UpdateInvoice)
.WithMetadata(new IdempotentAttribute { Required = false });
// Custom TTL (2 hours instead of default 24h)
app.MapPost("/api/v1/payments", ProcessPayment)
.WithMetadata(new IdempotentAttribute { CompletedTtlSeconds = 7200 });
flowchart TD
    START([Request arrives]) --> ABSENT{Key absent?}
    ABSENT -- Yes --> ACQUIRE[TryAcquire\nSET NX PX → InProgress]
    ABSENT -- No: InProgress --> CONFLICT[409 Request In Progress]
    ABSENT -- No: Completed --> REPLAY[Replay cached response\nIdempotent-Replayed: true]
    ACQUIRE --> EXECUTE[Execute handler]
    EXECUTE -- 2xx / cacheable 4xx --> SIZE{Response &gt; MaxResponseSizeBytes?}
    EXECUTE -- 5xx / timeout --> RELEASE[DELETE key → Absent]
    SIZE -- Yes --> TOMB[Tombstone\nSET XX PX → Tombstoned]
    SIZE -- No --> COMPLETE[Complete\nSET XX PX → Completed]
    COMPLETE --> RESPONSE[Return response]
    TOMB --> RESPONSE
    REPLAY --> RESPONSE
    ABSENT -- Tombstoned --> R413[413 Payload Too Large\nUse a new key]
    COMPLETE -- TTL expires --> ABSENT

A Tombstoned entry means “this request executed successfully but the response is too large to replay safely”. The middleware still streams the full response to the original caller; later retries with the same key get 413 Payload Too Large — preserving the at-most-once guarantee without paying the storage cost of a giant cached payload.

The Redis key is partitioned by tenant, user, HTTP method, and route to prevent cross-user key collisions:

{prefix}:{tenantId|global}:{userId|anon}:{method}:{routePattern}:{sha256(idempotencyKeyValue)}

Example: idp:global:d4e5f6a7:POST:/api/v1/invoices:a1b2c3d4e5f6...

The middleware computes a SHA-256 digest of the composite input (method + route + idempotency key value + request body). On replay, if the payload hash does not match the stored entry, the request is rejected with 422 to prevent key reuse with a different body.

Status codeCached?Rationale
2xxYesSuccessful responses are replayed
400, 404, 409, 410, 422YesDeterministic client errors
401, 403NoAuthentication state may change
5xxNoLock is released for retry
499 (client disconnect)NoResponse may be truncated
ScenarioStatusTitle
Missing header (required)422Missing Idempotency-Key
Header value too long400Idempotency-Key exceeds MaxKeyLength (rejected before hashing or cache lookup)
Multipart request422Unsupported Content-Type
Key in progress (another pod)409Request In Progress (Retry-After header set)
Race on completed entry mid-execute409Concurrent request fail-fast
Payload hash mismatch422Idempotency Key Conflict
Replay of tombstoned response413Original response exceeded replay size limit — retry with a new key
Execution timeout503Execution Timeout

Replayed responses include an Idempotent-Replayed: true header.

Caching a response that contains per-request security artefacts (rotating session cookies, CSRF tokens, WWW-Authenticate challenges) and replaying it to a later retry would re-issue stale credentials to a new caller. The middleware filters a default deny-list both on capture (entry stored without these headers) and on replay (defence in depth):

Excluded by defaultWhy
Set-Cookie, Set-Cookie2Session rotation
WWW-Authenticate, Proxy-AuthenticateAuth challenges
AuthorizationEchoed credentials
Server, Date, Transfer-EncodingPer-response transport headers

Add custom entries through configuration:

services.Configure<IdempotencyOptions>(opts =>
opts.ExcludedResponseHeaders.Add("X-Custom-Session-Token"));
PropertyDefaultDescription
HeaderName"Idempotency-Key"HTTP header name
KeyPrefix"idp"Redis key prefix
CompletedTtl24:00:00TTL for completed entries
TombstoneTtl24:00:00TTL for tombstoned (oversized) entries — matched to CompletedTtl so retries within the normal window see a deterministic 413
InProgressTtl00:00:30Lock TTL (must be > ExecutionTimeout)
ExecutionTimeout00:00:25Max handler execution time
MaxBodySizeBytes1048576Max request body size to hash (1 MiB)
MaxResponseSizeBytes262144Max response body stored for replay (256 KiB) — over this, entry is tombstoned
MaxKeyLength256Max length of the client-supplied header value (rejected with 400 above this)
ShouldCacheStatusCode2xx + 422Predicate controlling which status codes get cached
ExcludedResponseHeadersSee aboveHeaders filtered on capture + replay
AllowInMemoryStorefalseAllow a non-distributed IIdempotencyStore outside Development (per-replica store — see above)
CategoryKey typesPackage
ModulesGranitHttpIdempotencyModule, GranitHttpIdempotencyStackExchangeRedisModule
ContractsIdempotentAttribute, IIdempotencyMetadata (namespace Granit.Http.Idempotency)Granit.Http.Idempotency.Abstractions
StoreIIdempotencyStore, IdempotencyEntry, IdempotencyState, IdempotencyTombstoneReasonGranit.Http.Idempotency
OptionsIdempotencyOptions (section Http:Idempotency)Granit.Http.Idempotency
Options (Redis)RedisIdempotencyOptions (section Http:Idempotency:Redis)Granit.Http.Idempotency.StackExchangeRedis
ExtensionsAddGranitIdempotency(), UseGranitIdempotency()Granit.Http.Idempotency
Extensions (Redis)AddGranitRedisIdempotency(), AddGranitRedisIdempotencyHealthCheck()Granit.Http.Idempotency.StackExchangeRedis