Skip to content

Idempotency — Safe API Request Retries

Granit.Http.Idempotency provides Stripe-style HTTP idempotency middleware backed by Redis. Ensures that retried POST/PUT/PATCH requests produce the same response without re-executing side effects. Uses SHA-256 composite keys and AES-256-GCM encrypted entries.

[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
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 > MaxResponseSizeBytes?}
    EXECUTE -- 5xx / timeout --> RELEASE[DELETE key → Absent]
    SIZE -- Yes --> TOMB[SetCompleted\nstate = Tombstoned]
    SIZE -- No --> COMPLETE[SetCompleted\nstate = 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
CategoryKey typesPackage
ModuleGranitHttpIdempotencyModule
StoreIIdempotencyStore, IIdempotencyMetadata, IdempotencyEntry, IdempotencyStateGranit.Http.Idempotency
MetadataIdempotentAttributeGranit.Http.Idempotency
OptionsIdempotencyOptionsGranit.Http.Idempotency
ExtensionsAddGranitIdempotency(), UseGranitIdempotency()Granit.Http.Idempotency