CSharpEssentials.Idempotency
7.0.0
dotnet add package CSharpEssentials.Idempotency --version 7.0.0
NuGet\Install-Package CSharpEssentials.Idempotency -Version 7.0.0
<PackageReference Include="CSharpEssentials.Idempotency" Version="7.0.0" />
<PackageVersion Include="CSharpEssentials.Idempotency" Version="7.0.0" />
<PackageReference Include="CSharpEssentials.Idempotency" />
paket add CSharpEssentials.Idempotency --version 7.0.0
#r "nuget: CSharpEssentials.Idempotency, 7.0.0"
#:package CSharpEssentials.Idempotency@7.0.0
#addin nuget:?package=CSharpEssentials.Idempotency&version=7.0.0
#tool nuget:?package=CSharpEssentials.Idempotency&version=7.0.0
CSharpEssentials.Idempotency
Idempotency building blocks that do not depend on a transport: a record store, scoped record keys, scope resolvers and
deterministic keys built from business ids, plus an executor that runs an operation at most once on top of them.
CSharpEssentials.AspNetCore uses them for its HTTP middleware, CSharpEssentials.Mediator for its idempotency
behavior; message consumers, background jobs and other pipelines can use them directly.
Features
IIdempotencyExecutor: reserves a key, runs the operation once, stores its result and replays it to a retry; answersExecuted,Replayed,InFlightorKeyReused. A codec (IIdempotencyResultCodec<T>, orIdempotencyResultCodec.Create) serializes the result and classifies it asStore,StoreAsFailure(kept for the shorterFailureRetention) orRelease. Registered byAddIdempotencyExecutor.IdempotencyInbox: processes each message at most once per consumer over the executor, with a 7-daymessagingprofile.- Modes:
Off,ObserveandEnforce, globally, per profile or per operation, so a new idempotent operation can be rolled out inObservefirst. IIdempotencyRecordStore:TryReserveAsync,CompleteAsyncandReleaseAsyncwith a reservation token, so a request that outlived its lease cannot overwrite or remove the entry of a later retry. Payloads are opaque bytes.- Stores:
InMemoryIdempotencyRecordStore(atomic within one process) andDistributedCacheIdempotencyRecordStore(anyIDistributedCache; best-effort, not atomic).CSharpEssentials.EntityFrameworkCoreadds a transactional store on yourDbContext. IdempotencyRecordKey: namespace, scope and key.ToStorageKey()givesidem:v3:{n}:{namespace}|{n}:{scope}|{n}:{key}with UTF-8 byte lengths, andglobalas the scope of a global key, so two different keys never share a storage key. The constructor takes anIdempotencyScope?;nullorIdempotencyScope.Globalgives a global key.- Scopes:
IdempotencyScopes.User,Tenant(claim),UserAndTenant(claim),Client,Custom(kind, resolve)andGlobal. The scopes areuser:{id},tenant:{id},client:{id},{kind}:{value},{n}:tenant:{tenantId}/user:{userId}({n}is the UTF-8 byte length of the tenant part) andglobal. A resolver returnsnullwhen it cannot tell who the caller is;IdempotencyScope.Globalis a separate, explicit answer. IdempotencyScope: built only by its factories,User(id),Tenant(id),Client(id),UserAndTenant(userId, tenantId),Custom(kind, value)andGlobal. Every owner scope starts with its kind, soTenant("user:42")(tenant:user:42) never matchesUser("42")(user:42) and no owner scope can be theglobalpart of a global key.Customrejects akindthat is empty, contains:or/, is all digits or has an unpaired surrogate; the built-in kindsuser,tenant,clientandglobalare reserved in any casing.IdempotencyKeys:For(operation, ids),FromMessage(messageId, consumer),ToUuidV5(operation, ids)(RFC 9562, namespace9c8fbd17-da5f-4f32-a4bf-651721368d5e) andIsValid(key).IdempotencyErrorCodes: stable codes such asIdempotency.KeyReusedandIdempotency.InFlight.IIdempotencyScopeAccessor: gives a pipeline without a request context of its own the current principal.CSharpEssentials.AspNetCoreregisters one that readsHttpContext.User.- Targets
net11.0,net10.0,net9.0andnetstandard2.1. No reflection.
Installation
dotnet add package CSharpEssentials.Idempotency
Usage
Guarantee modes
| Lease mode | Transactional mode | |
|---|---|---|
| How | A store with a reservation token and a lease: in memory, the Redis recipe, any store that passes the contract tests | The record is written in the same database transaction as the business data (EfCoreIdempotencyRecordStore, Mediator InsideTransaction); the primary key settles the race |
| Duplicates | A run that outlives its lease, or a crash between the side effect and CompleteAsync, lets a retry run the operation again after the lease |
None for writes in that database: the record and the data commit or roll back together |
| External side effects | Pass the key on to the downstream system too | Same: a payment provider call is not in your transaction, so send it the deterministic key (Deterministic keys) |
| Use it for | Most operations, and everywhere without a shared database | Money and credit movement, and anything whose duplicate cannot be repaired |
The Idempotency-Key middleware of CSharpEssentials.AspNetCore is always lease mode: it reserves before and completes
after the endpoint, outside your handler's transaction, whatever store it uses. For a transactional guarantee over HTTP,
parse the header with IdempotencyKeyHeader.TryParse (the middleware's own normalization, so a quoted "abc" is
abc in both layers), pass that key to a Mediator command and run the command InsideTransaction; see
Transactional store for money movement. The middleware (namespace http)
and the behavior (namespace: the request type's full name, Type.FullName) never share a record, so you can keep both.
Run an operation once
IIdempotencyExecutor wraps any pipeline: a third-party mediator, a job runner or your own dispatcher.
services.AddIdempotencyExecutor(o => o.AddProfile("payments", p => p.Retention = TimeSpan.FromDays(7)));
IIdempotencyResultCodec<Receipt> codec = IdempotencyResultCodec.Create<Receipt>(
receipt => JsonSerializer.SerializeToUtf8Bytes(receipt),
payload => JsonSerializer.Deserialize<Receipt>(payload.Span)!);
IdempotencyRecordKey key = new("payments.capture", IdempotencyScope.Tenant(command.TenantId), command.IdempotencyKey);
IdempotencyExecution<Receipt> execution = await executor.ExecuteAsync(
key, fingerprint: command.Hash, new IdempotencyExecutionOptions { Profile = "payments" },
token => handler.HandleAsync(command, token), codec, cancellationToken);
| Status | Meaning |
|---|---|
Executed |
The operation ran; Value is its result |
Replayed |
A result stored for the same key and fingerprint; the operation did not run |
InFlight |
Another run holds the key; retry later with the same key |
KeyReused |
The key was used with another fingerprint; do not retry |
- The codec's
Classifydecides what happens to a result:Storekeeps it forRetention(24 hours),StoreAsFailureforFailureRetention(10 minutes), andReleasefrees the key, for a failure whose side effects were undone, such as a rolled-back transaction. Withoutclassifyevery result is stored. - A cancellation before the operation starts releases the key. An exception thrown by the operation is rethrown and keeps the key in flight until
Lease(1 minute) expires, because its side effects are unknown. - Settings resolve from the operation, then its profile, then
IdempotencyExecutorOptions.StoreKeyselects a keyedIIdempotencyRecordStore.AddIdempotencyExecutorvalidates the options and every profile at startup. ResolveMode(options)returns the mode an operation runs in, so a caller can skip computing its fingerprint inOff.
Process a message once
IdempotencyInbox is for brokers and consumers that have no inbox of their own; it does not replace the inbox or outbox
of a messaging framework.
IdempotencyExecutionStatus status = await inbox.ProcessAsync(
consumer: "billing.invoice-created",
messageId: message.Id,
scope: IdempotencyScope.Tenant(message.TenantId),
handler: token => HandleAsync(message, token),
cancellationToken: cancellationToken);
- The record namespace is
messaging:{consumer}and the key isIdempotencyKeys.FromMessage(messageId, consumer), so two consumers of the same message do not deduplicate each other. - The fingerprint is constant: the message id is authoritative, so a redelivery that serializes the message differently is still a duplicate.
Replayedmeans a duplicate: acknowledge it.InFlightmeans another delivery is processing it: redeliver it later.- The built-in
messagingprofile keeps records for 7 days, so a message redelivered from a dead-letter queue days later is still recognized. PassIdempotencyExecutionOptionsto override it, or register a profile namedmessagingto replace it. - A generic overload takes a handler with a result and a codec, and returns the
IdempotencyExecution<TResult>.
Rollout: Observe → Enforce
| Mode | Behavior |
|---|---|
Enforce (default) |
A stored result is replayed; an in-flight or reused key stops the operation |
Observe |
Records are reserved and stored as in Enforce, but nothing is blocked: a replay, an in-flight key or a reused key is logged and the operation runs. It measures duplicates; it does not prevent them |
Off |
The operation runs without touching the store |
services.AddIdempotencyExecutor(o =>
{
o.Mode = IdempotencyMode.Observe;
o.AddProfile("payments", p => p.Mode = IdempotencyMode.Enforce);
});
services.Configure<IdempotencyExecutorOptions>(configuration.GetSection("Idempotency:Executor")); // "Mode": "Enforce"
- The executor reads
IOptionsMonitor<IdempotencyExecutorOptions>on every call, so a reload of a bound configuration section changes the mode, globally or per profile, without a restart. A reload that makes the options invalid throwsOptionsValidationExceptionfrom every call until it is fixed. The store is the one registered at startup. - The mode comes from the operation (
IdempotencyExecutionOptions.Mode), else its profile, elseIdempotencyExecutorOptions.Mode. - After a switch from
ObservetoEnforce, a result stored inObserveis replayed and a key still in flight is answeredInFlight. With a store that writes in the business transaction (ITransactionalIdempotencyRecordStore, such as the EF Core store) only results whose transaction committed are stored, so a retry of work that rolled back runs again. Observemeasures duplicates; it does not prevent them. Keep it short for operations that move money.- In
Observea store that throws while reserving is logged (Idempotency.Observe.ReserveFailed) and the operation runs once without idempotency, unless the caller cancelled. InEnforcethe exception propagates. - The
Idempotency-Keymiddleware ofCSharpEssentials.AspNetCorehas its own switch,IdempotencyOptions.Mode. The Mediator behavior andIdempotencyInboxuse this one.
To add idempotency to an operation that already has callers:
- Ship it in
Observe. Nothing changes for callers; every decision is counted and logged. - Read the telemetry.
key_reusedshows callers that reuse a key for different content,in_flightshows concurrent duplicates,replayshows how often a retry would have been answered from the store, andunscopedshows callers that send a key without a resolvable scope (the log events name the endpoint or request type, never the key). - Fix what the numbers show (a client that reuses keys, a machine caller that needs
IdempotencyScopes.Client), then switch toEnforcethrough configuration.
Transactions in Observe. Observe records are real records. With an ITransactionalIdempotencyRecordStore (the
EF Core store) the executor and the HTTP middleware reserve the key through the Detached view, so the reservation never
waits for or fails the business transaction. When the operation returns a result to store, they release that reservation
and write the result through the store itself, in the business transaction (the Mediator transaction with
IdempotencyPlacement.InsideTransaction, or the one open around UseIdempotency). The record then commits and rolls back
with the business data, as in Enforce: when the commit fails, nothing is recorded and a retry after the switch to
Enforce runs the operation again. What remains:
- A store that cannot join the business transaction (in-memory, distributed cache, Redis) stores the result before the
commit when the transaction wraps the idempotency layer, in
ObserveandEnforcealike. A commit that then fails leaves a record, and a retry replays a result for work that never committed. Use the EF Core store, or put the idempotency layer outside the transaction, for work whose commit can fail after the operation returned. - Between the release of the detached reservation and the write in the business transaction the key is absent for one
round trip. A concurrent duplicate that arrives then is reserved: in
Observeit is countedreservedinstead ofin_flight, and on an instance that already runsEnforce(a rolling switch) it runs. - From that write until the commit, a reservation of the same key waits for the business transaction. In
Enforcethat wait is a plain lock wait the database can see; inObserveit can be a cycle the database cannot detect: request B holds its business transaction while its detached reservation, on a second connection, waits for the uncommitted result row of request A, and one edge of the cycle lives in the application across B's two connections. Only the command timeout ends it, and B then runs without idempotency (Idempotency.Observe.ReserveFailed). An operation that reuses the key of an operation it already completed in the same transaction waits the same way. - A database error on that write (a lock or statement timeout, for example) is rolled back to a savepoint, so the
business data still commits, without the record, and a retry after the switch to
Enforceruns again. The EF Core store takes the savepoint in the context's transaction, and on PostgreSQL also in an ambientTransactionScopewhose transaction the connection is enlisted in; on other providers an error that aborts an ambient transaction takes the business data with it. On PostgreSQL, under an isolation level stricter thanREAD COMMITTED(TransactionScopedefaults toSerializable) the write always fails this way, because the release changed the row after the transaction's snapshot, soObserverecords nothing: run the business transaction atREAD COMMITTED, which the store needs anyway. Other providers may succeed under a stricter level; that is not verified. - On SQL Server a savepoint cannot save a transaction the server has doomed: with
XACT_ABORT ONon the connection, or underSNAPSHOTisolation after an update conflict (error 3960), the failed write dooms the whole transaction, the rollback to the savepoint fails, and the business data is lost with it. The original error is thrown. KeepXACT_ABORToff and avoidSNAPSHOTfor transactions that runObserve, or useEnforce. - If the detached release fails, the write in the business transaction turns the committed in-flight row to
Completedinside that transaction. When that commit then fails, the row stays in flight, and after the switch toEnforcea retry is answeredInFlight(409) until the lease ends; it is not replayed.
Keep Observe short for operations that move money.
Lease and retry budget
A lease is how long a key stays in flight when nobody settles it: after a crash, a client abort, an exception or a
503/504, a retry gets InFlight until the lease ends and then runs the operation once more. Pick it by one rule:
lease ≥ max(the server's request timeout, the client's total retry budget)
A lease shorter than the server's request timeout lets a retry start a second run while the first is still working. A
lease shorter than the client's total retry budget (for AddStandardResilienceHandler, TotalRequestTimeout) lets the
client's last attempt run a second time. The defaults are 1 minute (IdempotencyExecutorOptions.Lease,
IdempotencyOptions.InFlightTimeout) and at most 15 minutes (MaxLease, MaxInFlightTimeout); a lease must not exceed
its retention either. The options and profiles are validated at startup, and the values of an operation
(IdempotencyExecutionOptions) on each call. The client retries an InFlight answer after its
Retry-After (at most IdempotentRetryOptions.MaxRetryAfter, 30 seconds by default), so a long operation needs a
client that keeps retrying for as long as the lease, or one that polls the result.
Set a longer lease per operation or profile (IdempotencyExecutionOptions.Lease, p.Lease), not globally. The token a
reservation carries keeps a request that outlived its lease from overwriting or removing the entry of the retry that took
over, but its side effects have already happened.
Telemetry
Every entry point counts its decisions on one counter, so an Observe rollout can be measured before it is enforced:
builder.Services.AddOpenTelemetry().WithMetrics(m => m.AddMeter(IdempotencyTelemetry.MeterName));
| Meter | CSharpEssentials.Idempotency (IdempotencyTelemetry.MeterName) |
| Counter | idempotency.decisions (IdempotencyTelemetry.DecisionsInstrumentName) |
decision |
reserved, replay, key_reused, in_flight, unscoped, released, stored |
mode |
observe or enforce. Nothing is recorded in Off |
source |
http (middleware), mediator (behavior), messaging (IdempotencyInbox), executor (direct calls) |
target |
The route template, the request type, messaging:{consumer}, or the record namespace |
replay, key_reused and in_flight are counted in Observe too, as what Enforce would have done. Keys and scopes are never tags, and logs never contain them: a log that names a record carries a key hash, the first 16 hex characters of the SHA-256 of its namespace, scope and key, so the log lines of one key can be correlated.
Log events:
| EventId | Name | Level | Logged by |
|---|---|---|---|
| 7001 | Idempotency.UnscopedKeySkipped |
Warning | HTTP, Mediator |
| 7002 | Idempotency.StoredResponseUnreadable |
Error | HTTP |
| 7003 | Idempotency.KeptInFlight |
Warning | HTTP, executor |
| 7004 | Idempotency.ResponseNotStorable |
Warning | HTTP |
| 7005 | Idempotency.StoreFailed |
Error | HTTP, executor |
| 7006 | Idempotency.StaleReservation |
Warning | HTTP, executor |
| 7007 | Idempotency.ReleaseFailed |
Warning | HTTP, executor |
| 7008 | Idempotency.ExecutionFilterMissing |
Warning | HTTP |
| 7009 | Idempotency.RequestTimeoutExceedsLease |
Warning | HTTP |
| 7010 | Idempotency.Observe.WouldReplay |
Information | HTTP, executor |
| 7011 | Idempotency.Observe.WouldRejectInFlight |
Information | HTTP, executor |
| 7012 | Idempotency.Observe.WouldRejectKeyReused |
Information | HTTP, executor |
| 7013 | Idempotency.Observe.WouldRejectScopeRequired |
Information | HTTP, Mediator |
| 7014 | Idempotency.Observe.ResolutionFailed |
Warning | HTTP, Mediator |
| 7015 | Idempotency.Observe.ReserveFailed |
Warning | HTTP, executor |
| 7016 | Idempotency.Observe.WouldRejectKeyMissing |
Information | HTTP, Mediator |
| 7017 | Idempotency.Observe.WouldRejectKeyInvalid |
Information | HTTP, Mediator |
Use the store directly
IdempotencyRecordKey recordKey = new("imports", IdempotencyScope.Global, IdempotencyKeys.For("import", fileId));
IdempotencyRecordReservation reservation =
await store.TryReserveAsync(recordKey, fingerprint: fileHash, lease: TimeSpan.FromMinutes(1));
if (reservation.Status == IdempotencyReservationStatus.Reserved)
{
byte[] result = await ImportAsync(fileId);
await store.CompleteAsync(recordKey, reservation.Token!, fileHash, result, TimeSpan.FromDays(1));
}
Completed carries the stored Payload; InFlight means another run holds the key; FingerprintMismatch means the
key was used for other content. Release a reservation with ReleaseAsync(recordKey, token) when its work was undone.
Deterministic keys
string key = IdempotencyKeys.For("refund", orderId, refundId);
Guid uuid = IdempotencyKeys.ToUuidV5("refund", orderId, refundId);
Each part is written as {utf8ByteLength}:{value} and the parts are joined with |, so ("a", "bc") and ("ab", "c")
give different keys. When that string contains a character outside printable ASCII (or " or \) or is longer than
255 characters, the key is h: followed by the unpadded base64url SHA-256 of it. The format is pinned by
tests/vectors/idempotency-key-vectors.json.
A part with a lone surrogate throws ArgumentException.
A caller that is a message handler, not a person, has no random key to retry with: a random key means a new key on every redelivery. Derive the key from the business ids, and send the same one downstream:
// consumer: IdempotencyInbox keys the message as IdempotencyKeys.FromMessage(messageId, consumer)
// command: one capture per order, however often the event arrives
string key = IdempotencyKeys.For("capture", orderId.ToString());
// payment provider: the same key, so the provider does not charge twice either
using HttpRequestMessage request = new(HttpMethod.Post, "/charges") { Content = JsonContent.Create(charge) };
request.SetIdempotencyKey(key);
The key then makes the side effect single end to end: the inbox drops the duplicate message, the command drops a second
event for the same order, and the provider drops a repeated call. ToUuidV5 gives the same key as a UUID for providers
that insist on one.
Calling an idempotent endpoint
The client side lives in CSharpEssentials.Http: request.SetIdempotencyKey(key) or
HttpRequestBuilder.WithIdempotencyKey(key) attaches the key once per logical call, and
RetryUnsafeMethodsOnlyWithIdempotencyKey() retries a keyed POST or PATCH with that same key on a missing response,
408, 429, 503, 504 and 409 Idempotency.InFlight (a 409 InFlight, 429 or 503 after its Retry-After), but never a replayed response or a
KeyReused, KeyInvalid, KeyMissing or ScopeRequired error. See
Idempotency-Key and safe retries.
Scopes and machine callers
IIdempotencyScopeResolver resolver = IdempotencyScopes.UserAndTenant("tenant_id");
IdempotencyScope? scope = resolver.Resolve(user);
if (scope is null)
{
return;
}
IdempotencyRecordKey recordKey = new("http", scope.Value, key);
User reads ClaimTypes.NameIdentifier, then sub. Client reads client_id, then azp. The claim-based resolvers
return null for an anonymous principal or a missing claim, and the caller decides what to do with it.
The scope factories and the IdempotencyRecordKey constructor throw ArgumentException for a value with an unpaired
surrogate, which has no UTF-8 encoding. A Custom kind cannot contain : or /, and user, tenant, client and
global are reserved for the built-in factories in any casing: Custom("user", id) throws, use User(id).
A scope is part of the key, so a key means something only to its owner: consume:42 from two tenants is two records.
Scopes are built only by the factories, so a raw id such as "user:42" cannot collide with the output of another
factory. User needs a user claim, so a machine caller that authenticates with client credentials has none; pick
IdempotencyScopes.Client (client_id, then azp) for it, or UserAndTenant and Custom when the owner is the pair.
IdempotencyScope.Global is a deliberate choice to share a key between all callers and never matches a scoped key.
When no scope resolves, UnscopedKeyBehavior decides, and the two layers differ on purpose:
| Layer | Default | A keyed request without a scope |
|---|---|---|
| HTTP middleware | Skip |
Runs without idempotency; logs Idempotency.UnscopedKeySkipped |
| Mediator behavior | Reject |
Idempotency.ScopeRequired |
Reject over HTTP answers 400; Idempotency.KeyReused is 422 and Idempotency.InFlight is 409 with Retry-After. Use the
same resolver in both layers, so an HTTP request that reaches an idempotent command resolves the same scope. See
Scopes: HTTP and Mediator defaults and the
HTTP side.
Transactional store for money movement
When the side effect is a write to your own database, give the record the same transaction: the EF Core store with the
Mediator behavior InsideTransaction, shown in EF Core store. The command runs in one transaction that
reserves the key, changes the data and completes the record, so a crash or a rollback leaves neither, and two concurrent
requests for one key serialize on the primary key. The cost is that a duplicate waits for the first commit.
public sealed record CapturePaymentCommand(Guid OrderId, string IdempotencyKey, decimal Amount)
: ICommand<Result<Receipt>>, ITransactionalRequest, IIdempotentRequest;
- A failed
Resultof anITransactionalRequestrolls back, and an exception does too, so in both cases the record rolls back with the data and the retry runs again. - Send the provider the deterministic key (
IdempotencyKeys.For("capture", orderId.ToString())), because its call is outside your transaction. - Use the Redis or distributed cache store for operations that have no database transaction to join, and a hybrid for the rest:
AddProfile("payments", p => p.StoreKey = "payments")withAddEfCoreIdempotencyRecordStore<AppDbContext>("payments")sends only that profile to the database. - Over HTTP the middleware can sit in front for the
Idempotency-Keycontract (lease mode); the command's record is what makes the data single.
Webhook dedupe
Providers deliver at least once and send no Idempotency-Key; their event id is the identity. Dedupe it with the Mediator
behavior and the EF Core store: verify the signature over the raw body, claim the event, do the work, commit.
public sealed record PaymentEventCommand(string AccountId, string EventId, string Payload)
: ICommand<Result>, ITransactionalRequest, IIdempotentRequest
{
public string IdempotencyKey => IdempotencyKeys.FromMessage(EventId, "payments-webhook");
IdempotencyScope? IIdempotentRequest.IdempotencyScope => IdempotencyScope.Custom("account", AccountId);
string? IIdempotentRequest.IdempotencyFingerprint => "event";
}
byte[] webhookSecret = Convert.FromBase64String(builder.Configuration["PaymentProvider:WebhookSecret"]
?? throw new InvalidOperationException("PaymentProvider:WebhookSecret is not configured."));
app.MapPost("/webhooks/payments", async (HttpRequest request, ISender sender, CancellationToken cancellationToken) =>
{
using MemoryStream body = new();
await request.Body.CopyToAsync(body, cancellationToken);
if (!WebhookSignature.IsValid(webhookSecret, body.GetBuffer().AsSpan(0, (int)body.Length), request.Headers[WebhookSignature.HeaderName]))
return Results.Unauthorized();
PaymentEvent? paymentEvent = JsonSerializer.Deserialize<PaymentEvent>(body.ToArray(), JsonSerializerOptions.Web);
if (paymentEvent is null)
return Results.BadRequest();
Result result = await sender.Send(
new PaymentEventCommand(paymentEvent.AccountId, paymentEvent.Id, paymentEvent.Payload), cancellationToken);
return result.IsSuccess ? Results.Ok() : result.ToProblemResult(statusCode: StatusCodes.Status500InternalServerError);
});
- The signature is an HMAC over the exact bytes the provider sent, so read the raw body and verify it before you deserialize; a bound model has lost those bytes.
WebhookSignatureis the HMAC-SHA256 check of the end-to-end example: it decodes the hex header and compares the bytes withCryptographicOperations.FixedTimeEquals. An unverified event id must never be claimed, or a forged request turns the real delivery into a duplicate. - The scope comes from the command: a webhook has no principal, and the behavior rejects a request without a scope.
Custom("account", ...)is a legal kind. - The fingerprint is constant, so the same event serialized differently is still a duplicate and never
KeyReused. - A duplicate returns the stored success (200), so the provider stops sending it.
- A failed transaction rolls back and releases the key, and the failure goes back as a 5xx (
ToProblemResult(statusCode: 500), or an exception that propagates), so the provider redelivers it and the record is gone. The defaultToProblemResult()maps a validation or not-found error to a 4xx, which many providers treat as final and never redeliver. - Doing the same in your own table (a unique constraint, claim first, then work) is the same pattern as the EF Core store's reserve step; there is no separate API for it.
- Keep retention longer than the provider's redelivery window.
Stores
| Store | Guarantee |
|---|---|
InMemoryIdempotencyRecordStore |
Reservation is atomic within one process. Expired entries are removed on access and by a sweep that runs at most once a minute. |
DistributedCacheIdempotencyRecordStore |
For a single instance or development. IDistributedCache has no set-if-absent, so two concurrent requests with the same key can both be reserved. Entries are versioned; an entry it cannot read is treated as absent. |
EfCoreIdempotencyRecordStore<TContext> (CSharpEssentials.EntityFrameworkCore) |
Atomic across instances through the primary key of a table in your DbContext, a fixed-length hash of the storage key. Records are written inside the context's current transaction, so they commit and roll back with the business data. See EF Core store. |
For strict guarantees across instances, use the EF Core store or implement IIdempotencyRecordStore with one atomic operation for
TryReserveAsync (Redis SET NX, a unique index) and token-checked CompleteAsync and ReleaseAsync, then run the
CSharpEssentials.Idempotency.Testing contract tests against it. See External stores.
Store requirement: a store must compare Namespace, Scope and Key ordinally: case-sensitively and with
trailing spaces significant. A SQL unique index on the text columns gets this wrong in two ways: a case-insensitive
collation makes Tenant("ABC") and Tenant("abc") one record, and a PAD SPACE collation, which ignores trailing
spaces (every SQL Server collation, _BIN2 included, and MySQL's utf8mb4_bin), makes Tenant("abc") and Tenant("abc ") one
record, so one scope can replay another's response. Key a SQL store by a fixed-length hash of ToStorageKey() in a
binary column instead, as the EF Core store does. That is the only safe way on SQL Server, which ignores trailing
spaces in = and unique comparisons under every collation and has no NO PAD one. Elsewhere a binary NO PAD collation
also works: PostgreSQL text or varchar (never char(n), which pads), MySQL utf8mb4_0900_bin, MariaDB
utf8mb4_nopad_bin. The contract tests check both.
Best-effort stores across multiple instances
DistributedCacheIdempotencyRecordStore reads the key and then writes it; IDistributedCache has no set-if-absent, so
two instances that see a free key at the same moment both write a reservation and both return Reserved. Both run the
operation. The later write wins, so only its token is valid and the other instance's CompleteAsync returns false,
after its side effect already happened. A completed entry is not affected: once it exists, every reader replays it.
Use it for one instance and for development, or where a rare duplicate is harmless. With several instances behind a load
balancer, or for anything that must not run twice, use the EF Core store or a store with an atomic reserve (Redis SET NX
through the Redis recipe), and run the contract tests against it. Override
IsReservationAtomic to false in the contract tests of a store you accept as best-effort, so the suite says so.
EF Core store
CSharpEssentials.EntityFrameworkCore has a transactional store. Map the table, register the store and put the Mediator
idempotency behavior inside the transaction:
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.AddIdempotencyRecords(Database.ProviderName);
services.AddEfCoreTransactionRunner<AppDbContext>();
services.AddMediatorTransactionRunnerBehavior();
services.AddMediatorIdempotencyBehavior(o => o.Placement = IdempotencyPlacement.InsideTransaction);
services.AddEfCoreIdempotencyRecordStore<AppDbContext>();
- A request whose transaction rolls back leaves no record, so a webhook or message that failed is processed again when it is redelivered. A duplicate that arrives while the first one is still running waits for its commit and replays its response.
- Records are keyed by
KeyHash, the SHA-256 of the storage key in abinary(32)column, so the store requirement above holds under any collation. The readable columns get the binary collation of a known provider (C,Latin1_General_100_BIN2,utf8mb4_0900_bin,BINARY);KeyCollationoverrides it. - The store needs the
READ COMMITTEDisolation level. Expired rows count as absent;PurgeExpiredAsyncdeletes them, for example from a hosted service. AddEfCoreIdempotencyRecordStore<AppDbContext>("payments")registers it as a keyed store, so only the profiles withStoreKey = "payments"use the database and the others keep the default store.- In
Observethe executor and the HTTP middleware reserve and release through itsDetachedview, in a context and transaction of their own, so a reservation does not wait for or fail the business transaction. A result to store is written in the business transaction, so it commits and rolls back with the business data as inEnforce(see Transactions inObserve).
The EF Core Readme has the details and a purge service.
External stores
There is no Redis or DynamoDB package. The library gives the IIdempotencyRecordStore contract, the requirements
below and the contract tests in CSharpEssentials.Idempotency.Testing; the store itself lives in your application.
Atomicity requirements
TryReserveAsyncis one atomic operation: of concurrent calls for a free or expired key, exactly one returnsReserved. A live entry with another fingerprint returnsFingerprintMismatch, whether it is in flight or completed.CompleteAsyncandReleaseAsyncare compare-and-set on the reservation token. They change the entry only while it carries the caller's token and is not completed.CompleteAsyncalso succeeds when the entry is gone (its lease expired and nobody took the key over);ReleaseAsyncnever removes a completed entry.- The lease applies to the in-flight reservation, the retention to the completed entry. The store applies the duration
it is given; the caller picks it (
Retention,FailureRetention,ServerErrorRetention). - Build the storage key with
key.ToStorageKey(prefix)and compare it ordinally and case-sensitively (see the store requirement above). - The payload is opaque bytes and may be empty, so a store marks completion separately from the payload content.
Redis
A reference implementation on StackExchange.Redis. Each operation is one Lua script on one key, so it is atomic and
works on Redis Cluster. An entry is a hash with t (token), f (fingerprint) and, once completed, p (payload);
leases and retention are PEXPIRE TTLs, so an expired entry is absent.
- Reserve: when the key is absent, write
tandfand setPEXPIRE lease; otherwise returnFingerprintMismatchfor anotherf,Completedwithpwhenpexists, elseInFlight. - Complete: when the key is absent, or
tmatches andpdoes not exist, writet,fandpand setPEXPIRE retention. - Release: when
tmatches andpdoes not exist,DEL.
using CSharpEssentials.Idempotency;
using StackExchange.Redis;
namespace Examples.Idempotency.Redis;
/// <summary>
/// <see cref="IIdempotencyRecordStore"/> on Redis. Every operation is one Lua script on one key, so it is atomic and
/// works on Redis Cluster. An entry is a hash: <c>t</c> (token), <c>f</c> (fingerprint) and, once completed,
/// <c>p</c> (payload). Leases and retention are Redis TTLs (<c>PEXPIRE</c>), so an expired entry is simply absent.
/// </summary>
public sealed class RedisIdempotencyRecordStore : IIdempotencyRecordStore
{
private const string ReserveScript = """
local entry = redis.call('HMGET', KEYS[1], 't', 'f', 'p')
if not entry[1] then
redis.call('HSET', KEYS[1], 't', ARGV[1], 'f', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return { 0 }
end
if entry[2] ~= ARGV[2] then
return { 3 }
end
if not entry[3] then
return { 1 }
end
return { 2, entry[3] }
""";
private const string CompleteScript = """
local entry = redis.call('HMGET', KEYS[1], 't', 'p')
if entry[1] and (entry[2] or entry[1] ~= ARGV[1]) then
return 0
end
redis.call('HSET', KEYS[1], 't', ARGV[1], 'f', ARGV[2], 'p', ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return 1
""";
private const string ReleaseScript = """
local entry = redis.call('HMGET', KEYS[1], 't', 'p')
if entry[1] == ARGV[1] and not entry[2] then
redis.call('DEL', KEYS[1])
return 1
end
return 0
""";
private readonly IConnectionMultiplexer _connection;
/// <summary>Uses <see cref="IdempotencyRecordKey.DefaultPrefix"/>.</summary>
public RedisIdempotencyRecordStore(IConnectionMultiplexer connection)
: this(connection, IdempotencyRecordKey.DefaultPrefix)
{
}
/// <param name="connection">The application's Redis connection.</param>
/// <param name="keyPrefix">Prefix of the Redis keys, passed to <see cref="IdempotencyRecordKey.ToStorageKey"/>.</param>
public RedisIdempotencyRecordStore(IConnectionMultiplexer connection, string keyPrefix)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(keyPrefix);
_connection = connection;
KeyPrefix = keyPrefix;
}
/// <summary>Prefix of the Redis keys written by this store.</summary>
public string KeyPrefix { get; }
/// <inheritdoc />
public async ValueTask<IdempotencyRecordReservation> TryReserveAsync(
IdempotencyRecordKey key,
string fingerprint,
TimeSpan lease,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentException.ThrowIfNullOrEmpty(fingerprint);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(lease, TimeSpan.Zero);
cancellationToken.ThrowIfCancellationRequested();
string token = Guid.NewGuid().ToString("N");
RedisResult result = await _connection.GetDatabase().ScriptEvaluateAsync(
ReserveScript,
[key.ToStorageKey(KeyPrefix)],
[token, fingerprint, Milliseconds(lease)]).ConfigureAwait(false);
return (int)result[0] switch
{
0 => IdempotencyRecordReservation.Reserved(token),
1 => IdempotencyRecordReservation.InFlight,
2 => IdempotencyRecordReservation.Completed((byte[]?)result[1] ?? []),
_ => IdempotencyRecordReservation.FingerprintMismatch,
};
}
/// <inheritdoc />
public async ValueTask<bool> CompleteAsync(
IdempotencyRecordKey key,
string token,
string fingerprint,
ReadOnlyMemory<byte> payload,
TimeSpan retention,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentException.ThrowIfNullOrEmpty(token);
ArgumentException.ThrowIfNullOrEmpty(fingerprint);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(retention, TimeSpan.Zero);
cancellationToken.ThrowIfCancellationRequested();
RedisResult result = await _connection.GetDatabase().ScriptEvaluateAsync(
CompleteScript,
[key.ToStorageKey(KeyPrefix)],
[token, fingerprint, payload.ToArray(), Milliseconds(retention)]).ConfigureAwait(false);
return (int)result == 1;
}
/// <inheritdoc />
public async ValueTask<bool> ReleaseAsync(IdempotencyRecordKey key, string token, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentException.ThrowIfNullOrEmpty(token);
cancellationToken.ThrowIfCancellationRequested();
RedisResult result = await _connection.GetDatabase().ScriptEvaluateAsync(
ReleaseScript,
[key.ToStorageKey(KeyPrefix)],
[token]).ConfigureAwait(false);
return (int)result == 1;
}
/// <summary>Whole milliseconds for <c>PEXPIRE</c>, rounded up so a positive span never becomes 0.</summary>
private static long Milliseconds(TimeSpan duration) => Math.Max(1L, (long)Math.Ceiling(duration.TotalMilliseconds));
}
Register your IConnectionMultiplexer and select the store. For HTTP endpoints:
builder.Services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnectionString));
builder.Services.AddIdempotency(o => o.UseStore<RedisIdempotencyRecordStore>(ServiceLifetime.Singleton));
For the executor, IdempotencyInbox or the Mediator behavior, register it before AddIdempotencyExecutor, which keeps
an existing store:
builder.Services.AddSingleton<IIdempotencyRecordStore, RedisIdempotencyRecordStore>();
builder.Services.AddIdempotencyExecutor();
For one profile only, register it as a keyed IIdempotencyRecordStore and name the key in the profile's StoreKey.
StackExchange.Redis commands take no CancellationToken, so the store checks it before the call only. Multi-field
HSET needs Redis 4.0 or later.
This code is kept in examples/Examples.Idempotency.Redis,
where CI runs every contract scenario against a Redis container:
public sealed class RedisIdempotencyRecordStoreTests(RedisFixture redis)
: IdempotencyRecordStoreContractTests, IClassFixture<RedisFixture>
{
protected override bool UseRealClock => true;
protected override IIdempotencyRecordStore CreateStore(TimeProvider timeProvider) =>
new RedisIdempotencyRecordStore(redis.Connection);
}
DynamoDB
Use one item per ToStorageKey() partition key with token, fingerprint, payload and expiresAt, and conditional
writes. DynamoDB TTL deletes expired items late, so expiry is part of every condition and TTL only cleans up.
- Reserve:
PutItemwithattribute_not_exists(pk) OR expiresAt <= :now. OnConditionalCheckFailedException, read the existing item (ReturnValuesOnConditionCheckFailure = ALL_OLD) and returnFingerprintMismatch,CompletedorInFlight. - Complete:
PutItemwithattribute_not_exists(pk) OR (#token = :token AND attribute_not_exists(payload)), withexpiresAtset to now plus the retention. - Release:
DeleteItemwith#token = :token AND attribute_not_exists(payload).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- Microsoft.Bcl.TimeProvider (>= 8.0.0)
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Options (>= 9.0.4)
-
net10.0
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Options (>= 9.0.4)
-
net11.0
- No dependencies.
-
net9.0
- Microsoft.Extensions.Caching.Abstractions (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Options (>= 9.0.4)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on CSharpEssentials.Idempotency:
| Package | Downloads |
|---|---|
|
CSharpEssentials.EntityFrameworkCore
Enhances Entity Framework Core with functional programming patterns and DDD-friendly features. Includes base entity classes, soft delete support, audit trails, query filters, optimistic concurrency, PostgreSQL integration, query performance monitoring, and domain event handling. Perfect for building maintainable and scalable data access layers in modern .NET applications. |
|
|
CSharpEssentials.AspNetCore
A comprehensive ASP.NET Core library that enhances functional programming capabilities in web applications. Features include API versioning, global exception handling with enhanced problem details, enum binding and wire formats, model validation, optimized JSON handling, and seamless integration with CSharpEssentials core functional patterns (Result, Maybe, Rule Engine). Perfect for building robust, maintainable, and type-safe web APIs following functional programming principles. OpenAPI documents come from CSharpEssentials.AspNetCore.OpenApi or CSharpEssentials.AspNetCore.Swashbuckle. |
|
|
CSharpEssentials.Mediator
High-performance CQRS and pipeline behaviors built on the Mediator source-generator library. Provides Result-integrated commands, queries, validation, logging, caching, transaction, lock and idempotency behaviors with a Result-first pipeline. |
|
|
CSharpEssentials.Idempotency.Testing
xUnit contract tests for IIdempotencyRecordStore implementations. Derive from IdempotencyRecordStoreContractTests, create your store, and the same scenarios that verify the built-in stores run against it: fingerprint mismatch, token fencing, expiry, isolation and concurrent reservations. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 7.0.0 | 95 | 10/20/2026 |