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 populatedMarking endpoints as idempotent
Section titled “Marking endpoints as idempotent”app.MapPost("/api/v1/invoices", CreateInvoice) .WithMetadata(new IdempotentAttribute { Required = true });
// Optional key — middleware is bypassed when header is absentapp.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 });State machine
Section titled “State machine”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.
Composite key structure
Section titled “Composite key structure”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...
Payload hash verification
Section titled “Payload hash verification”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.
Response caching rules
Section titled “Response caching rules”| Status code | Cached? | Rationale |
|---|---|---|
| 2xx | Yes | Successful responses are replayed |
| 400, 404, 409, 410, 422 | Yes | Deterministic client errors |
| 401, 403 | No | Authentication state may change |
| 5xx | No | Lock is released for retry |
| 499 (client disconnect) | No | Response may be truncated |
Error responses
Section titled “Error responses”| Scenario | Status | Title |
|---|---|---|
| Missing header (required) | 422 | Missing Idempotency-Key |
| Header value too long | 400 | Idempotency-Key exceeds MaxKeyLength (rejected before hashing or cache lookup) |
| Multipart request | 422 | Unsupported Content-Type |
| Key in progress (another pod) | 409 | Request In Progress (Retry-After header set) |
| Race on completed entry mid-execute | 409 | Concurrent request fail-fast |
| Payload hash mismatch | 422 | Idempotency Key Conflict |
| Replay of tombstoned response | 413 | Original response exceeded replay size limit — retry with a new key |
| Execution timeout | 503 | Execution Timeout |
Replayed responses include an Idempotent-Replayed: true header.
Replay-header filter
Section titled “Replay-header filter”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 default | Why |
|---|---|
Set-Cookie, Set-Cookie2 | Session rotation |
WWW-Authenticate, Proxy-Authenticate | Auth challenges |
Authorization | Echoed credentials |
Server, Date, Transfer-Encoding | Per-response transport headers |
Add custom entries through configuration:
services.Configure<IdempotencyOptions>(opts => opts.ExcludedResponseHeaders.Add("X-Custom-Session-Token"));Configuration reference
Section titled “Configuration reference”| Property | Default | Description |
|---|---|---|
HeaderName | "Idempotency-Key" | HTTP header name |
KeyPrefix | "idp" | Redis key prefix |
CompletedTtl | 24:00:00 | TTL for completed entries |
TombstoneTtl | 24:00:00 | TTL for tombstoned (oversized) entries — matched to CompletedTtl so retries within the normal window see a deterministic 413 |
InProgressTtl | 00:00:30 | Lock TTL (must be > ExecutionTimeout) |
ExecutionTimeout | 00:00:25 | Max handler execution time |
MaxBodySizeBytes | 1048576 | Max request body size to hash (1 MiB) |
MaxResponseSizeBytes | 262144 | Max response body stored for replay (256 KiB) — over this, entry is tombstoned |
MaxKeyLength | 256 | Max length of the client-supplied header value (rejected with 400 above this) |
ShouldCacheStatusCode | 2xx + 422 | Predicate controlling which status codes get cached |
ExcludedResponseHeaders | See above | Headers filtered on capture + replay |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitHttpIdempotencyModule | — |
| Store | IIdempotencyStore, IIdempotencyMetadata, IdempotencyEntry, IdempotencyState | Granit.Http.Idempotency |
| Metadata | IdempotentAttribute | Granit.Http.Idempotency |
| Options | IdempotencyOptions | Granit.Http.Idempotency |
| Extensions | AddGranitIdempotency(), UseGranitIdempotency() | Granit.Http.Idempotency |
See also
Section titled “See also”- Caching module —
ICacheValueEncryptorused by idempotency store - API & Http overview — All HTTP infrastructure packages
- Blog: Idempotency keys: why every POST should be retry-safe — the rationale and patterns behind this module’s design