LowCodeHub.Idempotency
0.0.2
See the version list below for details.
dotnet add package LowCodeHub.Idempotency --version 0.0.2
NuGet\Install-Package LowCodeHub.Idempotency -Version 0.0.2
<PackageReference Include="LowCodeHub.Idempotency" Version="0.0.2" />
<PackageVersion Include="LowCodeHub.Idempotency" Version="0.0.2" />
<PackageReference Include="LowCodeHub.Idempotency" />
paket add LowCodeHub.Idempotency --version 0.0.2
#r "nuget: LowCodeHub.Idempotency, 0.0.2"
#:package LowCodeHub.Idempotency@0.0.2
#addin nuget:?package=LowCodeHub.Idempotency&version=0.0.2
#tool nuget:?package=LowCodeHub.Idempotency&version=0.0.2
LowCodeHub.Idempotency
An end-to-end idempotency library for ASP.NET Core Minimal APIs. Deduplicates write requests by Idempotency-Key header with full response replay, atomic lock semantics, pluggable storage backends, and built-in observability — all from a single middleware and one endpoint extension.
Why This Library?
| Feature | LowCodeHub.Idempotency | Manual Implementation | Stripe-Style |
|---|---|---|---|
| Response replay | Full — status, headers, body | Build from scratch | Full |
| Concurrent duplicates | Atomic lock → 409 Conflict | Race conditions | Atomic |
| Fingerprint enforcement | Built-in → 422 on mismatch | Manual | Yes |
| Storage backends | 4 built-in — Memory, Redis, SQL Server, PostgreSQL | One custom backend | Internal |
| Server error recovery | Lock released — safe to retry | Manual | Yes |
| Per-endpoint opt-in | .RequireIdempotency() |
Manual filter | Custom middleware |
| Observability | OpenTelemetry counters + traces | Manual | Internal |
| Admin diagnostics | Built-in query endpoint | Build from scratch | Internal |
| Health checks | Per-backend auto-registered | Manual | N/A |
Installation
dotnet add package LowCodeHub.Idempotency
Quick Start
builder.Services.AddIdempotency(builder.Configuration);
app.UseIdempotency();
app.MapPost("/orders", CreateOrder)
.RequireIdempotency();
That's it. Any POST /orders request with an Idempotency-Key header will have its full response (status code, headers, body) stored and replayed on subsequent requests with the same key — preventing duplicate orders from retries, double-clicks, or load-balancer replays.
Table of Contents
- How It Works
- Configuration
- Middleware Setup
- Marking Endpoints
- Storage Backends
- Request Fingerprinting
- HTTP Response Codes
- Behavior Details
- Admin Diagnostics
- Health Checks
- Observability
- Custom Storage Implementation
- Requirements
- License
How It Works
┌─────────────────────────────────────────────────────────┐
│ Client sends POST /orders │
│ Header: Idempotency-Key: abc-123 │
└─────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ IdempotencyMiddleware │
│ 1. Check if key exists in store │
│ │ ├── Found (completed) → replay stored response │
│ │ ├── Found (locked) → return 409 Conflict │
│ │ └── Not found → acquire lock, continue │
│ 2. Compute request fingerprint (SHA256 of body) │
│ │ └── Mismatch with stored → return 422 │
│ 3. Execute endpoint handler │
│ 4. Capture response (status + headers + body) │
│ 5. Store response → release lock │
│ │ └── 5xx response → release lock (don't store) │
└─────────────────────────────────────────────────────────┘
Key design principles:
- First request — the endpoint executes normally and the full response is stored.
- Subsequent requests — the stored response is replayed without re-execution.
- Concurrent duplicates — a second request arriving while the first is processing gets a
409 Conflict. - Server errors — the lock is released so the client can safely retry.
- Fingerprint enforcement — reusing a key with a different request body returns
422 Unprocessable Entity.
Configuration
appsettings.json
{
"Idempotency": {
"HeaderName": "Idempotency-Key",
"DefaultExpiration": "1.00:00:00",
"LockDuration": "00:05:00",
"MaxBodyCaptureSize": 262144,
"EnforceRequestFingerprint": true
}
}
All Options
| Option | Default | Description |
|---|---|---|
HeaderName |
"Idempotency-Key" |
HTTP header name for the idempotency key |
DefaultExpiration |
24 hours |
How long stored responses remain valid |
LockDuration |
5 minutes |
How long a lock is held during processing |
MaxBodyCaptureSize |
256 KB |
Max response body size to capture and store |
EnforceRequestFingerprint |
true |
Reject key reuse with a different request body |
AllowedMethods |
POST, PATCH, PUT |
HTTP methods that support idempotency |
Code-Based Configuration
builder.Services.AddIdempotency(options =>
{
options.DefaultExpiration = TimeSpan.FromHours(24);
options.LockDuration = TimeSpan.FromMinutes(5);
options.EnforceRequestFingerprint = true;
options.MaxBodyCaptureSize = 256 * 1024;
});
Middleware Setup
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseIdempotency(); // ← after auth, before endpoint execution
The middleware only activates for endpoints marked with .RequireIdempotency(). Unmarked endpoints pass through with zero overhead.
Marking Endpoints
app.MapPost("/orders", CreateOrder)
.RequireIdempotency();
app.MapPost("/payments", CreatePayment)
.RequireIdempotency(TimeSpan.FromHours(48)); // custom TTL
app.MapGet("/orders", GetOrders); // not marked — passes through unchanged
GET/DELETE and other safe methods are skipped automatically even if marked (configurable via AllowedMethods).
Storage Backends
In-Memory (Default)
Registered automatically by AddIdempotency(). Suitable for development and single-node deployments. Records are lost on process restart.
Redis
builder.Services.AddIdempotencyRedis(options =>
{
options.ConnectionString = "localhost:6379";
options.KeyPrefix = "idempotency:";
});
Or through configuration:
{
"Idempotency": {
"Redis": {
"ConnectionString": "localhost:6379",
"KeyPrefix": "idempotency:"
}
}
}
Uses SET NX EX for atomic lock acquisition. Ideal for high-throughput multi-node APIs.
SQL Server
builder.Services.AddIdempotencySqlServer(options =>
{
options.ConnectionString = "Server=...;Database=...;";
options.Schema = "dbo";
options.Table = "IdempotencyRecords";
});
Requires a table with the following schema:
CREATE TABLE [dbo].[IdempotencyRecords] (
[Key] NVARCHAR(256) NOT NULL,
[State] INT NOT NULL,
[RequestFingerprint] NVARCHAR(128) NOT NULL,
[StatusCode] INT NULL,
[ResponseHeaders] NVARCHAR(MAX) NULL,
[ResponseBody] VARBINARY(MAX) NULL,
[CreatedAtUtc] DATETIMEOFFSET(7) NOT NULL,
[ExpiresAtUtc] DATETIMEOFFSET(7) NOT NULL,
CONSTRAINT [PK_IdempotencyRecords] PRIMARY KEY ([Key]),
INDEX [IX_IdempotencyRecords_ExpiresAtUtc] ([ExpiresAtUtc])
);
PostgreSQL
builder.Services.AddIdempotencyPostgreSql(options =>
{
options.ConnectionString = "Host=...;Database=...;";
options.Schema = "public";
options.Table = "idempotency_records";
});
Requires a table with the following schema:
CREATE TABLE public.idempotency_records (
key VARCHAR(256) NOT NULL,
state INTEGER NOT NULL,
request_fingerprint VARCHAR(128) NOT NULL,
status_code INTEGER NULL,
response_headers JSONB NULL,
response_body BYTEA NULL,
created_at_utc TIMESTAMPTZ NOT NULL,
expires_at_utc TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_idempotency_records PRIMARY KEY (key)
);
CREATE INDEX ix_idempotency_records_expires ON public.idempotency_records (expires_at_utc);
Request Fingerprinting
When EnforceRequestFingerprint is true (default), the middleware computes a SHA256 hash of the request body and stores it alongside the idempotency record. Subsequent requests with the same key but a different body receive 422 Unprocessable Entity.
This prevents accidental key misuse — for example, a client reusing the same idempotency key for two different order payloads.
HTTP Response Codes
| Status | When |
|---|---|
| 200 (or original) | First request completes / replay of stored response |
| 400 | Missing Idempotency-Key header on a marked endpoint |
| 409 | Concurrent duplicate — another request with the same key is in progress |
| 422 | Key reused with a different request body (fingerprint mismatch) |
Behavior Details
- Server errors (5xx): The lock is released so the client can retry with the same key.
- Client errors (4xx): The response IS stored and replayed (consistent with Stripe convention).
- Exceptions: The lock is released and the exception propagates normally.
- Body size limit: Responses exceeding
MaxBodyCaptureSizeare not stored; the lock is released. - Expired records: Treated as non-existent — the key can be reused after expiration.
Admin Diagnostics
app.MapIdempotencyDiagnostics("/admin/idempotency")
.RequireAuthorization(); // recommended
Query parameters: keyPrefix, state, createdAfter, createdBefore, maxResults.
Returns record metadata (no response bodies) for operational visibility.
Health Checks
Each storage backend registers a health check automatically:
| Check | Tags |
|---|---|
idempotency-redis |
idempotency, redis, readiness |
idempotency-sqlserver |
idempotency, sqlserver, readiness |
idempotency-postgresql |
idempotency, postgresql, readiness |
app.MapHealthChecks("/health/ready", new() { Predicate = hc => hc.Tags.Contains("readiness") });
Observability
Built-in OpenTelemetry instrumentation under the LowCodeHub.Idempotency source:
Metrics
| Metric | Type | Description |
|---|---|---|
idempotency.requests.total |
Counter | Total requests processed |
idempotency.cache.hit |
Counter | Responses replayed from store |
idempotency.cache.miss |
Counter | New requests (no existing record) |
idempotency.cache.conflict |
Counter | Concurrent duplicates (409) |
idempotency.cache.fingerprint_mismatch |
Counter | Key reuse with different body (422) |
idempotency.store.errors |
Counter | Storage backend errors |
idempotency.request.duration |
Histogram (ms) | Request processing duration |
Traces
| Activity | Description |
|---|---|
idempotency.check |
Checking store for existing record |
idempotency.store |
Storing response after execution |
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("LowCodeHub.Idempotency"))
.WithMetrics(m => m.AddMeter("LowCodeHub.Idempotency"));
Custom Storage Implementation
Implement IIdempotencyStore to use a different backend:
public interface IIdempotencyStore
{
Task<IdempotencyRecord?> TryGetAsync(string key, CancellationToken ct);
Task<bool> TryLockAsync(string key, string requestFingerprint,
DateTimeOffset expiresAtUtc, CancellationToken ct);
Task CompleteAsync(string key, int statusCode,
Dictionary<string, string> responseHeaders, byte[] responseBody,
CancellationToken ct);
Task RemoveLockAsync(string key, CancellationToken ct);
Task<IReadOnlyList<IdempotencyRecord>> QueryAsync(
IdempotencyQueryFilter filter, CancellationToken ct);
}
Register your implementation before calling AddIdempotency:
builder.Services.AddSingleton<IIdempotencyStore, MyMongoIdempotencyStore>();
builder.Services.AddIdempotency(builder.Configuration);
// No need to call AddIdempotencyRedis/SqlServer/PostgreSql
The library uses TryAddSingleton, so your registration takes precedence.
Requirements
- .NET 10 or later
- Redis (via
StackExchange.Redis), SQL Server (viaMicrosoft.Data.SqlClient), or PostgreSQL (viaNpgsql) — or your own store implementation - All database drivers are included as dependencies
License
MIT © Ahmed Abuelnour
| 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
- Microsoft.Data.SqlClient (>= 7.0.0)
- Npgsql (>= 10.0.2)
- StackExchange.Redis (>= 2.12.14)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.