Skip to content

Configure Idempotency

Granit.Idempotency provides Stripe-style HTTP idempotency for your API endpoints. When a client retries a request (network failure, timeout, double-click), the middleware returns the original response without re-executing the business logic. State is managed in Redis with atomic locks and AES-256-CBC encryption.

  • A working Granit application
  • Redis instance (used for idempotency state storage)
  • Granit.Caching configured (provides ICacheValueEncryptor for AES encryption)
Terminal window
dotnet add package Granit.Idempotency
using Granit.Core.Modularity;
using Granit.Idempotency;
[DependsOn(typeof(GranitIdempotencyModule))]
public sealed class MyAppModule : GranitModule { }

The module reads the Idempotency configuration section and registers all required services automatically.

Register the middleware in your ASP.NET Core pipeline after authentication and authorization — the middleware needs ICurrentUserService to be populated:

app.UseAuthentication();
app.UseAuthorization();
app.UseGranitIdempotency();
app.MapControllers();
{
"Idempotency": {
"HeaderName": "Idempotency-Key",
"KeyPrefix": "idp",
"CompletedTtl": "24:00:00",
"InProgressTtl": "00:00:30",
"ExecutionTimeout": "00:00:25",
"MaxBodySizeBytes": 1048576
}
}
OptionDefaultDescription
HeaderNameIdempotency-KeyHTTP header name
KeyPrefixidpRedis key prefix
CompletedTtl24 hoursHow long completed responses are cached
InProgressTtl30 secondsLock duration while request is processing
ExecutionTimeout25 secondsTimeout for the business handler
MaxBodySizeBytes1,048,576Max body size read for hash computation
app.MapPost("/payments", CreatePaymentAsync)
.WithMetadata(new IdempotentAttribute())
.WithName("CreatePayment");

Only endpoints decorated with [Idempotent] activate the middleware. All other endpoints pass through unaffected.

Clients include a unique idempotency key (UUID v4 recommended) in the request header:

POST /api/v1/payments HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"amount": 100, "currency": "EUR"}
ScenarioResponseHeader
First request (lock acquired)Normal business response
Retry with same body (response cached)Original response replayedX-Idempotency-Replayed: true
Concurrent request (execution in progress)409 ConflictRetry-After: 30
Retry with different body422 Unprocessable Entity
Execution timeout503 Service Unavailable
Server error (5xx)Lock released, client can retry

The middleware implements a three-state machine backed by Redis:

Absent --(SET NX PX)--> InProgress --(SET XX PX)--> Completed
|
+--(5xx / timeout)--> Absent
  1. Acquire lock: SET NX PX creates the key only if absent (atomic).
  2. Execute handler: The business logic runs with a CancellationToken that expires at ExecutionTimeout.
  3. Store response: On success, the response (status code, headers, body) is encrypted with AES-256-CBC and stored with CompletedTtl.
  4. On failure: 5xx responses or exceptions release the lock so the client can retry.
{KeyPrefix}:{tenantId}:{userId}:{METHOD}:{routePattern}:{sha256(idempotencyKey)}

The idempotency key value is hashed with SHA-256 before inclusion in the Redis key, preventing injection of special characters.

The middleware computes a SHA-256 hash over METHOD + routePattern + idempotencyKey + body. If a retry uses the same idempotency key but a different body, the hash mismatch triggers a 422 Unprocessable Entity response.

CodesBehavior
2xx, 400, 404, 409, 410, 422Cached and replayed
401, 403Never cached (permissions may change)
5xxLock released, client can retry
  • Tenant isolation: The Redis key includes tenantId + userId, preventing cross-tenant response replay even with identical idempotency keys.
  • Encryption: Cached responses are encrypted with AES-256-CBC via ICacheValueEncryptor (provided by Granit.Caching). This is mandatory for ISO 27001 compliance when response bodies contain health data.
  • No side-channel: The idempotency key is hashed before storage, preventing Redis key enumeration attacks.