Idempotency nedir? Ödemede para iki kez çekilmesin
Ödeme sistemleri rehberinin 1. bölümü
Idempotency, aynı istek tekrar gelse de sonucun değişmemesidir. Ödemede çift tıklamaya ve banka timeout'una karşı .NET'te idempotency key ile çözüm.

13 dk okumaGüncellendi:

İçindekiler
- Idempotency nedir, tekrarı reddetmekten farkı ne
- Tekrar nereden gelir
- Kilit tekrarı durdurur, sonucu vermez
- Idempotency key: ikinci istek ilkinin sonucunu alsın
- Consumer tarafı: aynı mesaj iki kez gelirse
- Banka timeout’u: sonucu bilinmeyen çağrı
- Kendi sisteminde nasıl test edersin
- Okura kalan
- Sık sorulanlar
- Idempotency key ile unique constraint arasındaki fark ne, ikisi de gerekli mi?
- Idempotency key ne kadar süre saklanmalı?
- Banka timeout verdiğinde ödeme neden “başarısız” sayılmamalı?
Bir kullanıcı fatura ödemek için butona iki kez bastı. İki istek de bankaya gitti. Banka ilkini işledi, ikincisini aynı fatura zaten ödendiği için reddetti. Para bir kez çekildi. Teknik olarak her şey yolundaydı. Kullanıcının ekranında kalan son mesaj ise ikinci isteğin cevabıydı: işlem başarısız. Parası çekilmişti ama ödemesinin geçmediğini düşündü.
Kısa cevap: idempotency, aynı istek bir kez de gelse on kez de gelse sistemde bıraktığı etkinin aynı kalmasıdır. Ödeme API’lerinde bundan bir adım fazlası beklenir: ikinci isteğin, ilk isteğin cevabını da alması. Bunu istemcinin her ödeme niyeti için ürettiği bir idempotency key sağlar.
Bu olay bana idempotency’nin yarısını bildiğimizi gösterdi. İlk yarısı, yani paranın bir kez çekilmesi, bankanın sayesinde tutmuştu. İkinci yarısı hiç yoktu. Paranın bir kez çekilmesini sağlamak işin kolay tarafı. Zor olan, ikinci isteğe doğru cevabı verebilmek ve bankadan hiç cevap gelmediğinde ne yapacağını bilmek.
Idempotency nedir, tekrarı reddetmekten farkı ne
Idempotency, bir işlemin bir kez de yapılsa birden fazla kez de yapılsa sistemde aynı etkiyi bırakmasıdır. HTTP tarafında bu kavram zaten tanımlı: RFC 9110’a göre GET, PUT ve DELETE idempotent, POST değil. Ödeme başlatmak neredeyse her zaman bir POST. Yani protokol sana bir garanti vermiyor; idempotency’yi senin kurman gerekiyor.
Aynı ödeme isteği ikinci kez geldiğinde bir sistem üç şekilde davranabilir:
| İkinci istek geldiğinde | Para | Kullanıcının gördüğü |
|---|---|---|
| Hiç koruma yok | İki kez çekilir | İki başarılı ödeme |
| Tekrar reddedilir | Bir kez çekilir | Hata mesajı |
| Idempotency key ile | Bir kez çekilir | İlk ödemenin sonucu |
Benim hikâyemdeki banka ikinci satırdaydı. HTTP’nin tanımıyla bu davranış idempotent sayılır: RFC 9110 yalnızca isteğin sunucudaki etkisine bakar, cevabın aynı olmasını şart koşmaz. DELETE de idempotenttir, ama ikinci çağrı 404 dönebilir.
Ödeme API’lerinde bu yetmiyor. Stripe ve IETF taslağı, tamamlanmış bir isteğin tekrarına ilk işlemin sonucunun dönülmesini bekliyor; başarılı da olsa hata da olsa. Tekrarı reddetmek etkiyi korur, kullanıcıyı korumaz. Kullanıcı açısından “ödemen alınamadı” ile “ödemen zaten alındı” arasında büyük fark var ve ikinci isteğe ilkinin sonucunu dönmeyen bir sistem bu farkı kullanıcıya bırakıyor.
Tekrar nereden gelir
Çift tıklama en görünür kaynak, ama tek kaynak değil. Kullanıcı bir kez bassa bile aynı ödeme sistemin içinde birkaç yerde çoğalabiliyor:
- Kullanıcı: Butona iki kez basıyor ya da sayfa yanıt vermiyor diye yeniliyor.
- İstemci: Mobil uygulama ya da frontend, cevap gelmeyince isteği kendiliğinden tekrar deniyor.
- Ara katman: Gateway ya da load balancer, arkadaki servisten cevap alamayınca isteği başka bir instance’a yeniden gönderiyor.
- Mesaj kuyruğu: Consumer mesajı işledi ama ack göndermeden çöktü. Broker mesajı tekrar teslim ediyor.
- Arka plan işleri: Hangfire’da hata alan job otomatik olarak yeniden çalışıyor.
Her katmanın tekrar denemesi kendi açısından doğru bir karar. Sorun, bu kararların hepsinin aynı para hareketine dokunabilmesi. Bu yüzden korumayı tek bir katmana koyup rahatlamak işe yaramıyor.
Kilit tekrarı durdurur, sonucu vermez
Olaydan sonra ilk yaptığımız şey, aynı faturaya aynı anda iki ödemenin girmesini engellemekti. Fatura üzerinden bir Redis lock koyduk. İkinci istek kilidi bekliyor, kilit açılınca veritabanında bu fatura için ödeme olup olmadığına bakıyor ve varsa isteği reddediyor:
[HttpPost("invoices/{invoiceId:guid}/payments")]
public async Task<IActionResult> Pay(Guid invoiceId, CancellationToken ct)
{
await using var _ = await locks.AcquireAsync($"invoice-payment:{invoiceId}", wait: TimeSpan.FromSeconds(30), ct);
var alreadyPaid = await db.Payments.AnyAsync(
p => p.InvoiceId == invoiceId && p.Status != PaymentStatus.Failed, ct);
if (alreadyPaid)
return Conflict("Bu fatura için ödeme zaten var.");
var payment = await payments.StartAsync(invoiceId, ct);
return Ok(PaymentDto.From(payment));
}
Kilit iki isteği sıraya koyuyor, veritabanı kontrolü ikincisini durduruyor. Bankaya artık tek istek gidiyor ve kullanıcının “başarısız” sanma sorunu çözüldü.
Ama sorun başka bir kılığa girdi. İkinci istek kilidi beklediği için kullanıcının ekranında yükleniyor işareti uzun süre döndü ve kullanıcı bu sefer işlemin uzun sürdüğünü sandı. Kilit tekrarı durduruyor ama ikinci isteğe söyleyecek bir şeyi yok. Bildiği tek şey “bu fatura için zaten bir ödeme var”.
Kilidin bir de kendi sınırları var. Süresi işlemden kısa kalırsa kilit sen işlemi bitirmeden açılıyor. Redis’e ulaşılamazsa koruma tamamen ortadan kalkıyor. Bu yüzden kilidi bir hızlandırıcı, asıl garantiyi ise veritabanı kısıtı olarak görmek gerekiyor. Ona aşağıda geleceğim.
Idempotency key: ikinci istek ilkinin sonucunu alsın
Bu yüzden kilidin üstüne bir katman daha koyduk: idempotency key. Kilit aynı faturaya iki ödemenin girmesini engellemeye devam ediyor; anahtar ise ikinci isteğe doğru cevabı veriyor.
Fikir basit. İstemci her ödeme niyeti için benzersiz bir anahtar üretiyor ve isteği Idempotency-Key başlığıyla gönderiyor. Kullanıcı butona iki kez basarsa ya da istemci isteği tekrar denerse anahtar aynı kalıyor. Sunucu bu anahtarı, isteğin özetini ve sonunda verdiği cevabı saklıyor. Aynı anahtar tekrar geldiğinde işlemi yeniden yapmıyor, sakladığı cevabı dönüyor.
Bunun için IETF’te bir standart taslağı var ve Stripe gibi ödeme sağlayıcıları yıllardır bu şekilde çalışıyor. Anahtarın ömrü sınırsız olmak zorunda değil; Stripe anahtarları en az 24 saat sonra silinebilir sayıyor.
Bir istek geldiğinde sunucunun dört durumu ayırt etmesi gerekiyor:
- Anahtar ilk kez geliyor: Kaydı “işleniyor” olarak ekle, ödemeyi yap, cevabı kaydet.
- Anahtar tamamlanmış: İşlemi yapma, kaydedilmiş cevabı dön. Çift tıklayan kullanıcı ilk ödemenin sonucunu görüyor.
- Anahtar hâlâ işleniyor: İlk istek bitmedi.
409 Conflictdön; istemci biraz sonra aynı anahtarla tekrar sorar. - Anahtar aynı ama istek farklı: Aynı anahtar başka bir tutarla gelmiş.
422dön; bu bir tekrar değil, istemci hatası.
ASP.NET Core’da bunu bir action filter olarak yazabilirsin:
public sealed class IdempotencyFilter(AppDbContext db, ICurrentUser currentUser) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
string? key = context.HttpContext.Request.Headers["Idempotency-Key"];
if (string.IsNullOrWhiteSpace(key))
{
context.Result = new BadRequestObjectResult("Idempotency-Key başlığı zorunlu.");
return;
}
var record = new IdempotencyRecord(currentUser.UserId, key, Fingerprint(context.ActionArguments));
db.IdempotencyRecords.Add(record);
try
{
await db.SaveChangesAsync(); // (UserId, Key) unique: aynı anda gelen ikinci istek burada düşer
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
db.Entry(record).State = EntityState.Detached;
var existing = await db.IdempotencyRecords.SingleAsync(r => r.UserId == currentUser.UserId && r.Key == key);
context.Result = existing switch
{
_ when existing.Fingerprint != record.Fingerprint => new UnprocessableEntityObjectResult("Bu anahtar farklı bir istekle kullanılmış."),
{ CompletedAt: null } => new ConflictObjectResult("İlk istek hâlâ işleniyor."),
_ => new ContentResult { StatusCode = existing.StatusCode, Content = existing.ResponseBody, ContentType = "application/json" },
};
return;
}
var executed = await next();
if (executed.Result is ObjectResult { StatusCode: < 500 } result)
record.Complete(result.StatusCode.Value, JsonSerializer.Serialize(result.Value));
else
db.IdempotencyRecords.Remove(record); // kendi hatamız: istemci aynı anahtarla tekrar deneyebilsin
await db.SaveChangesAsync();
}
private static string Fingerprint(IDictionary<string, object?> args) =>
Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(
args.Where(a => a.Value is not CancellationToken).ToDictionary())));
}
Kodda birkaç karar var ve her biri bilerek verilmeli:
- Anahtar kullanıcıyla birlikte saklanıyor. Unique kısıt
(UserId, Key)üzerinde. Yalnızca anahtara bakarsan, başka bir kullanıcının anahtarını bilen biri onun ödeme cevabını alabilir. Bu da başka bir kılıkta IDOR olur. - Eşzamanlılığı kısıt çözüyor, kontrol değil. “Önce bak, yoksa ekle” yazarsan aynı anda gelen iki istek ikisi de “yok” görür. Ekleyip kısıtın patlamasını beklemek, iki isteği veritabanının sıraya koymasını sağlıyor.
- Hangi cevabın saklanacağı domain kararı. Banka ödemeyi reddettiyse bu ödemenin sonucu; saklanır ve aynı anahtarla gelen herkes aynı reddi görür. Bizim servisimiz çöktüyse bu bir sonuç değil; kayıt silinir ve istemci tekrar deneyebilir.
Anahtarın istemcide nerede üretildiği de önemli. Butona her basışta yeni anahtar üretirsen hiçbir şey kazanmazsın. Anahtar, ödeme ekranı açılırken bir kez üretilmeli ve o ödeme niyetinin bütün denemelerinde aynı kalmalı.
NuGet’te bunu hazır yapan paketler de var. Kullanabilirsin, ama yukarıdaki üç kararı paket senin yerine veremez. Hangisini nasıl verdiğine bakmadan eklersen, hata cevaplarını da saklayan ve kullanıcıyı sonsuza dek aynı hataya kilitleyen bir sistem kurabilirsin.
Consumer tarafı: aynı mesaj iki kez gelirse
API katmanı istemciden gelen tekrarı çözüyor. Ödeme bir kuyruğa düştükten sonra ise tekrar başka yerden geliyor: RabbitMQ gibi broker’lar mesajı “en az bir kez” teslim eder. Consumer bankayı çağırıp ack göndermeden çökerse aynı mesaj tekrar gelir.
Literatürde buna idempotent consumer deniyor ve genelde işlenen mesajları tutan ayrı bir inbox tablosuyla anlatılıyor. Bizde ayrı bir tablo yok; işin kendi durumu ve bir unique constraint aynı işi görüyor.
Durum kontrolünün tek bir tuzağı var: önce okuyup sonra yazarsan iki consumer aynı anda “Oluşturuldu” görür ve ikisi de bankayı çağırır. Kontrolü okuma ve yazma olarak değil, tek bir koşullu update olarak yapmak gerekiyor:
public async Task HandleAsync(PaymentRequested message, CancellationToken ct)
{
var claimed = await db.Payments
.Where(p => p.Id == message.PaymentId && p.Status == PaymentStatus.Created)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Status, PaymentStatus.SentToBank), ct);
if (claimed == 0)
return; // başka bir consumer almış ya da zaten işlenmiş
var payment = await db.Payments.SingleAsync(p => p.Id == message.PaymentId, ct);
await bank.PayAsync(payment.Reference, payment.Amount, ct);
// ...
}
ExecuteUpdateAsync etkilenen satır sayısını döndürüyor. Satırı yalnızca bir consumer “Oluşturuldu”dan “Bankaya iletildi”ye çekebiliyor; diğeri sıfır alıp çıkıyor.
Unique constraint ise son savunma hattı. Aynı fatura için başarısız olmamış tek bir ödeme olabilir:
builder.HasIndex(p => p.InvoiceId)
.IsUnique()
.HasFilter("\"Status\" <> 'Failed'");
Kilit atlansa, filter yanlış yazılsa, consumer’da bir hata olsa bile veritabanı aynı faturaya ikinci bir canlı ödeme yazılmasına izin vermiyor. Filtredeki Failed istisnası da önemli: başarısız bir ödemeden sonra kullanıcı aynı faturayı tekrar ödeyebilmeli.
Banka timeout’u: sonucu bilinmeyen çağrı
Buraya kadarki her şey, bizim kendi veritabanımızın sınırları içinde çalışıyor. Banka çağrısı bu sınırın dışında. İstek gitti, cevap gelmedi. Para çekildi mi? Bilmiyorsun.
İlk refleks bu ödemeyi başarısız kaydetmek oluyor. Ama yukarıdaki unique constraint’e bak: başarısız ödeme kısıtın dışında. Ödemeyi başarısız kaydedersen kullanıcı aynı faturayı tekrar ödeyebilir. Banka ilk isteği aslında işlediyse para iki kez çekilir. Timeout’u başarısızlık saymak, bütün koruma katmanlarını kendi elinle açmak demek.
Bu yüzden bizde ödeme bankaya gönderildiği anda “Bankaya iletildi” durumuna geçiyor ve sonuç gelene kadar orada kalıyor. Banka cevap verirse ödeme Başarılı ya da Başarısız oluyor. Cevap gelmezse arka planda 15 dakikada bir çalışan bir Hangfire job’ı bekleyen ödemeleri bankaya soruyor:
RecurringJob.AddOrUpdate<PendingPaymentsJob>(
"pending-payments", job => job.RunAsync(CancellationToken.None), "*/15 * * * *");
public sealed class PendingPaymentsJob(AppDbContext db, IBankGateway bank)
{
public async Task RunAsync(CancellationToken ct)
{
var pending = await db.Payments
.Where(p => p.Status == PaymentStatus.SentToBank)
.ToListAsync(ct);
foreach (var payment in pending)
{
var result = await bank.QueryAsync(payment.Reference, ct);
if (result == BankQueryResult.Approved)
payment.MarkSucceeded();
else if (result is BankQueryResult.Rejected or BankQueryResult.NotFound)
payment.MarkFailed(); // bulunamadı: istek bankaya hiç ulaşmamış
// sonuç hâlâ belirsizse ödeme olduğu yerde kalır, bir sonraki turda tekrar sorulur
}
await db.SaveChangesAsync(ct);
}
}
Sorgunun üçüncü bir cevabı daha var: banka işlemi hiç bulamayabilir. Bu, isteğin bankaya ulaşmadığı anlamına geliyor ve ödeme “alınamadı” olarak kapanıyor. Kullanıcı faturayı tekrar ödeyebiliyor, çünkü çekilmiş bir para yok. Burada güvendiğin tek şey, bankanın “bulunamadı” cevabının gerçekten “istek bana hiç gelmedi” demek olması. Bu yüzden bu cevabı her bankanın dokümanında ve testte ayrıca doğrulamak gerekiyor.
Bu sorgunun çalışabilmesi için bir şart var: bankaya giden her isteğin bizim ürettiğimiz ve tekrar denemelerde değişmeyen bir referansı olmalı. Yazının başındaki bankanın ikinci isteği reddedebilmesi de aynı fikre dayanıyordu: iki istek aynı faturayı taşıyordu. Referans, banka tarafındaki idempotency key’in ta kendisi.
Bu durumu kullanıcıya da dürüstçe göstermek gerekiyor. “Bankaya iletildi” durumundaki bir ödeme için kullanıcıya “başarısız” değil, “sonuç bekleniyor” demek gerekiyor. Aksi hâlde yazının başındaki kullanıcıyı bu kez kendi elinle yaratırsın.
Kendi sisteminde nasıl test edersin
Idempotency’yi elle test etmek zor, çünkü tekrarlar zamanlamaya bağlı. Birkaç integration testi bu açıkları önceden yakalar:
- Aynı ödeme isteğini
Task.WhenAllile aynı anda iki kez gönder. Veritabanında tek ödeme olmalı, banka sahtesi bir kez çağrılmalı ve iki cevap aynı olmalı. - Aynı
Idempotency-Keyile farklı tutar gönder.422bekle. - Consumer’a aynı mesajı iki kez ver. Banka sahtesi bir kez çağrılmalı.
- Banka sahtesini timeout verecek şekilde ayarla. Ödeme “Bankaya iletildi”de kalmalı, başarısıza düşmemeli ve aynı fatura için yeni bir ödeme açılamamalı.
Okura kalan
- Tekrarı reddetmek etkiyi korur, kullanıcıyı korumaz. İkinci istek, ilk isteğin sonucunu dönmeli; yoksa kullanıcı parası çekilmiş bir ödemeyi başarısız sanır.
- Tekrar tek bir yerden gelmiyor. Kullanıcı, istemci, gateway, broker ve arka plan işleri ayrı ayrı tekrar dener; her katmanın kendi koruması olmalı.
- Kilit tekrarı sıraya koyar, cevap vermez. Asıl garanti veritabanındaki unique constraint’te; idempotency key ise ikinci isteğe doğru cevabı verir.
- Durum kontrolünü okuma ve yazma olarak değil, tek bir koşullu update olarak yap.
- Timeout bir sonuç değil. Cevabı gelmeyen ödemeyi başarısız kaydetme; sonuç bilinene kadar ara bir durumda tut ve sor.
Sık sorulanlar
Idempotency key ile unique constraint arasındaki fark ne, ikisi de gerekli mi?
İkisi farklı işi görüyor. Unique constraint aynı faturaya ikinci bir canlı ödeme yazılmasını engeller; kilit atlansa, consumer’da hata olsa bile veritabanı buna izin vermez. Idempotency key ise ikinci isteğe ilk isteğin cevabını döndürür; kısıt tek başına tekrarı reddeder ama kullanıcıya “ödemen zaten alındı” diyemez.
Idempotency key ne kadar süre saklanmalı?
Sınırsız olmak zorunda değil. Stripe anahtarları en az 24 saat sonra silinebilir sayıyor; aynı niyetin tekrarları bu sürenin çok altında geliyor. Önemli olan, anahtarın işlem bitene kadar ve istemcinin makul bir tekrar penceresi boyunca durması.
Banka timeout verdiğinde ödeme neden “başarısız” sayılmamalı?
Çünkü timeout bir sonuç değil, sonucun bilinmediği bir durum. Ödemeyi başarısız kaydedersen kullanıcı aynı faturayı tekrar ödeyebilir ve banka ilk isteği aslında işlediyse para iki kez çekilir. Ödeme “Bankaya iletildi” durumunda bekler, arka plandaki sorgu bankadan sonucu öğrenene kadar kullanıcıya “sonuç bekleniyor” denir.
Benzer yazılar

12 dk
Outbox pattern nedir? .NET ve EF Core ile
Outbox pattern nedir? RabbitMQ yokken EF Core tablosu ve .NET BackgroundService ile transactional outbox: çift işleme, retry, para çıkışı.

4 dk
Idempotency key istemcide nerede üretilir?
Idempotency key butona basışta değil, ödeme niyeti oluştuğu anda üretilir. İstemci tarafında key'in yeri, snapshot anı ve sayfa yenilenince ne olduğu.

4 dk
Feature flag teknik borca nasıl dönüşür?
Tek bir müşteri için açılan feature flag uzun süre kodda kalır. Tenant bazlı flag'lerin bedeli, temizliği ve yeni flag açarken sorulacak üç soru.