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, 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:

  1. Before: read the Idempotency-Key header. If present, build the cache key idem:t:{tenant}:{key} (tenant from the caller’s tenant claim, "global" when absent) and probe the cache.
  2. If cached: write the cached status + body, set an Idempotency-Replayed: true response header, and skip the handler entirely.
  3. 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.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 (and the Idempotency-Replayed: true header, 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 DefaultTtl expires, 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.
  • 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.
  • 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.