Skip to content

Idempotency

Granit.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-CBC encrypted entries.

[DependsOn(typeof(GranitIdempotencyModule))]
public class AppModule : GranitModule { }
{
"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\nX-Idempotency-Replayed: true]
    ACQUIRE --> EXECUTE[Execute handler]
    EXECUTE -- 2xx / cacheable 4xx --> COMPLETE[SetCompleted\nSET XX PX → Completed]
    EXECUTE -- 5xx / timeout --> RELEASE[DELETE key → Absent]
    COMPLETE --> RESPONSE[Return response]
    REPLAY --> RESPONSE
    COMPLETE -- TTL expires --> ABSENT

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
Multipart request422Unsupported Content-Type
Key in progress (another pod)409Request In Progress
Payload hash mismatch422Idempotency Key Conflict
Execution timeout503Execution Timeout

Replayed responses include an X-Idempotency-Replayed: true header.

PropertyDefaultDescription
HeaderName"Idempotency-Key"HTTP header name
KeyPrefix"idp"Redis key prefix
CompletedTtl24:00:00TTL for completed entries
InProgressTtl00:00:30Lock TTL (must be > ExecutionTimeout)
ExecutionTimeout00:00:25Max handler execution time
MaxBodySizeBytes1048576Max request body size to hash (1 MiB)
CategoryKey typesPackage
ModuleGranitIdempotencyModule
StoreIIdempotencyStore, IIdempotencyMetadata, IdempotencyEntry, IdempotencyStateGranit.Idempotency
MetadataIdempotentAttributeGranit.Idempotency
OptionsIdempotencyOptionsGranit.Idempotency
ExtensionsAddGranitIdempotency(), UseGranitIdempotency()Granit.Idempotency