LowCodeHub.Idempotency
0.0.4
dotnet add package LowCodeHub.Idempotency --version 0.0.4
NuGet\Install-Package LowCodeHub.Idempotency -Version 0.0.4
<PackageReference Include="LowCodeHub.Idempotency" Version="0.0.4" />
<PackageVersion Include="LowCodeHub.Idempotency" Version="0.0.4" />
<PackageReference Include="LowCodeHub.Idempotency" />
paket add LowCodeHub.Idempotency --version 0.0.4
#r "nuget: LowCodeHub.Idempotency, 0.0.4"
#:package LowCodeHub.Idempotency@0.0.4
#addin nuget:?package=LowCodeHub.Idempotency&version=0.0.4
#tool nuget:?package=LowCodeHub.Idempotency&version=0.0.4
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
- Database Migrations
- 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 the SQL Server schema from Database Migrations.
PostgreSQL
builder.Services.AddIdempotencyPostgreSql(options =>
{
options.ConnectionString = "Host=...;Database=...;";
options.Schema = "public";
options.Table = "idempotency_records";
});
Requires the PostgreSQL schema from Database Migrations.
Database Migrations
LowCodeHub.Idempotency does not create or migrate its own database schema during service registration. The schema scripts ship as embedded resources in the package, but the consuming application owns when and how they are applied.
Embedded resource prefixes:
| Provider | Embedded resource prefix |
|---|---|
| SQL Server | LowCodeHub.Idempotency.Repositories.SqlServer.Scripts. |
| PostgreSQL | LowCodeHub.Idempotency.Repositories.PostgreSql.Scripts. |
The scripts create the idempotency records table and expiration index.
With LowCodeHub.Migration.SqlServer
using LowCodeHub.Idempotency.Options;
using LowCodeHub.Migration.SqlServer.Extensions;
builder.Services.AddMigration(o =>
{
o.ConnectionString = builder.Configuration.GetConnectionString("Idempotency")!;
o.Directories = ["LowCodeHub.Idempotency.Repositories.SqlServer.Scripts."];
});
await app.RunDatabaseMigrationAsync<SqlServerIdempotencyOptions>();
With LowCodeHub.Migration.PostgreSql
using LowCodeHub.Idempotency.Options;
using LowCodeHub.Migration.PostgreSql.Extensions;
builder.Services.AddMigration(o =>
{
o.ConnectionString = builder.Configuration.GetConnectionString("Idempotency")!;
o.Directories = ["LowCodeHub.Idempotency.Repositories.PostgreSql.Scripts."];
});
await app.RunDatabaseMigrationAsync<PostgreSqlIdempotencyOptions>();
Do not scan the whole LowCodeHub.Idempotency assembly without a directory filter, because the package contains scripts for both providers.
With EF Core Migrations
EF Core will not discover these embedded scripts automatically. Create an application-owned EF migration and execute the embedded scripts from Up.
using LowCodeHub.Idempotency.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
public partial class AddLowCodeHubIdempotencySchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
foreach (var resource in SqlIdempotency.SqlServerResources)
{
migrationBuilder.Sql(SqlIdempotency.ReadFromResource(resource));
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Drop Idempotency objects here if your application's migration policy requires reversible migrations.
}
}
For PostgreSQL, loop over SqlIdempotency.PostgreSqlResources instead.
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.1)
- Npgsql (>= 10.0.2)
- StackExchange.Redis (>= 2.13.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.