Configure Idempotency — Safe Retries
Granit.Http.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-GCM authenticated encryption.
Prerequisites
Section titled “Prerequisites”- A working Granit application
- A Redis instance for production deployments (the in-memory default store is Development / single-replica only)
1. Install the packages
Section titled “1. Install the packages”dotnet add package Granit.Http.Idempotencydotnet add package Granit.Http.Idempotency.StackExchangeRedis # production store2. Register the modules
Section titled “2. Register the modules”using Granit.Modularity;using Granit.Http.Idempotency;using Granit.Http.Idempotency.StackExchangeRedis;
[DependsOn(typeof(GranitHttpIdempotencyStackExchangeRedisModule))]public sealed class MyAppModule : GranitModule { }The Redis module depends on GranitHttpIdempotencyModule and replaces the
default InMemoryIdempotencyStore with RedisIdempotencyStore — the
IIdempotencyStore implementation whose transitions are single atomic Redis
commands (SET NX PX / SET XX PX). Hosts that only need the in-memory store
(Development, single replica) depend on GranitHttpIdempotencyModule alone.
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”{ "Http:Idempotency": { "HeaderName": "Idempotency-Key", "KeyPrefix": "idp", "CompletedTtl": "24:00:00", "InProgressTtl": "00:00:30", "ExecutionTimeout": "00:00:25", "MaxBodySizeBytes": 1048576 }, "Http:Idempotency:Redis": { "Configuration": "redis.internal:6380", "InstanceName": "dd:", "RequireTls": true }, "Cache:Encryption:Key": "BASE64_ENCODED_32_BYTE_KEY"}| 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 |
MaxKeyLength | 256 | Maximum accepted length of the Idempotency-Key header value |
MaxResponseSizeBytes | 262,144 (256 KiB) | Responses larger than this are tombstoned — replays receive 413 Payload Too Large |
TombstoneTtl | 24 hours | How long a tombstone is retained before the key can be reused |
ExcludedResponseHeaders | Set-Cookie, Authorization, … | Per-replay header allow-list applied on top of the captured response |
AllowInMemoryStore | false | Allow a non-distributed store outside Development — see below |
The Redis provider adds its own section, Http:Idempotency:Redis:
| Option | Default | Description |
|---|---|---|
IsEnabled | true | Toggle the Redis store module |
Configuration | localhost:6379 | StackExchange.Redis configuration string |
InstanceName | dd: | Application-scoped key prefix |
RequireTls | true | Force TLS on the Redis connection |
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 | Idempotent-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 | +--(response > MaxResponseSizeBytes)--> Tombstoned
Tombstoned --(replay)--> 413 Payload Too Large (until TombstoneTtl elapses) | +--(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-GCM 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: With the Redis provider, cached responses are encrypted with
AES-256-GCM via
ICacheValueEncryptor— unconditionally, using theCache:Encryption:Key. 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.
Verify
Section titled “Verify”Test idempotency by sending the same request twice with the same key:
# First request — creates the resourcecurl -s -X POST http://localhost:5000/api/v1/orders \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-abc-123" \ -d '{"product": "Widget", "quantity": 1}' | jq .id# → "d4e5f6a7-..."
# Second request with SAME key — returns cached response, no duplicatecurl -s -X POST http://localhost:5000/api/v1/orders \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-abc-123" \ -d '{"product": "Widget", "quantity": 1}' | jq .id# → "d4e5f6a7-..." (same ID)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.Http.Idempotency reference for the full configuration and service registration details