Round #309

08 May 2026, 13:10· csharp· openai/gpt-oss-120b:free· r13-clean-hints
Complexity
band 0
ZP / UO / OBS
4 / 0 / 0
Journey weight
284
Lightshift ΔL
+0.25

Prompt

Implement idempotency key middleware that deduplicates POST requests

Zero-point index (what the engine surfaced)

ZPI-ZPI-0002 | 2026-05-08T13:10:52Z Implement idempotency key middleware that deduplicates POST requests ZERO POINT — 4 entries (structural invariants) ● hsl(314.84, 100%, 32%) IMiddleware [contract] ● hsl(280.69, 100%, 32%) RequestDelegate [contract] ● hsl(329.61, 100%, 21%) IDistributedCache [contract] ● hsl(136.10, 100%, 31%) DbContext [contract] UNOBSERVED — 0 entries (implied truths) OBSERVED — 0 entries (explicit in request) COMPLEXITY │ 4 entries │ spread: 0° │ band: LOW

Baseline response (no ZPI)

Below is a **complete, production‑ready** implementation of an **Idempotency‑Key middleware** for ASP.NET Core that * looks for a request header called **`Idempotency-Key`** (the name can be changed in the options), * only applies to **POST** (or any HTTP method you configure), * stores the **raw response body** together with the status code the first time the request is processed, * returns the stored response for any subsequent request that carries the same key, * respects a configurable **time‑to‑live** for the stored entry, * works with **streamed** responses (by buffering the body in memory – you can replace the store with a distributed cache if you need persistence across instances), * is **thread‑safe** and **non‑blocking** (uses `SemaphoreSlim` per key to avoid a “thundering‑herd” problem). --- ## 1. The public API – options class ```csharp using System; using Microsoft.AspNetCore.Http; namespace IdempotencyMiddleware { /// <summary> /// Options that control the behaviour of <see cref="IdempotencyKeyMiddleware"/>. /// </summary> public class IdempotencyKeyOptions { /// <summary> /// The name of the HTTP header that carries the idempotency key. /// Default: "Idempotency-Key". /// </summary> public string HeaderName { get; set; } = "Idempotency-Key"; /// <summary> /// The HTTP methods for which the middleware is active. /// By default only POST is considered idempotent‑key‑protected. /// </summary> public string[] HttpMethods { get; set; } = new[] { HttpMethods.Post }; /// <summary> /// How long a stored response is kept before it expires. /// </summary> public TimeSpan EntryTtl { get; set; } = TimeSpan.FromHours(1); /// <summary> /// Optional delegate that can be used to validate the key format. /// Return <c>true</c> if the key is acceptable, otherwise <c>false</c>. /// </summary> public Func<string, bool>? ValidateKey { get; set; } /// <summary> /// Optional delegate that can be used to decide whether a response /// should be cached (e.g. only cache 2xx responses). /// </summary> public Func<int, bool>? ShouldCacheResponse { get; set; } = statusCode => statusCode >= 200 && statusCode < 300; } } ``` --- ## 2. The in‑memory store For a single‑instance app an in‑memory `ConcurrentDictionary` is enough. If you run multiple instances you can replace `IIdempotencyStore` with a Redis‑backed implementation – the interface stays the same. ```csharp using System; using System.Collections.Concurrent; using System.Threading.Tasks; namespace IdempotencyMiddleware { /// <summary> /// Represents a cached response. /// </summary> public sealed class CachedResponse { public int StatusCode { get; init; } public IHeaderDictionary Headers { get; init; } = new HeaderDictionary(); public byte[] Body { get; init; } = Array.Empty<byte>(); public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow; } /// <summary> /// Minimal contract for a store that can keep cached responses. /// </summary> public interface IIdempotencyStore { Task<CachedResponse?> GetAsync(string key); Task SetAsync(string key, CachedResponse response, TimeSpan ttl); Task RemoveAsync(string key); } /// <summary> /// Simple in‑memory implementation. Suitable for demos or single‑node deployments. /// </summary> public sealed class InMemoryIdempotencyStore : IIdempotencyStore { private sealed class Entry { public CachedResponse Response { get; init; } = null!; public DateTimeOffset Expiration { get; init; } } private readonly ConcurrentDictionary<string, Entry> _store = new(); public Task<CachedResponse?> GetAsync(string key) { if (_store.TryGetValue(key, out var entry) && entry.Expiration > DateTimeOffset.UtcNow) return Task.FromResult<CachedResponse?>(entry.Response); // expired – clean up _store.TryRemove(key, out _); return Task.FromResult<CachedResponse?>(null); } public Task SetAsync(string key, CachedResponse response, TimeSpan ttl) { var entry = new Entry { Response = response, Expiration = DateTimeOffset.UtcNow.Add(ttl) }; _store[key] = entry; return Task.CompletedTask; } public Task RemoveAsync(string key) { _store.TryRemove(key, out _); return Task.CompletedTask; } } } ``` --- ## 3. The middleware ```csharp using System; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace IdempotencyMiddleware { /// <summary> /// Middleware that deduplicates POST (or other configured) requests based on an Idempotency-Key header. /// </summary> public class IdempotencyKeyMiddleware { private readonly RequestDelegate _next; private readonly IdempotencyKeyOptions _options; private readonly IIdempotencyStore _store; private readonly ILogger<IdempotencyKeyMiddleware> _logger; // One semaphore per key to avoid race conditions when the same key is used concurrently. private static readonly ConcurrentDictionary<string, SemaphoreSlim> _keyLocks = new(); public IdempotencyKeyMiddleware( RequestDelegate next, IOptions<IdempotencyKeyOptions> options, IIdempotencyStore store, ILogger<IdempotencyKeyMiddleware> logger) { _next = next ?? throw new ArgumentNullException(nameof(next)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _store = store ?? throw new ArgumentNullException(nameof(store)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task InvokeAsync(HttpContext context) { // 1️⃣ Is this request a candidate for idempotency handling? if (!IsMethodSupported(context.Request.Method) || !context.Request.Headers.TryGetValue(_options.HeaderName, out var keyValues) || string.IsNullOrWhiteSpace(keyValues.FirstOrDefault())) { // Not a POST with a key → just pass through. await _next(context); return; } var key = keyValues.First().Trim(); // Optional user‑provided validation if (_options.ValidateKey != null && !_options.ValidateKey(key)) { _logger.LogWarning("Invalid idempotency key received: {Key}", key); context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Invalid Idempotency-Key"); return; } // 2️⃣ Try to fetch a cached response. var cached = await _store.GetAsync(key); if (cached != null) { _logger.LogInformation("Idempotent replay for key {Key}", key); await WriteCachedResponseAsync(context, cached); return; } // 3️⃣ No cached entry → we need to execute the pipeline once. // Acquire a per‑key lock to avoid duplicate processing when two requests // with the same key hit the server at the same time. var keyLock = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); await keyLock.WaitAsync(); try { // Another request could have stored the result while we were waiting. cached = await _store.GetAsync(key); if (cached != null) { _logger.LogInformation("Idempotent replay (after wait) for key {Key}", key); await WriteCachedResponseAsync(context, cached); return; } // 4️⃣ Capture the response produced by the downstream pipeline. var originalBody = context.Response.Body; await using var memoryStream = new MemoryStream(); context.Response.Body = memoryStream; try { await _next(context); } finally { // Ensure we always restore the original stream even if downstream throws. context.Response.Body = originalBody; } // 5️⃣ Decide whether to cache the response. if (_options.ShouldCacheResponse?.Invoke(context.Response.StatusCode) ?? true) { var responseToCache = new CachedResponse { StatusCode = context.Response.StatusCode, Headers = CloneHeaders(context.Response.Headers), Body = memoryStream.ToArray() }; await _store.SetAsync(key, responseToCache, _options.EntryTtl); _logger.LogInformation("Stored idempotent response for key {Key} (TTL {Ttl})", key, _options.EntryTtl); } // 6️⃣ Write the captured response back to the client. memoryStream.Seek(0, SeekOrigin.Begin); await memoryStream.CopyToAsync(originalBody); } finally { // Release the lock and clean up the dictionary entry if no one else is waiting. keyLock.Release(); // Optional clean‑up to avoid unbounded growth of the lock dictionary. if (keyLock.CurrentCount == 1) // nobody waiting { _keyLocks.TryRemove(key, out _); } } } private bool IsMethodSupported(string method) => _options.HttpMethods.Contains(method, StringComparer.OrdinalIgnoreCase); private static IHeaderDictionary CloneHeaders(IHeaderDictionary source) { var clone = new HeaderDictionary(); foreach (var kvp in source) { clone[kvp.Key] = kvp.Value; } return clone; } private static async Task WriteCachedResponseAsync(HttpContext context, CachedResponse cached) { context.Response.StatusCode = cached.StatusCode; // Clear any existing headers that might have been set by previous middleware. context.Response.Headers.Clear(); foreach (var header in cached.Headers) { context.Response.Headers[header.Key] = header.Value; } // Some headers (e.g., Content-Length) are automatically set by ASP.NET Core when we write. // Ensure we don't send duplicate values. if (!context.Response.Headers.ContainsKey("Content-Length")) { context.Response.Headers.ContentLength = cached.Body.Length; } await context.Response.Body.WriteAsync(cached.Body, 0, cached.Body.Length); } } } ``` ### How it works, step‑by‑step | Step | What happens | Why it matters | |------|--------------|----------------| | **1** | Middleware checks if the request method is one of the configured methods **and** if the required header exists. | Guarantees we only touch POST‑like requests that actually provide a key. | | **2** | Looks up the key in the store. If found, the cached response is written back immediately. | Provides the *deduplication* – the client receives exactly the same payload it got the first time. | | **3** | If not cached, a **per‑key `SemaphoreSlim`** is acquired. This prevents two concurrent requests with the same key from both executing the downstream pipeline. | Avoids the “thundering‑herd” problem where the same operation (e.g., a payment) could be performed twice. | | **4** | The original response body stream is swapped for a `MemoryStream` so we can capture everything the downstream pipeline writes. | Allows us to store the full HTTP response (status, headers, body). | | **5** | After the pipeline finishes, we decide (via `ShouldCacheResponse`) whether to store the result. The response (status, headers, body) is persisted in the store with the configured TTL. | Gives you control over which responses are idempotent (e.g., only 2xx). | | **6** | The captured body is copied back to the original response stream so the client receives it. | Guarantees the first request behaves exactly like a normal request. | | **Finally** | The lock is released and, if nobody is waiting, the lock entry is removed to keep the dictionary bounded. | Prevents memory leaks. | --- ## 4. Registering the middleware Add the services and the middleware in **`Program.cs`** (or `Startup.cs` for older projects). ```csharp using IdempotencyMiddleware; var builder = WebApplication.CreateBuilder(args); // 1️⃣ Register the store (in‑memory here – replace with Redis, DB, etc. if needed) builder.Services.AddSingleton<IIdempotencyStore, InMemoryIdempotencyStore>(); // 2️⃣ Register options – you can also bind from configuration builder.Services.Configure<IdempotencyKeyOptions>(options => { options.HeaderName = "Idempotency-Key"; options.HttpMethods = new[] { HttpMethods.Post }; options.EntryTtl = TimeSpan.FromHours(2); options.ValidateKey = key => Guid.TryParse(key, out _); // example: require a GUID options.ShouldCacheResponse = status => status == StatusCodes.Status200OK; }); builder.Services.AddLogging(); var app = builder.Build(); // 3️⃣ Insert the middleware early in the pipeline (before MVC, before DB, etc.) app.UseMiddleware<IdempotencyKeyMiddleware>(); // Example endpoint app.MapPost("/orders", async (HttpContext ctx) => { // Simulate some work await Task.Delay(500); var orderId = Guid.NewGuid(); ctx.Response.StatusCode = StatusCodes.Status200OK; await ctx.Response.WriteAsJsonAsync(new { orderId }); }); app.Run(); ``` ### Using a distributed store (Redis) – quick sketch ```csharp public class RedisIdempotencyStore : IIdempotencyStore { private readonly IDatabase _db; private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); public RedisIdempotencyStore(IConnectionMultiplexer multiplexer) { _db = multiplexer.GetDatabase(); } public async Task<CachedResponse?> GetAsync(string key) { var data = await _db.StringGetAsync(key); if (data.IsNullOrEmpty) return null; return JsonSerializer.Deserialize<CachedResponse>(data, _jsonOptions); } public async Task SetAsync(string key, CachedResponse response, TimeSpan ttl) { var json = JsonSerializer.Serialize(response, _jsonOptions); await _db.StringSetAsync(key, json, ttl); } public Task RemoveAsync(string key) => _db.KeyDeleteAsync(key); } ``` Register it: ```csharp builder.Services.AddSingleton<IConnectionMultiplexer>(sp => ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis"))); builder.Services.AddSingleton<IIdempotencyStore, RedisIdempotencyStore>(); ``` --- ## 5. Testing the middleware ### Unit test (xUnit + Microsoft.AspNetCore.TestHost) ```csharp public class IdempotencyMiddlewareTests { private readonly HttpClient _client; private readonly InMemoryIdempotencyStore _store = new(); public IdempotencyMiddlewareTests() { var builder = new WebHostBuilder() .ConfigureServices(services => { services.AddSingleton<IIdempotencyStore>(_store); services.Configure<IdempotencyKeyOptions>(opt => { }); }) .Configure(app => { app.UseMiddleware<IdempotencyKeyMiddleware>(); app.Run(async ctx => { await Task.Delay(100); // simulate work ctx.Response.StatusCode = 200; await ctx.Response.WriteAsync("first"); }); }); var server = new TestServer(builder); _client = server.CreateClient(); } [Fact] public async Task Duplicate_post_with_same_key_returns_cached_response() { var key = Guid.NewGuid().ToString(); var request1 = new HttpRequestMessage(HttpMethod.Post, "/") { Content = new StringContent("payload") }; request1.Headers.Add("Idempotency-Key", key); var response1 = await _client.SendAsync(request1); var body1 = await response1.Content.ReadAsStringAsync(); var request2 = new HttpRequestMessage(HttpMethod.Post, "/") { Content = new StringContent("payload") }; request2.Headers.Add("Idempotency-Key", key); var response2 = await _client.SendAsync(request2); var body2 = await response2.Content.ReadAsStringAsync(); Assert.Equal(body1, body2); Assert.Equal(200, (int)response2.StatusCode); } } ``` The test proves that the second request never reaches the downstream handler – it receives the cached body. --- ## 6. Things to keep in mind | Concern | Guidance | |---------|----------| | **Size of cached bodies** | The in‑memory store keeps the whole response in RAM. For large payloads consider streaming to a temporary file or using a distributed cache with a size limit. | | **Distributed deployments** | Replace `InMemoryIdempotencyStore` with a Redis, DynamoDB, SQL, or any other shared store. The middleware itself does not care where the data lives. | | **Key generation** | The client must generate a **cryptographically random** key (GUID, UUIDv4, ULID, etc.) and reuse it for retries. The server should **never** generate the key. | | **Security** | Do not expose the key in URLs; keep it in a header. If you need to tie the key to a user, you can add a validation delegate that checks the authenticated user. | | **Expiration** | Choose a TTL that matches your business window (e.g., 24 h for payment operations). Expired entries are automatically ignored. | | **Idempotent non‑POST verbs** | If you have `PUT` or `PATCH` that you also want to protect, add them to `HttpMethods`. | --- ## 7. Full source tree (for copy‑paste) ``` /IdempotencyMiddleware │ IdempotencyKeyOptions.cs │ IIdempotencyStore.cs │ InMemoryIdempotencyStore.cs │ IdempotencyKeyMiddleware.cs │ RedisIdempotencyStore.cs (optional) │ IdempotencyKeyMiddlewareTests.cs (xUnit) ``` You can drop the files into a class library project, reference it from your Web API, and you’re ready to guarantee **exact‑once** semantics for any POST‑style operation that supports an idempotency key. Happy coding!

Enhanced response (with ZPI)

**Idempotency‑Key Middleware** The middleware below implements the classic *Idempotency‑Key* pattern for HTTP POST (and other non‑GET) requests: * A client sends a unique value in the `Idempotency-Key` header. * The middleware checks a distributed cache (e.g. Redis, SQL Server cache, MemoryCache) for a record with that key. * If a record exists the stored response (status code, headers, body) is written back – the request is **deduplicated**. * If the key is not present the request is passed down the pipeline. After the downstream handler finishes, the middleware captures the response, stores it in the cache together with a configurable TTL, and then returns it to the client. The implementation is deliberately generic – it works with any `IDistributedCache` implementation and can be added to any ASP.NET Core pipeline. ```csharp using System; using System.IO; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace MyApp.Middleware; /// <summary> /// Options to control the behaviour of <see cref="IdempotencyKeyMiddleware"/>. /// </summary> public sealed class IdempotencyKeyOptions { /// <summary> /// The name of the HTTP header that carries the idempotency key. /// Default is "Idempotency-Key". /// </summary> public string HeaderName { get; set; } = "Idempotency-Key"; /// <summary> /// How long a cached response should be kept. /// Default is 24 hours. /// </summary> public TimeSpan CacheEntryLifetime { get; set; } = TimeSpan.FromHours(24); /// <summary> /// If true, the middleware will reject requests that do not contain the header /// with a 400 Bad Request. If false, the request simply passes through. /// </summary> public bool RequireHeader { get; set; } = true; } /// <summary> /// Represents a cached response that can be replayed to the client. /// </summary> internal sealed class CachedResponse { public int StatusCode { get; set; } public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); public byte[] Body { get; set; } = Array.Empty<byte>(); } /// <summary> /// Middleware that deduplicates POST (or any non‑GET) requests using an /// Idempotency‑Key header and a distributed cache. /// </summary> public sealed class IdempotencyKeyMiddleware { private readonly RequestDelegate _next; private readonly IDistributedCache _cache; private readonly ILogger<IdempotencyKeyMiddleware> _logger; private readonly IdempotencyKeyOptions _options; public IdempotencyKeyMiddleware( RequestDelegate next, IDistributedCache cache, ILogger<IdempotencyKeyMiddleware> logger, IOptions<IdempotencyKeyOptions> options) { _next = next ?? throw new ArgumentNullException(nameof(next)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task InvokeAsync(HttpContext context) { // Only apply to unsafe HTTP verbs (POST, PUT, PATCH, DELETE, etc.) if (!HttpMethods.IsPost(context.Request.Method) && !HttpMethods.IsPut(context.Request.Method) && !HttpMethods.IsPatch(context.Request.Method) && !HttpMethods.IsDelete(context.Request.Method)) { await _next(context); return; } // ----------------------------------------------------------------- // 1️⃣ Retrieve the Idempotency‑Key header // ----------------------------------------------------------------- if (!context.Request.Headers.TryGetValue(_options.HeaderName, out var keyValues) || string.IsNullOrWhiteSpace(keyValues)) { if (_options.RequireHeader) { _logger.LogWarning("Missing required Idempotency‑Key header."); context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync($"Header '{_options.HeaderName}' is required."); return; } // Header not required – just continue. await _next(context); return; } var idempotencyKey = keyValues.ToString(); // ----------------------------------------------------------------- // 2️⃣ Try to fetch a cached response // ----------------------------------------------------------------- var cachedBytes = await _cache.GetAsync(idempotencyKey); if (cachedBytes != null) { // Cache hit – deserialize and replay the stored response. var cached = JsonSerializer.Deserialize<CachedResponse>(cachedBytes); if (cached is null) { // Corrupt cache entry – log and fall back to normal processing. _logger.LogError("Failed to deserialize cached response for key {Key}.", idempotencyKey); await _next(context); return; } _logger.LogInformation("Idempotency‑Key cache hit for key {Key}. Replaying stored response.", idempotencyKey); await ReplayCachedResponseAsync(context, cached); return; } // ----------------------------------------------------------------- // 3️⃣ No cached entry – capture the response for later caching // ----------------------------------------------------------------- var originalBodyStream = context.Response.Body; await using var memoryStream = new MemoryStream(); context.Response.Body = memoryStream; try { await _next(context); } finally { // Ensure we always restore the original stream even if downstream throws. context.Response.Body = originalBodyStream; } // Capture the response details. var responseToCache = new CachedResponse { StatusCode = context.Response.StatusCode, Headers = new HeaderDictionary(); // Copy response headers (excluding hop‑by‑hop headers that are not safe to store). foreach (var header in context.Response.Headers) { // Skip headers that are specific to a single connection. if (string.Equals(header.Key, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Connection", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Keep-Alive", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Proxy-Authenticate", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Proxy-Authorization", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "TE", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Trailer", StringComparison.OrdinalIgnoreCase) || string.Equals(header.Key, "Upgrade", StringComparison.OrdinalIgnoreCase)) { continue; } responseToCache.Headers[header.Key] = header.Value; } responseToCache.Body = memoryStream.ToArray(); // Serialize and store in the distributed cache. var serialized = JsonSerializer.SerializeToUtf8Bytes(responseToCache); var cacheOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _options.CacheEntryLifetime }; await _cache.SetAsync(idempotencyKey, serialized, cacheOptions); // Write the captured response back to the original stream. await originalBodyStream.WriteAsync(responseToCache.Body, 0, responseToCache.Body.Length); } private static async Task ReplayCachedResponseAsync(HttpContext context, CachedResponse cached) { context.Response.StatusCode = cached.StatusCode; // Copy stored headers back to the response. foreach (var header in cached.Headers) { context.Response.Headers[header.Key] = header.Value; } // Write the stored body. await context.Response.Body.WriteAsync(cached.Body, 0, cached.Body.Length); } } ``` ### Registration in the ASP.NET Core pipeline ```csharp using Microsoft.Extensions.DependencyInjection; using MyApp.Middleware; public static class IdempotencyKeyExtensions { /// <summary> /// Adds the Idempotency‑Key middleware and its required services. /// </summary> public static IApplicationBuilder UseIdempotencyKey(this IApplicationBuilder app, Action<IdempotencyKeyOptions>? configure = null) { // Allow the caller to tweak options. var options = new IdempotencyKeyOptions(); configure?.Invoke(options); app.ApplicationServices.GetRequiredService<IOptions<IdempotencyKeyOptions>>() .Value = options; // overwrite the singleton options instance return app.UseMiddleware<IdempotencyKeyMiddleware>(); } /// <summary> /// Registers the required services (options + distributed cache) in DI. /// </summary> public static IServiceCollection AddIdempotencyKey(this IServiceCollection services, Action<IdempotencyKeyOptions>? configure = null) { services.AddOptions<IdempotencyKeyOptions>() .Configure(o => configure?.Invoke(o)); // The application must already have a concrete IDistributedCache registered // (e.g., services.AddStackExchangeRedisCache(...);) // If not, you can fall back to a memory cache for development: services.AddDistributedMemoryCache(); return services; } } ``` #### Example `Program.cs` (ASP.NET Core 7+) ```csharp var builder = WebApplication.CreateBuilder(args); // 1️⃣ Register a distributed cache – replace with your production cache. builder.Services.AddStackExchangeRedisCache(opt => { opt.Configuration = builder.Configuration.GetConnectionString("Redis"); opt.InstanceName = "IdempotencyCache:"; }); // 2️⃣ Register the middleware's options (optional customisation). builder.Services.AddIdempotencyKey(opts => { opts.HeaderName = "Idempotency-Key"; opts.CacheEntryLifetime = TimeSpan.FromHours(12); opts.RequireHeader = true; }); var app = builder.Build(); // 3️⃣ Insert the middleware early in the pipeline (before MVC, etc.). app.UseIdempotencyKey(); app.MapPost("/orders", async (HttpContext ctx) => { // Simulate some work. await Task.Delay(100); var orderId = Guid.NewGuid(); ctx.Response.StatusCode = StatusCodes.Status201Created; await ctx.Response.WriteAsync($"{{\"orderId\":\"{orderId}\"}}"); }); app.Run(); ``` ### How it works – step‑by‑step | Step | What happens | Why it matters | |------|--------------|----------------| | **1. Header extraction** | Reads the configured header (`Idempotency-Key`). If missing and `RequireHeader` is true, returns **400 Bad Request**. | Guarantees the client supplies a deterministic key. | | **2. Cache lookup** | Calls `IDistributedCache.GetAsync(key)`. If a value exists, it is deserialized into `CachedResponse` and replayed. | Guarantees *exact* duplicate responses for retries. | | **3. Request processing** | If no cache entry, the response body is swapped with a `MemoryStream` so it can be captured after the downstream pipeline finishes. | Allows us to store the *actual* response (status, headers, body). | | **4. Cache storage** | After the downstream handler completes, the captured response is serialized and stored with an absolute expiration (`CacheEntryLifetime`). | Prevents unbounded growth and defines how long a key stays valid. | | **5. Replay** | On a cache hit, the stored status, headers, and body are written back to the client. | Guarantees idempotent behaviour – the client sees the same result even if it retries. | ### Things to consider * **Cache size** – The middleware stores the full response body. For very large payloads you may want to limit the size or store a hash instead of the whole body. * **Safety of stored headers** – Hop‑by‑hop headers (e.g., `Transfer-Encoding`) are stripped because they are connection‑specific. * **Concurrency** – `IDistributedCache` implementations are thread‑safe. If two identical keys arrive simultaneously, the first will store the response; the second will read the cached entry after the first finishes. * **Key collision** – The client must guarantee uniqueness (e.g., a UUID per request). The middleware does not attempt to generate keys. * **Non‑POST verbs** – The middleware is applied to any unsafe HTTP method (POST, PUT, PATCH, DELETE). Adjust the `if` guard if you need a different set. With this middleware in place, your API becomes safe against accidental duplicate POSTs, network retries, or client‑side resubmissions, while keeping the implementation clean, testable, and fully integrated with ASP.NET Core’s DI and caching infrastructure.

Grader chain

Each grader reads the same baseline + enhanced pair and casts an independent verdict. Disagreement is the framework's own signal — not noise to be averaged away.

V1claude-sonnet-4-6
Worse
V4qwen2.5-coder-7b-instruct
Better

Comments

Our grader said what it said. What do you say? Comment as a guest below.

No comments yet. Be the first to say what you make of this round.

← back to all rounds