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, 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 (an IEndpointFilter) wraps the handler:
- Before: read the
Idempotency-Keyheader. If present, build the cache keyidem:t:{tenant}:{key}(tenant from the caller’stenantclaim,"global"when absent) and probe the cache. - If cached: write the cached status + body, set an
Idempotency-Replayed: trueresponse header, and skip the handler entirely. - If not: invoke the handler, then store the serialized result + status code 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. Keys longer than MaxKeyLength are rejected with a 400 before the handler runs.
The probe reads through IDistributedCache directly (a real get-or-null, bypassing L1 — replays are rare so L1 warmth has little value); the write goes through HybridCache.SetAsync with tags (idempotency + the tenant tag) so tag-based purges work. Caching the response is best-effort: if the store write fails, the filter logs a warning and the request still succeeds.
Configuration
{ "IdempotencyOptions": { "HeaderName": "Idempotency-Key", // default "DefaultTtl": "1.00:00:00", // 24 hours (default) "MaxKeyLength": 128 // default }}Idempotency is on by default in FshPlatformOptions (EnableIdempotency = true); AddHeroPlatform binds the options. The replay store is 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.1Authorization: Bearer <token>tenant: acmeIdempotency-Key: 7f3d9a2c-1b8e-4f5a-9c0d-2e8f6b1a3d4cContent-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 (and the
Idempotency-Replayed: trueheader, so clients can tell). - 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. After
DefaultTtlexpires, a replayed request runs again. Set the TTL to whatever is realistic for your client retry strategy — 24 h is the default; longer 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.WebhookSubscriptiondeactivation — setsIsActive = false; idempotent.- Find-or-create DM channels —
DirectKeyuniqueness 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.
- The cache key does NOT include the route. Reusing one key across two different idempotent endpoints within the same tenant replays the first endpoint’s response on the second. Always generate a fresh key per logical request — never share keys across operations.
- Cache miss after restart is normal. If Valkey isn’t configured, the in-memory L2 starts empty after a restart — replayed requests run again. Always use Valkey in production.
Related
- Caching — the HybridCache that backs the replay store.
- HTTP resilience — caller-side retry policy that needs idempotency to be safe.
- Web building block — the filter implementation.