Round #284
08 May 2026, 12:25· csharp· openai/gpt-oss-120b:free· r13-clean-hints
- Complexity
- band 0
- ZP / UO / OBS
- 4 / 0 / 0
- Journey weight
- 288
- 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-08T12:25:29Z
Implement idempotency key middleware that deduplicates POST requests
ZERO POINT — 4 entries (structural invariants)
● hsl(314.84, 100%, 27%) IMiddleware [contract]
● hsl(280.69, 100%, 27%) RequestDelegate [contract]
● hsl(329.61, 100%, 24%) IDistributedCache [contract]
● hsl(136.10, 100%, 34%) 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 deduplicates POST (or any non‑GET) requests.
The middleware:
* Looks for a request header (by default **`Idempotency-Key`**).
* Stores the request body together with the response (status code, headers, body) in a configurable cache.
* On a subsequent request with the same key it **returns the cached response** without re‑executing the downstream pipeline.
* Works for any content type (JSON, form‑urlencoded, multipart, etc.).
* Is safe for distributed scenarios when you plug in a distributed cache (e.g., Redis, SQL Server, NCache).
* Allows per‑endpoint opt‑in/opt‑out and custom key‑generation logic.
* Returns **409 Conflict** when the same key is used with a different HTTP method or with a different request payload (optional strict mode).
---
## 1. Package References
Add the following NuGet packages to your project (the core one is already part of ASP.NET Core, the others are optional depending on the cache you choose):
```xml
<ItemGroup>
<!-- Core ASP.NET Core -->
<PackageReference Include="Microsoft.AspNetCore.App" />
<!-- Distributed cache (choose one) -->
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" />
<!-- or -->
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.0" />
</ItemGroup>
```
---
## 2. Data Model (what we store in the cache)
```csharp
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace IdempotencyMiddleware
{
/// <summary>
/// Represents a cached response for a given idempotency key.
/// </summary>
public sealed class IdempotentResponse
{
public int StatusCode { get; set; }
public Dictionary<string, string[]> Headers { get; set; } = new();
public byte[] Body { get; set; } = Array.Empty<byte>();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
// Optional: store a hash of the request payload to detect mismatched bodies.
public string? RequestHash { get; set; }
}
}
```
The model is deliberately simple and serializable by `System.Text.Json`.
---
## 3. Options class
```csharp
using System;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
namespace IdempotencyMiddleware
{
public class IdempotencyOptions
{
/// <summary>
/// Header name that carries the idempotency key. Default: "Idempotency-Key".
/// </summary>
public string HeaderName { get; set; } = "Idempotency-Key";
/// <summary>
/// How long a cached response lives. Default: 24 hours.
/// </summary>
public TimeSpan CacheEntryExpiration { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Whether to enforce strict payload matching.
/// If true, a request with the same key but a different body will be rejected with 409.
/// </summary>
public bool StrictPayload { get; set; } = false;
/// <summary>
/// Function that decides whether a request should be processed by the middleware.
/// By default only POST, PUT, PATCH, DELETE are considered.
/// </summary>
public Func<HttpContext, bool> ShouldHandleRequest { get; set; } = ctx =>
ctx.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
ctx.Request.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
ctx.Request.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase) ||
ctx.Request.Method.Equals("DELETE", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Optional custom key generator (e.g., combine header + user id).
/// Return null to skip handling.
/// </summary>
public Func<HttpContext, string?>? GenerateKey { get; set; }
/// <summary>
/// Cache implementation. If not supplied, an in‑memory cache is created.
/// </summary>
public IDistributedCache? DistributedCache { get; set; }
/// <summary>
/// Optional logger name.
/// </summary>
public string? LoggerCategory { get; set; }
}
}
```
---
## 4. Middleware implementation
```csharp
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace IdempotencyMiddleware
{
public class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly IdempotencyOptions _options;
private readonly IDistributedCache _cache;
private readonly ILogger _logger;
public IdempotencyMiddleware(RequestDelegate next, IdempotencyOptions options, ILoggerFactory loggerFactory)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? throw new ArgumentNullException(nameof(options));
// Resolve cache
_cache = options.DistributedCache ??
new MemoryDistributedCache(new OptionsWrapper<MemoryDistributedCacheOptions>(new MemoryDistributedCacheOptions()));
_logger = loggerFactory.CreateLogger(options.LoggerCategory ?? typeof(IdempotencyMiddleware).FullName);
}
public async Task InvokeAsync(HttpContext context)
{
// 1️⃣ Decide if we should handle this request
if (!_options.ShouldHandleRequest(context))
{
await _next(context);
return;
}
// 2️⃣ Resolve the idempotency key
string? key = ResolveKey(context);
if (string.IsNullOrWhiteSpace(key))
{
// No key → just pass through (or you could reject with 400)
await _next(context);
return;
}
// 3️⃣ Try to fetch a cached response
var cached = await GetCachedResponseAsync(key);
if (cached != null)
{
// Optional strict payload check
if (_options.StrictPayload && !await IsSameRequestPayloadAsync(context, cached.RequestHash))
{
_logger.LogWarning("Idempotency key '{Key}' reused with a different payload.", key);
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsync("Idempotency key reused with a different request payload.");
return;
}
_logger.LogInformation("Returning cached response for idempotency key '{Key}'.", key);
await WriteCachedResponseAsync(context, cached);
return;
}
// 4️⃣ No cached entry → capture the response
var originalBody = context.Response.Body;
await using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
// Capture request payload hash (if strict mode)
string? requestHash = null;
if (_options.StrictPayload)
{
requestHash = await ComputeRequestHashAsync(context);
// Reset request body stream for downstream processing
context.Request.Body.Position = 0;
}
try
{
await _next(context);
}
finally
{
// 5️⃣ Store the response in cache
var responseToCache = await BuildCachedResponseAsync(context, memoryStream, requestHash);
await StoreResponseAsync(key, responseToCache);
// 6️⃣ Write the response back to the original stream
memoryStream.Position = 0;
await memoryStream.CopyToAsync(originalBody);
context.Response.Body = originalBody;
}
}
#region Helper methods
private string? ResolveKey(HttpContext ctx)
{
// Custom generator takes precedence
if (_options.GenerateKey != null)
{
return _options.GenerateKey(ctx);
}
// Default: read from header
if (ctx.Request.Headers.TryGetValue(_options.HeaderName, out var values))
{
// Take the first non‑empty value
var key = values.FirstOrDefault(v => !StringValues.IsNullOrEmpty(v));
return string.IsNullOrWhiteSpace(key) ? null : key;
}
return null;
}
private async Task<IdempotentResponse?> GetCachedResponseAsync(string key)
{
var raw = await _cache.GetAsync(key);
if (raw == null) return null;
try
{
return JsonSerializer.Deserialize<IdempotentResponse>(raw);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize cached idempotent response for key '{Key}'.", key);
// Corrupt entry – delete it
await _cache.RemoveAsync(key);
return null;
}
}
private async Task StoreResponseAsync(string key, IdempotentResponse response)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.CacheEntryExpiration
};
await _cache.SetAsync(key, bytes, options);
}
private async Task WriteCachedResponseAsync(HttpContext ctx, IdempotentResponse cached)
{
ctx.Response.StatusCode = cached.StatusCode;
// Clear any existing headers that might have been set earlier
ctx.Response.Headers.Clear();
foreach (var header in cached.Headers)
{
ctx.Response.Headers[header.Key] = header.Value;
}
// Ensure content‑length is correct
ctx.Response.ContentLength = cached.Body?.Length;
if (cached.Body?.Length > 0)
{
await ctx.Response.Body.WriteAsync(cached.Body, 0, cached.Body.Length);
}
}
private async Task<IdempotentResponse> BuildCachedResponseAsync(HttpContext ctx, MemoryStream capturedBody, string? requestHash)
{
// Ensure the stream is at the beginning
capturedBody.Position = 0;
var bodyBytes = capturedBody.ToArray();
// Copy headers (excluding hop‑by‑hop headers)
var headers = ctx.Response.Headers
.Where(h => !IsHopByHopHeader(h.Key))
.ToDictionary(
h => h.Key,
h => h.Value.ToArray()
);
return new IdempotentResponse
{
StatusCode = ctx.Response.StatusCode,
Headers = headers,
Body = bodyBytes,
CreatedAt = DateTimeOffset.UtcNow,
RequestHash = requestHash
};
}
private static bool IsHopByHopHeader(string headerName)
{
// According to RFC 2616 / 7230
var hopByHop = new[]
{
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"TE", "Trailer", "Transfer-Encoding", "Upgrade"
};
return hopByHop.Contains(headerName, StringComparer.OrdinalIgnoreCase);
}
private async Task<string> ComputeRequestHashAsync(HttpContext ctx)
{
// Ensure the request body can be read multiple times
ctx.Request.EnableBuffering();
using var sha256 = SHA256.Create();
await using var cryptoStream = new CryptoStream(Stream.Null, sha256, CryptoStreamMode.Write);
await ctx.Request.Body.CopyToAsync(cryptoStream);
await cryptoStream.FlushAsync();
var hash = sha256.Hash!;
// Reset position for downstream pipeline
ctx.Request.Body.Position = 0;
return Convert.ToBase64String(hash);
}
private async Task<bool> IsSameRequestPayloadAsync(HttpContext ctx, string? storedHash)
{
if (storedHash == null) return true; // nothing to compare
var currentHash = await ComputeRequestHashAsync(ctx);
return storedHash == currentHash;
}
#endregion
}
// Extension method for easy registration
public static class IdempotencyMiddlewareExtensions
{
public static IApplicationBuilder UseIdempotency(this IApplicationBuilder builder,
Action<IdempotencyOptions>? configure = null)
{
var options = new IdempotencyOptions();
configure?.Invoke(options);
return builder.UseMiddleware<IdempotencyMiddleware>(options);
}
}
}
```
### How it works
| Step | What happens |
|------|--------------|
| **1** | Middleware checks `ShouldHandleRequest`. By default only non‑GET verbs are processed. |
| **2** | It extracts the key from the configured header (`Idempotency-Key`) or via a custom delegate. |
| **3** | Looks for a cached entry in the configured `IDistributedCache`. |
| **4** | If found → optional strict payload verification → writes cached status, headers, and body to the response and **returns**. |
| **5** | If not found → the response body is swapped with a `MemoryStream` so we can capture it after the downstream pipeline finishes. |
| **6** | After the pipeline, the captured response (status, headers, body) together with an optional request‑body hash is stored in the cache for the configured TTL. |
| **7** | The captured body is flushed back to the original response stream so the client receives the normal response. |
---
## 5. Registering the middleware
### a) Simple in‑memory usage (good for dev / single‑instance)
```csharp
public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// other middlewares …
app.UseIdempotency(opts =>
{
opts.HeaderName = "Idempotency-Key";
opts.CacheEntryExpiration = TimeSpan.FromHours(12);
opts.StrictPayload = true; // optional
});
// MVC / minimal APIs …
app.UseRouting();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}
}
```
### b) Distributed Redis cache (recommended for multi‑instance)
```csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddStackExchangeRedisCache(opt =>
{
opt.Configuration = "localhost:6379";
opt.InstanceName = "Idempotency_";
});
// Register the cache so the middleware can pick it up
services.AddSingleton<IDistributedCache>(sp => sp.GetRequiredService<IDistributedCache>());
}
```
```csharp
public void Configure(IApplicationBuilder app)
{
app.UseIdempotency(opts =>
{
opts.DistributedCache = app.ApplicationServices.GetRequiredService<IDistributedCache>();
opts.StrictPayload = true;
});
// …
}
```
### c) Custom key generation (e.g., combine user id + header)
```csharp
app.UseIdempotency(opts =>
{
opts.GenerateKey = ctx =>
{
// If the user is authenticated, prepend the user id to avoid cross‑user collisions
var userId = ctx.User?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userId)) return null;
if (!ctx.Request.Headers.TryGetValue("Idempotency-Key", out var header)) return null;
var clientKey = header.FirstOrDefault();
return $"{userId}:{clientKey}";
};
});
```
---
## 6. Testing the middleware
### Unit test (xUnit + Microsoft.AspNetCore.TestHost)
```csharp
public class IdempotencyMiddlewareTests
{
private readonly TestServer _server;
private readonly HttpClient _client;
public IdempotencyMiddlewareTests()
{
var builder = new WebHostBuilder()
.ConfigureServices(s => s.AddMemoryCache())
.Configure(app =>
{
app.UseIdempotency();
app.Run(async ctx =>
{
// Simulate a slow operation
await Task.Delay(50);
ctx.Response.StatusCode = 201;
await ctx.Response.WriteAsync($"Created at {DateTime.UtcNow:O}");
});
});
_server = new TestServer(builder);
_client = _server.CreateClient();
}
[Fact]
public async Task Duplicate_post_returns_cached_response()
{
var key = Guid.NewGuid().ToString();
var request1 = new HttpRequestMessage(HttpMethod.Post, "/")
{
Content = new StringContent("{\"value\":1}", Encoding.UTF8, "application/json")
};
request1.Headers.Add("Idempotency-Key", key);
var response1 = await _client.SendAsync(request1);
var body1 = await response1.Content.ReadAsStringAsync();
// second request – same key, same payload
var request2 = new HttpRequestMessage(HttpMethod.Post, "/")
{
Content = new StringContent("{\"value\":1}", Encoding.UTF8, "application/json")
};
request2.Headers.Add("Idempotency-Key", key);
var response2 = await _client.SendAsync(request2);
var body2 = await response2.Content.ReadAsStringAsync();
Assert.Equal(response1.StatusCode, response2.StatusCode);
Assert.Equal(body1, body2);
}
[Fact]
public async Task Duplicate_with_different_payload_strict_mode_returns_409()
{
var key = Guid.NewGuid().ToString();
var server = new WebHostBuilder()
.ConfigureServices(s => s.AddMemoryCache())
.Configure(app =>
{
app.UseIdempotency(opts => opts.StrictPayload = true);
app.Run(async ctx =>
{
await ctx.Response.WriteAsync("ok");
});
})
.Build();
var client = server.GetTestClient();
var req1 = new HttpRequestMessage(HttpMethod.Post, "/")
{
Content = new StringContent("{\"a\":1}", Encoding.UTF8, "application/json")
};
req1.Headers.Add("Idempotency-Key", key);
var resp1 = await client.SendAsync(req1);
Assert.Equal(HttpStatusCode.OK, resp1.StatusCode);
var req2 = new HttpRequestMessage(HttpMethod.Post, "/")
{
Content = new StringContent("{\"a\":2}", Encoding.UTF8, "application/json")
};
req2.Headers.Add("Idempotency-Key", key);
var resp2 = await client.SendAsync(req2);
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
}
}
```
---
## 7. Things to keep in mind
| Concern | Guidance |
|---------|----------|
| **Cache size** | Idempotent responses can be large (e.g., file uploads). Consider limiting the size or storing only a reference (e.g., a blob key) for very big payloads. |
| **TTL** | Choose a TTL that matches your business semantics. For “create order” you might keep it 24 h; for “payment” maybe only a few minutes. |
| **Security** | The key is supplied by the client; never trust it for authentication. Combine it with user‑specific data if you need per‑user isolation. |
| **Idempotent vs. safe** | This middleware only deduplicates *non‑GET* requests. GETs are already safe and cache‑friendly. |
| **Streaming responses** | The current implementation buffers the whole response in memory. For very large streaming responses you would need a different strategy (e.g., write to a temporary file). |
| **Distributed cache consistency** | When using Redis or SQL Server, the cache is shared across all instances, guaranteeing deduplication even in a scaled‑out environment. |
---
## 8. Full source tree (for copy‑paste)
```
/IdempotencyMiddleware
│ IdempotentResponse.cs
│ IdempotencyOptions.cs
│ IdempotencyMiddleware.cs
│ IdempotencyMiddlewareExtensions.cs
│ (optional) IdempotencyMiddlewareTests.cs
```
You can drop the four `.cs` files into any ASP.NET Core project, register the middleware with `app.UseIdempotency(...)`, and you’re ready to safely deduplicate POST/PUT/PATCH/DELETE calls using an **Idempotency‑Key** header.
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, NCache) for a stored entry with that key.
* If an entry exists, the cached response (status code, headers, body) is written back to the client – the request is **deduplicated**.
* If no entry exists, the request is allowed to continue down the pipeline.
* The response is captured, cached for the configured TTL, and then written to the client.
The implementation uses only built‑in ASP.NET Core abstractions (`IMiddleware`, `RequestDelegate`) and `IDistributedCache`. It is deliberately cache‑agnostic – you can plug any `IDistributedCache` implementation (Redis, SQL Server, NCache, MemoryDistributedCache, etc.) via DI.
---
## 1. Middleware implementation
```csharp
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using System.Text.Json;
namespace MyApp.Middleware;
/// <summary>
/// Middleware that enforces idempotency for non‑GET requests using the
/// Idempotency-Key header. It stores the response in an <see cref="IDistributedCache"/>
/// so that duplicate requests with the same key return the cached response
/// instead of re‑executing the endpoint logic.
/// </summary>
public sealed class IdempotencyKeyMiddleware : IMiddleware
{
private const string HeaderName = "Idempotency-Key";
private readonly IDistributedCache _cache;
private readonly ILogger<IdempotencyKeyMiddleware> _logger;
private readonly TimeSpan _cacheEntryLifetime;
/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="cache">Distributed cache used to store responses.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="cacheEntryLifetime">
/// How long a cached response is considered valid. Default: 24 h.
/// </param>
public IdempotencyKeyMiddleware(
IDistributedCache cache,
ILogger<IdempotencyKeyMiddleware> logger,
TimeSpan? cacheEntryLifetime = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cacheEntryLifetime = cacheEntryLifetime ?? TimeSpan.FromHours(24);
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// Only apply to methods that can change state.
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;
}
// The client must supply an Idempotency-Key header.
if (!context.Request.Headers.TryGetValue(HeaderName, out var keyValues) ||
string.IsNullOrWhiteSpace(keyValues))
{
_logger.LogWarning("Missing or empty {HeaderName} header on {Method} {Path}",
HeaderName, context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync($"{HeaderName} header is required.");
return;
}
var idempotencyKey = keyValues.ToString();
// Try to fetch a cached response.
var cached = await _cache.GetAsync(idempotencyKey);
if (cached != null)
{
_logger.LogInformation("Idempotent request hit cache for key {Key}", idempotencyKey);
await WriteCachedResponseAsync(context, cached);
return;
}
// No cached entry – capture the response for later caching.
var originalBody = context.Response.Body;
await using var memoryStream = new MemoryStream();
context.Response.Body = memoryStream;
try
{
await next(context);
}
catch (Exception ex)
{
// If the downstream pipeline throws, we do NOT cache the response.
_logger.LogError(ex, "Exception while processing request with Idempotency-Key {Key}", idempotencyKey);
// Restore original body before re‑throwing.
context.Response.Body = originalBody;
throw;
}
// Ensure the response body is fully written.
await context.Response.Body.FlushAsync();
// Read the captured response.
memoryStream.Seek(0, SeekOrigin.Begin);
var responseBody = await new StreamReader(memoryStream, Encoding.UTF8).ReadToEndAsync();
// Build a cache payload that contains status, headers and body.
var payload = new CachedResponse
{
StatusCode = context.Response.StatusCode,
Headers = context.Response.Headers,
Body = responseBody
};
var serialized = JsonSerializer.Serialize(payload);
var bytes = Encoding.UTF8.GetBytes(serialized);
// Store in cache.
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _cacheEntryLifetime
};
await _cache.SetAsync(idempotencyKey, bytes, options);
_logger.LogInformation("Cached response for Idempotency-Key {Key} (TTL {TTL})",
idempotencyKey, _cacheEntryLifetime);
// Reset the stream position and copy to the original response body.
memoryStream.Seek(0, SeekOrigin.Begin);
await memoryStream.CopyToAsync(originalBody);
context.Response.Body = originalBody;
}
private static async Task WriteCachedResponseAsync(HttpContext context, byte[] cachedBytes)
{
var json = Encoding.UTF8.GetString(cachedBytes);
var cached = JsonSerializer.Deserialize<CachedResponse>(json);
if (cached == null)
{
// Corrupted cache entry – treat as a miss.
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsync("Corrupted idempotency cache entry.");
return;
}
context.Response.StatusCode = cached.StatusCode;
// Clear any existing headers that might have been set earlier.
context.Response.Headers.Clear();
foreach (var header in cached.Headers)
{
// Some headers (e.g., Set-Cookie) can have multiple values.
context.Response.Headers[header.Key] = header.Value;
}
// Write the stored body.
await context.Response.WriteAsync(cached.Body);
}
// Helper DTO for serialization.
private sealed class CachedResponse
{
public int StatusCode { get; set; }
public IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
public string Body { get; set; } = string.Empty;
}
}
```
### How it works
| Step | Description |
|------|-------------|
| **1. Method filter** | Only POST/PUT/PATCH/DELETE are considered; GET/HEAD are passed through unchanged. |
| **2. Header validation** | Returns **400 Bad Request** if the `Idempotency-Key` header is missing or empty. |
| **3. Cache lookup** | `IDistributedCache.GetAsync(key)` – if found, the stored response (status, headers, body) is written back and the request pipeline stops. |
| **4. Capture response** | The original `HttpResponse.Body` is swapped with a `MemoryStream`. After the downstream endpoint finishes, the body is read, serialized together with status and headers, and stored in the cache. |
| **5. Write response** | The captured stream is copied back to the original response stream so the client receives the normal response. |
| **6. Error handling** | Exceptions from downstream are *not* cached; the original body stream is restored and the exception re‑thrown. Corrupted cache entries result in a **500** response. |
---
## 2. DI registration & pipeline integration
```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.DependencyInjection;
using MyApp.Middleware;
var builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------
// Distributed cache registration (example: Redis)
// ---------------------------------------------------------------------
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "IdempotencyDemo:";
});
// ---------------------------------------------------------------------
// Register the middleware as a service (IMiddleware)
// ---------------------------------------------------------------------
builder.Services.AddTransient<IdempotencyKeyMiddleware>();
// ---------------------------------------------------------------------
// Optional: expose configuration via IOptions if you want to make TTL configurable
// ---------------------------------------------------------------------
builder.Services.Configure<IdempotencyOptions>(builder.Configuration.GetSection("Idempotency"));
// ---------------------------------------------------------------------
// Build the app
// ---------------------------------------------------------------------
var app = builder.Build();
// ---------------------------------------------------------------------
// Insert the middleware early in the pipeline (before MVC, before
// authentication if you want the key to be evaluated even for unauthenticated
// requests). Adjust ordering to your needs.
// ---------------------------------------------------------------------
app.UseMiddleware<IdempotencyKeyMiddleware>();
// ---------------------------------------------------------------------
// Typical MVC / minimal API endpoints
// ---------------------------------------------------------------------
app.MapPost("/orders", async (HttpContext ctx) =>
{
// Simulate work
await Task.Delay(100);
return Results.Created($"/orders/{Guid.NewGuid()}", new { Message = "Order created" });
});
app.Run();
```
### Configuration class (optional)
```csharp
namespace MyApp.Middleware;
/// <summary>
/// Allows external configuration of the idempotency middleware (e.g. via appsettings.json).
/// </summary>
public sealed class IdempotencyOptions
{
/// <summary>
/// How long a cached response lives. Default is 24 h.
/// </summary>
public TimeSpan CacheLifetime { get; set; } = TimeSpan.FromHours(24);
}
```
If you want the middleware to read the TTL from `IOptions<IdempotencyOptions>`, inject it in the constructor and use `options.CacheLifetime`.
---
## 3. Usage notes & best practices
* **Idempotency‑Key generation** – Clients should generate a UUID (or any globally unique value) *per logical operation* and reuse it for retries.
* **Cache size** – Because each cached entry stores the full response body, monitor cache memory/size. For large payloads consider compressing the body before caching.
* **Security** – The key is opaque; do **not** expose internal identifiers. Treat it as a secret – an attacker could replay a request if they guess a key.
* **TTL** – Choose a TTL that matches the business semantics (e.g., 5 min for quick operations, 24 h for order creation).
* **Idempotent GET** – GET is already safe; the middleware skips it. If you need to deduplicate other verbs, add them to the method filter.
* **Distributed cache** – In a multi‑instance deployment you *must* use a distributed cache (Redis, SQL Server, NCache, etc.) so that all nodes share the same deduplication state.
---
### Summary
The `IdempotencyKeyMiddleware` above gives you a drop‑in, production‑ready solution for deduplicating POST (and other state‑changing) requests using the `Idempotency-Key` header. It leverages ASP.NET Core’s `IMiddleware` abstraction, works with any `IDistributedCache` implementation, and includes thorough logging, error handling, and easy DI registration.
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-haiku-4-5-20251001
BetterV4qwen2.5-coder-7b-instruct
BetterComments
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.