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.
Prerequisites
Section titled “Prerequisites”- A working Granit application
- Redis instance (used for idempotency state storage)
Granit.Cachingconfigured (providesICacheValueEncryptorfor AES encryption)
1. Install the package
Section titled “1. Install the package”dotnet add package Granit.Idempotency2. Register the module
Section titled “2. Register the module”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.
3. Add the middleware
Section titled “3. Add the middleware”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();4. Configure options
Section titled “4. Configure options”{ "Idempotency": { "HeaderName": "Idempotency-Key", "KeyPrefix": "idp", "CompletedTtl": "24:00:00", "InProgressTtl": "00:00:30", "ExecutionTimeout": "00:00:25", "MaxBodySizeBytes": 1048576 }}| Option | Default | Description |
|---|---|---|
HeaderName | Idempotency-Key | HTTP header name |
KeyPrefix | idp | Redis key prefix |
CompletedTtl | 24 hours | How long completed responses are cached |
InProgressTtl | 30 seconds | Lock duration while request is processing |
ExecutionTimeout | 25 seconds | Timeout for the business handler |
MaxBodySizeBytes | 1,048,576 | Max body size read for hash computation |
5. Mark endpoints as idempotent
Section titled “5. Mark endpoints as idempotent”app.MapPost("/payments", CreatePaymentAsync) .WithMetadata(new IdempotentAttribute()) .WithName("CreatePayment");[HttpPost("payments")][Idempotent]public async Task<IActionResult> CreatePaymentAsync( [FromBody] CreatePaymentRequest request, CancellationToken cancellationToken){ var payment = await paymentService.CreateAsync(request, cancellationToken); return CreatedAtAction(nameof(GetPayment), new { id = payment.Id }, payment);}Only endpoints decorated with [Idempotent] activate the middleware. All other
endpoints pass through unaffected.
6. Client-side usage
Section titled “6. Client-side usage”Clients include a unique idempotency key (UUID v4 recommended) in the request header:
POST /api/v1/payments HTTP/1.1Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000Content-Type: application/json
{"amount": 100, "currency": "EUR"}Response behavior
Section titled “Response behavior”| Scenario | Response | Header |
|---|---|---|
| First request (lock acquired) | Normal business response | — |
| Retry with same body (response cached) | Original response replayed | X-Idempotency-Replayed: true |
| Concurrent request (execution in progress) | 409 Conflict | Retry-After: 30 |
| Retry with different body | 422 Unprocessable Entity | — |
| Execution timeout | 503 Service Unavailable | — |
| Server error (5xx) | Lock released, client can retry | — |
How it works
Section titled “How it works”The middleware implements a three-state machine backed by Redis:
Absent --(SET NX PX)--> InProgress --(SET XX PX)--> Completed | +--(5xx / timeout)--> Absent- Acquire lock:
SET NX PXcreates the key only if absent (atomic). - Execute handler: The business logic runs with a
CancellationTokenthat expires atExecutionTimeout. - Store response: On success, the response (status code, headers, body) is
encrypted with AES-256-CBC and stored with
CompletedTtl. - On failure: 5xx responses or exceptions release the lock so the client can retry.
Redis key structure
Section titled “Redis key structure”{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.
Payload hash validation
Section titled “Payload hash validation”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.
Cached status codes
Section titled “Cached status codes”| Codes | Behavior |
|---|---|
| 2xx, 400, 404, 409, 410, 422 | Cached and replayed |
| 401, 403 | Never cached (permissions may change) |
| 5xx | Lock released, client can retry |
Security
Section titled “Security”- 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 byGranit.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.
Next steps
Section titled “Next steps”- Granit.Caching concept for distributed cache and encryption setup
- Set up notifications to notify users of completed operations
- Granit.Idempotency reference for the full configuration and service registration details