Wird geladen...

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


Wie funktioniert das?


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


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.