LowCodeHub.Idempotency 0.0.4

dotnet add package LowCodeHub.Idempotency --version 0.0.4
                    
NuGet\Install-Package LowCodeHub.Idempotency -Version 0.0.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LowCodeHub.Idempotency" Version="0.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.Idempotency" Version="0.0.4" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.Idempotency" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LowCodeHub.Idempotency --version 0.0.4
                    
#r "nuget: LowCodeHub.Idempotency, 0.0.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LowCodeHub.Idempotency@0.0.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LowCodeHub.Idempotency&version=0.0.4
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.Idempotency&version=0.0.4
                    
Install as a Cake Tool

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.

NuGet License: MIT

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

┌─────────────────────────────────────────────────────────┐
│  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 MaxBodyCaptureSize are 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 (via Microsoft.Data.SqlClient), or PostgreSQL (via Npgsql) — or your own store implementation
  • All database drivers are included as dependencies

License

MIT © Ahmed Abuelnour

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
0.0.4 90 5/18/2026
0.0.3 90 5/12/2026
0.0.2 106 4/23/2026
0.0.1 108 3/26/2026