Idempotency-Key in ASP.NET Core (.NET 8): Doppelte POST-Anfragen verhindern
POST sicher wiederholen: Idempotency-Key aus Header lesen, erste Antwort speichern und bei Duplikaten identisch zurückgeben—keine doppelten Inserts.
POST-Anfragen sind standardmäßig nicht idempotent. Wenn ein Client (z. B. eine mobile App) wegen eines Netzwerk-Timeouts dieselbe POST-Anfrage erneut sendet, kann der Server sie zweimal verarbeiten. Das führt schnell zu doppelten Inserts, doppelter Abrechnung oder doppelten Reservierungen.
Eine praktische Lösung ist ein Idempotency-Key: Der Client sendet pro POST einen eindeutigen Key im Header. Der Server verarbeitet die Anfrage genau einmal, speichert die Antwort und spielt die ursprüngliche Antwort erneut ab (Replay), wenn derselbe Key noch einmal kommt. Damit werden Retries sicher.
Ziel
- Den Header
Idempotency-Keyfür POST-Endpunkte unterstützen. - Pro Key die Anfrage innerhalb einer TTL nur einmal ausführen.
- Bei Duplikaten denselben Statuscode + Response-Body zurückgeben.
- Die Implementierung einfach halten (In-Memory-Store für Single-Instance).
Wie funktioniert das?
- Client sendet
POST /api/reservationsund setztIdempotency-Key: ... - Server prüft, ob der Key bereits gespeichert ist
- Wenn vorhanden: gecachte Antwort zurückgeben (optional:
Idempotency-Replay: true) - Wenn nicht vorhanden: Endpunkt ausführen, Antwort abfangen, speichern und zurückgeben
Schritt 1: Kleiner Store-Vertrag
In Produktion sollte Idempotenz bei mehreren Instanzen meist in einem geteilten Store liegen (Redis/DB). In diesem Beispiel verwenden wir einen In-Memory-Store, damit das Prinzip leicht nachzuvollziehen ist.
public sealed record IdempotencyRecord(
int StatusCode,
string ContentType,
byte[] Body,
Dictionary<string, string> Headers,
DateTimeOffset CreatedAt);
public interface IIdempotencyStore
{
Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct);
Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct);
}
Schritt 2: In-Memory-Store (IMemoryCache)
using Microsoft.Extensions.Caching.Memory;
public sealed class MemoryIdempotencyStore(IMemoryCache cache) : IIdempotencyStore
{
private readonly IMemoryCache _cache = cache;
public Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct)
{
_cache.TryGetValue(key, out IdempotencyRecord? record);
return Task.FromResult(record);
}
public Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct)
{
_cache.Set(key, record, ttl);
return Task.CompletedTask;
}
}
Schritt 3: Middleware (Capture + Replay)
Die Middleware prüft bei POST-Anfragen den Header Idempotency-Key.
Wenn ein Record im Cache existiert, wird die Antwort identisch zurückgeschrieben.
Andernfalls wird der Response-Body gepuffert, nach dem Endpunkt-Aufruf an den Client zurückgeschrieben
und anschließend im Store gespeichert.
using Microsoft.AspNetCore.Http;
public sealed class IdempotencyMiddleware(
RequestDelegate next,
IIdempotencyStore store)
{
private readonly RequestDelegate _next = next;
private readonly IIdempotencyStore _store = store;
// Kleine TTL fürs Demo
private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(10);
public async Task InvokeAsync(HttpContext context)
{
if (!HttpMethods.IsPost(context.Request.Method))
{
await _next(context);
return;
}
if (!context.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
{
await _next(context);
return;
}
var key = keyValues.ToString().Trim();
if (string.IsNullOrWhiteSpace(key) || key.Length > 128)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Ungültiger Idempotency-Key.");
return;
}
// Path in den Cache-Key aufnehmen, um Kollisionen zwischen Endpunkten zu vermeiden
var cacheKey = $"{context.Request.Path}:{key}";
// 1) Replay, falls vorhanden
var existing = await _store.GetAsync(cacheKey, context.RequestAborted);
if (existing is not null)
{
context.Response.StatusCode = existing.StatusCode;
context.Response.ContentType = existing.ContentType;
foreach (var h in existing.Headers)
context.Response.Headers[h.Key] = h.Value;
context.Response.Headers["Idempotency-Replay"] = "true";
await context.Response.Body.WriteAsync(existing.Body, context.RequestAborted);
return;
}
// 2) Antwort abfangen
var originalBody = context.Response.Body;
await using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context);
// Demo: Bei Bedarf nur erfolgreiche Responses cachen.
// In echten Systemen kann auch das Cachen von 4xx sinnvoll sein (je nach Semantik).
var status = context.Response.StatusCode;
var contentType = context.Response.ContentType ?? "application/json";
buffer.Position = 0;
var bodyBytes = buffer.ToArray();
// Response an den echten Stream kopieren (zum Client)
buffer.Position = 0;
await buffer.CopyToAsync(originalBody, context.RequestAborted);
// Minimales Header-Set speichern (Set-Cookie etc. besser nicht speichern)
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (context.Response.Headers.TryGetValue("Location", out var loc))
headers["Location"] = loc.ToString();
var record = new IdempotencyRecord(
StatusCode: status,
ContentType: contentType,
Body: bodyBytes,
Headers: headers,
CreatedAt: DateTimeOffset.UtcNow);
await _store.SetAsync(cacheKey, record, DefaultTtl, context.RequestAborted);
}
finally
{
context.Response.Body = originalBody;
}
}
}
Schritt 4: Verdrahten (Program.cs)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Demo: In-Memory-Store für Single-Instance
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();
var app = builder.Build();
// Vor den Controllern ausführen
app.UseMiddleware<IdempotencyMiddleware>();
app.MapControllers();
app.Run();
Beispiel-Endpunkt
Der Endpunkt bleibt sauber—keine Idempotency-Logik im Controller.
using Microsoft.AspNetCore.Mvc;
public sealed record CreateReservationRequest(string Code, string Country);
[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
[HttpPost]
public IActionResult Create([FromBody] CreateReservationRequest req)
{
// Stellen Sie sich vor, hier wird in die DB inseriert und eine neue Resource-Id erzeugt.
var id = Random.Shared.Next(1000, 9999);
return Created($"/api/reservations/{id}", new
{
id,
req.Code,
req.Country
});
}
}
Schneller Test
Senden Sie denselben POST zweimal mit demselben Key. Die zweite Antwort sollte ein Replay sein.
curl -i -X POST "https://localhost:5001/api/reservations" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
-d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"
curl -i -X POST "https://localhost:5001/api/reservations" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
-d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"
In der zweiten Antwort sollten Sie Idempotency-Replay: true sehen – und denselben Body/Status wie beim ersten Aufruf.
Wichtige Hinweise
- Multi-Instance: IMemoryCache ist pro Instanz. Bei mehreren Servern einen gemeinsamen Store verwenden (Redis/DB).
- Key-Scope: Falls nötig, Benutzer-/Tenant-Infos in den Cache-Key aufnehmen (um Kollisionen zwischen Benutzern zu vermeiden).
-
Body-Mismatch: Manche Systeme speichern zusätzlich einen Hash des Request-Bodys und geben bei gleichem Key aber anderem Payload
409zurück. - Was cachen? Viele APIs cachen 2xx (und manchmal auch 4xx). Entscheiden Sie nach Ihrer Business-Semantik.
TL;DR
- Ein Idempotency-Key macht POST bei Retries sicher.
- Erste Antwort speichern und bei Duplikaten identisch zurückgeben.
- Bei mehreren Instanzen unbedingt einen gemeinsamen Store (Redis/DB) nutzen.