Yükleniyor...

ASP.NET Core (.NET 8) Idempotency Key: Tekrarlanan POST İsteklerini Engelleyin

Idempotency-Key header’ı ile POST isteklerini güvenli şekilde tekrar edilebilir hale getirin; ilk yanıtı saklayıp tekrarında aynı yanıtı döndürerek çift kayıtları önleyin.

POST istekleri varsayılan olarak idempotent değildir. Bir istemci (özellikle mobil uygulamalar) network timeout yaşadığında aynı POST’u otomatik olarak tekrar gönderebilir. Sunucu bu isteği iki kez işlerse, çift insert, çift ücret, çift rezervasyon gibi can sıkıcı sonuçlar doğabilir.

Pratik çözüm: Idempotency-Key. İstemci her POST isteğinde header ile benzersiz bir anahtar gönderir. Sunucu isteği bir kere işler, dönen yanıtı saklar ve aynı anahtar tekrar gelirse ilk yanıtı aynen yeniden oynatır (replay). Böylece “retry” güvenli hale gelir.


Amaç


Nasıl Çalışır?


Adım 1: Küçük Bir Store Sözleşmesi

Production’da birden fazla instance çalıştırıyorsanız idempotency verisini genelde paylaşımlı bir store’da tutmak gerekir (Redis/DB gibi). Bu örnekte konsept kolay anlaşılsın diye in-memory store kullanıyoruz.


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);
}

Adım 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;
    }
}

Adım 3: Middleware (Yakalama + Replay)

Middleware, POST isteklerinde Idempotency-Key header’ını kontrol eder. Cache’te kayıt varsa aynı yanıtı geri yazar. Yoksa endpoint çalışırken response body’yi buffer’a alır, endpoint bittiğinde buffer’ı hem istemciye yazar hem de cache’e kaydeder.


using Microsoft.AspNetCore.Http;

public sealed class IdempotencyMiddleware(
    RequestDelegate next,
    IIdempotencyStore store)
{
    private readonly RequestDelegate _next = next;
    private readonly IIdempotencyStore _store = store;

    // Demo için küçük bir TTL
    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("Geçersiz Idempotency-Key.");
            return;
        }

        // Endpoint çakışmalarını önlemek için path'i cache key'e dahil edin
        var cacheKey = $"{context.Request.Path}:{key}";

        // 1) Varsa replay et
        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) Yoksa response'u yakala
        var originalBody = context.Response.Body;

        await using var buffer = new MemoryStream();
        context.Response.Body = buffer;

        try
        {
            await _next(context);

            // Demo: sadece "başarılı" yanıtları saklamak isterseniz burada filtreleyebilirsiniz.
            // Gerçek sistemlerde 4xx’i de saklamak mantıklı olabilir (iş kuralına göre).
            var status = context.Response.StatusCode;
            var contentType = context.Response.ContentType ?? "application/json";

            buffer.Position = 0;
            var bodyBytes = buffer.ToArray();

            // Yanıtı gerçek stream'e kopyala (istemciye gönder)
            buffer.Position = 0;
            await buffer.CopyToAsync(originalBody, context.RequestAborted);

            // Minimal header seti sakla (Set-Cookie vb. saklamamak daha güvenli)
            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;
        }
    }
}

Adım 4: Bağla (Program.cs)


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Demo: tek instance için in-memory store
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();

var app = builder.Build();

// Controller'lardan önce çalışsın
app.UseMiddleware<IdempotencyMiddleware>();

app.MapControllers();
app.Run();

Örnek Endpoint

Endpoint tertemiz kalır; idempotency mantığı controller içine taşınmaz.


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)
    {
        // Burada DB insert yaptığınızı ve yeni resource id döndürdüğünüzü düşünün.
        var id = Random.Shared.Next(1000, 9999);

        return Created($"/api/reservations/{id}", new
        {
            id,
            req.Code,
            req.Country
        });
    }
}

Hızlı Test

Aynı key ile aynı POST’u iki kez gönderin. İkinci yanıt “replay” olmalı.


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\"}"

İkinci yanıtta Idempotency-Replay: true header’ını ve ilk çağrı ile aynı body/status’u görmelisiniz.


Önemli Notlar


TL;DR

  • Idempotency-Key, POST’u retry senaryolarında güvenli hale getirir.
  • İlk yanıtı saklayıp aynı key tekrar geldiğinde aynı yanıtı döndürün.
  • Birden fazla instance varsa mutlaka paylaşımlı store (Redis/DB) kullanın.