GM.Idempotency.Mediator
1.0.0
dotnet add package GM.Idempotency.Mediator --version 1.0.0
NuGet\Install-Package GM.Idempotency.Mediator -Version 1.0.0
<PackageReference Include="GM.Idempotency.Mediator" Version="1.0.0" />
<PackageVersion Include="GM.Idempotency.Mediator" Version="1.0.0" />
<PackageReference Include="GM.Idempotency.Mediator" />
paket add GM.Idempotency.Mediator --version 1.0.0
#r "nuget: GM.Idempotency.Mediator, 1.0.0"
#:package GM.Idempotency.Mediator@1.0.0
#addin nuget:?package=GM.Idempotency.Mediator&version=1.0.0
#tool nuget:?package=GM.Idempotency.Mediator&version=1.0.0
GM.Idempotency
Idempotency-key management for the GM.* .NET ecosystem. Make an operation safe to retry: run it at most once per key and replay the original result on duplicates — a repeated HTTP POST, a RabbitMQ redelivery, a double-clicked "Pay" button.
GM.Idempotency— the coreIIdempotencyService, a cache-backed store, and the distributed-lock-guarded check-and-set. Depends only onGM.Caching+GM.DistributedLock.GM.Idempotency.Http— an ASP.NET Core middleware that reads theIdempotency-Keyheader, short-circuits duplicate POST/PUT/PATCH requests, and replays the stored response.GM.Idempotency.Mediator— aGM.Mediatorpipeline behavior that dedups a command/message before the handler runs.
All three ship from one repo and version in lockstep.
How it works
Dedup keys live in GM.Caching (ICacheService) so lookups are fast and records expire on a
TTL. The check-and-set is wrapped in a GM.DistributedLock so two near-simultaneous duplicates
can't both read "not yet processed" and both run:
ExecuteAsync(key, operation):
1. fast path — a stored record? → replay it, no lock taken
2. acquire lock(key) — a duplicate waits here for the in-flight original
3. double-check — stored now? → replay it
4. run operation once, store result (+ TTL)
5. release lock
A record stores enough of the result to replay it — not just a boolean "processed" flag — so a
retried HTTP request gets the same status/body and a redelivered message gets the same response. An
operation that throws is not recorded, so it stays retryable. If a duplicate can't get the lock
within the wait (the original is still running), the call resolves to Pending — the HTTP layer
returns 409, the Mediator layer throws so the broker redelivers.
Install
dotnet add package GM.Idempotency
dotnet add package GM.Idempotency.Http # HTTP middleware
dotnet add package GM.Idempotency.Mediator # GM.Mediator behavior
Core service
builder.Services.AddGMCaching(); // or AddGMRedisCaching(...) — the dedup store
builder.Services.AddGMDistributedLock(); // or AddGMRedisDistributedLock(...) — cross-process safety
builder.Services.AddGMIdempotency(o =>
{
o.DefaultTtl = TimeSpan.FromHours(24); // how long a key is remembered
o.LockExpiry = TimeSpan.FromSeconds(30);
o.LockWait = TimeSpan.FromSeconds(10);
});
In-memory vs. Redis —
AddGMCaching()/AddGMDistributedLock()are per-process. To dedup across multiple instances (the usual production case), register the Redis backends instead. Nothing else changes.
Use the race-safe ExecuteAsync directly when you're not going through the HTTP or Mediator layers:
var execution = await idempotency.ExecuteAsync(
key: $"charge:{command.Id}",
operation: async ct => IdempotencyResult.Json(await ChargeAsync(command, ct)));
var result = execution.Result!.ReadJson<ChargeResult>(); // fresh on first call, replayed on a dupe
// execution.Outcome is Executed | Replayed | Pending
The three low-level primitives — IsProcessedAsync, MarkAsProcessedAsync, TryGetCachedResultAsync
— are also available, but a manual check-then-mark is racy; prefer ExecuteAsync.
HTTP middleware
builder.Services.AddGMIdempotency();
builder.Services.AddGMIdempotencyHttp(o =>
{
o.HeaderName = "Idempotency-Key"; // default
o.Methods = ["POST", "PUT", "PATCH"]; // guarded verbs
o.RequireOptIn = false; // true → only [Idempotent] endpoints
});
app.UseRouting(); // before the middleware, so per-endpoint [Idempotent] is visible
app.UseGMIdempotency();
A client sends the same Idempotency-Key on a retry:
POST /orders → 201 Created { "id": "a1b2", "serverToken": "…9f" }
POST /orders (retry) → 201 Created { "id": "a1b2", "serverToken": "…9f" }
+ Idempotency-Replayed: true ← same body, not re-created
The stored copy keeps the status code, Content-Type, an allow-list of headers (Location by
default — the created-resource URL) and the body. Only cacheable responses (2xx by default, up to
a size limit) are stored, so a 500 is left to genuinely retry. Per-endpoint opt-in:
app.MapPost("/orders", CreateOrder).WithMetadata(new IdempotentAttribute { TtlSeconds = 3600 });
// or on an MVC action: [Idempotent(TtlSeconds = 3600)]
GM.Mediator behavior (message dedup)
For at-least-once delivery (RabbitMQ via GM.Messaging), dedup by message id before the handler runs:
builder.Services.AddGMMediator(typeof(Program).Assembly);
builder.Services.AddGMIdempotency();
builder.Services.AddGMIdempotencyBehavior(); // register first → wraps your other behaviors
// Opt a command in with its message id (the "derived from the message" strategy):
public sealed record ProcessPayment(string MessageId, decimal Amount)
: IRequest<PaymentResult>, IIdempotentRequest
{
public string IdempotencyKey => MessageId;
}
A redelivery of MessageId is recognized: the handler runs once, and the redelivery gets the stored
PaymentResult back. Requests that don't opt in pass through untouched.
Key strategies
The key is deliberately flexible — pick per integration:
| Strategy | Where | How |
|---|---|---|
| Caller-supplied | HTTP | the Idempotency-Key header (client owns it, resends on retry) |
| Derived from the message | Mediator | IIdempotentRequest.IdempotencyKey → the message/command id |
| Hash of the payload | Mediator | [Idempotent] on a request with no natural id → IdempotencyKey.FromPayload(...) |
IIdempotencyKeyProvider<TSource> is the extension point if you need a different source; the
IdempotencyKey helpers (FromHash, FromPayload, Compose) are the building blocks.
Follow-up integration points (flagged, not built)
These are candidates, called out for a deliberate decision rather than wired up here:
- GM.HttpClient outbound retry — should its retry policy generate and attach an
Idempotency-Keyto outbound POST/PUTs (and reuse it across retries) so downstream services can dedup our calls? A natural pairing, but it changes outbound contracts — decide per upstream. - GM.Payments — the highest-value consumer (never double-charge). Likely wants the Mediator behavior on payment commands and to propagate a provider-level idempotency key to the payment gateway. Needs a payments-domain decision on key derivation and TTL.
- GM.KYC webhook handlers — inbound webhooks are retried by providers; dedup by the provider's event id via the Mediator behavior (or a thin HTTP guard). Confirm each provider's event-id header.
Options
IdempotencyOptions (core): KeyPrefix, DefaultTtl, LockExpiry, LockWait, LockRetryInterval.
IdempotencyHttpOptions: HeaderName, Methods, RequireOptIn, ScopeKeyToEndpoint,
IsCacheableStatusCode, ReplayResponseHeaders, MaxReplayableBodyBytes, ReplayedHeaderName,
ConflictStatusCode.
Samples
Runnable API demonstrating both integration points: GM.Idempotency.Samples.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net10.0
- GM.Idempotency (>= 1.0.0)
- GM.Mediator (>= 1.3.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 79 | 8/3/2026 |