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ç
- POST endpoint’lerinde
Idempotency-Keyheader’ını desteklemek. - Aynı key için isteği TTL süresince sadece 1 kez işlemek.
- Duplicate istekte aynı status code + response body döndürmek.
- Örneği basit tutmak (tek instance için in-memory store).
Nasıl Çalışır?
- İstemci
POST /api/reservationsatar ve header ekler:Idempotency-Key: ... - Sunucu bu key daha önce saklanmış mı kontrol eder
- Varsa: cache’teki yanıtı döndürür (opsiyonel:
Idempotency-Replay: true) - Yoksa: endpoint’i çalıştırır, yanıtı yakalar, cache’e kaydeder ve yanıtı döndürü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
- Multi-instance: IMemoryCache instance başınadır. Birden fazla sunucu varsa paylaşımlı store kullanın (Redis/DB).
- Key scope: Gerekirse cache key’e kullanıcı/tenant bilgisi ekleyin (kullanıcılar arası çakışmayı engellemek için).
-
Body farklıysa: Bazı sistemler request body hash’i de saklar; aynı key farklı payload ile gelirse
409gibi hata döndürür. - Neyi cache’lemeli? Çoğu API 2xx’leri (bazıları 4xx’i de) saklar. İş kuralınıza göre karar verin.
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.