Skip to content
fullstackhero

Concept

Idempotency

Idempotency-Key header support with HybridCache-backed replay protection across instances.

views 0 Last updated

Network is unreliable. A POST /orders request might submit, time out before the response arrives, and get retried. Without idempotency, the retry creates a second order. With it, the retry returns the same response as the first.

The kit ships an Idempotency-Key header convention. Callers send a fresh key with every request that mutates state; the kit caches the response (status + body) keyed on (tenant, route, key) for a configurable window. Duplicate requests within the window return the cached response without re-executing the handler.

Opt an endpoint in

The Web block ships a .WithIdempotency() extension on RouteHandlerBuilder:

endpoints.MapPost("/orders", handler)
.RequirePermission(perm)
.WithIdempotency();

That’s the whole opt-in. The kit’s IdempotencyEndpointFilter runs before the handler and after the response is generated:

  1. Before: read Idempotency-Key header. If present, build (tenant, route, key) cache key. Check the cache.
  2. If cached: return the cached response without invoking the handler.
  3. If not: invoke the handler. After the response is written, capture status code + body and store under the cache key with a TTL.

If no header is sent, the filter is a no-op — the endpoint behaves like an ordinary one.

Configuration

{
"IdempotencyOptions": {
"Enabled": true,
"RetentionWindow": "24:00:00",
"MaxBodyBytes": 65536
}
}

AddHeroPlatform(o => o.EnableIdempotency = true) wires the filter. The cache itself uses HybridCache, so the L2 (Valkey) backing means cached responses survive across instances. Without Valkey you get per-instance idempotency, which is OK for dev but not for multi-instance production.

What clients send

POST /api/v1/orders HTTP/1.1
Authorization: Bearer <token>
tenant: acme
Idempotency-Key: 7f3d9a2c-1b8e-4f5a-9c0d-2e8f6b1a3d4c
Content-Type: application/json
{ "items": [...] }

The key should be a fresh UUID per logical request:

  • A retry of the same request uses the same key — gets the cached response.
  • A different request (even from the same client) uses a fresh key — gets a fresh response.

Most HTTP client libraries can generate keys automatically; for HttpClientFactory consumers, a DelegatingHandler is a clean place to do it.

What it doesn’t do

  • It doesn’t dedupe content. If the client sends two requests with different keys and identical bodies, both create resources. Idempotency keys are per-request-attempt identifiers, not content hashes. (Use a content hash + lookup if you want content dedupe — but it’s a different feature.)
  • It doesn’t span the request boundary forever. The retention window is configurable; after it expires, a replayed request runs again. Set the window to whatever is realistic for your client retry strategy — 24 h is the default; 7 days is reasonable for batch / async flows.
  • It doesn’t catch partial failures. If the handler runs, mutates state, then crashes before the response is captured, the cache won’t have an entry. The retry runs again. Combine idempotency with idempotent domain operations for true safety.

Domain-level idempotency

The kit’s domain already has several naturally-idempotent operations:

  • Notification.MarkRead()ReadAtUtc ??= now; second call is a no-op.
  • Ticket.Reopen() — guarded against illegal source states; second call from a valid state is harmless.
  • WebhookSubscription deactivation — sets IsActive = false; idempotent.
  • Find-or-create DM channels — DirectKey uniqueness makes “find or create” race-safe.

Layer Idempotency-Key on top of these for full retry safety across both transport and domain failure modes.

Gotchas

  • The cache key includes tenant, so two tenants using the same idempotency key get separate responses. This is correct; tenants are independent.
  • MaxBodyBytes defaults to 64 KB. Responses larger than this aren’t cached (and a warning logs). For large responses, raise the cap or skip idempotency on that endpoint.
  • Cache miss after restart is normal. If you restart the host and Valkey isn’t running, the in-memory L2 starts empty — replayed requests run again. Always use Valkey in production.