Idempotency Key Olmadan Retry Yapmak
Aynı isteğin ikinci hayatı

İlk gördüğüm semptom çift kargo etiketiydi. Kullanıcı 'öde'ye basmış, 30 saniye dönen bir halka, sonra 'bir daha dene'. Destek iki sipariş gördü, muhasebe iki yetki, depo iki koli. Log'da iki {POST /checkout}, iki 201. Hepsi doğru çalışmıştı. Doğru çalışmak, tam da sorundu.
Timeout, başarısızlık değildir. Cevabın size ulaşmamasıdır. Karşı taraf kaydetmiş olabilir. Retry, 'belki olmamıştır' varsayımı. Varsayım, yazma uçlarında pahalı. Okuma uçlarında çoğu zaman ucuz. İkisini aynı policy ile sarmak, bu karışıklığın başlangıcı.
Idempotency key bir moda değil. Aynı niyeti ikinci kez taşıyan isteğin, birinciyle aynı sonuca bağlanması. Sonuç bazen aynı gövde, bazen aynı hata. Önemli olan, ikinci kez yan etki üretmemek.
Nerede kırılıyor
Kırılma üç yerde. Tarayıcı çift tık. Mobil, zayıf ağda aynı POST'u tekrarlıyor. Sizin worker'ınız, broker redelivery yapıyor. Üçünün de ortak yanı, sizin kodunuzun 'yeni iş' sanması. Sanmamak için işin bir kimliği olmalı, istek gövdesinin hash'i değil — kullanıcı aynı sepeti 1 dakika sonra bilinçli yenileyebilir.
Anahtar, niyetin kimliği. Checkout için form açılınca üretilir, öde bitene kadar aynı kalır. Worker için mesaj {messageId}. İç servis çağrısında çağıran üretir, çağırılan saklar. Üreten taraf unutursa, karşı taraf mucize yapamaz.
Hash ile id'yi karıştırmayın. Gövde hash'i, aynı içeriğin tekrarını yakalar. Kullanıcı adresi düzeltip aynı anahtarla gelirse ne olacak? Anahtar aynıysa eski sonuç, içerik değiştiyse çatışma. Bunu bilinçli seçmek gerekir; sessizce hash kullanmak, düzeltmeyi de 'tekrar' sanar.
HTTP kenarı
Dışarıya açılan yazma uçlarında header istiyorum. {Idempotency-Key}. Yoksa 400. Aynı anahtar, aynı gövde: kayıtlı cevap. Aynı anahtar, farklı gövde: 409. Farklı anahtar: yeni iş. Bu sözleşme, client'a da disiplin verir.
public sealed class IdempotencyFilter(AppDbContext db) : IEndpointFilter
{
public async ValueTask InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var key = ctx.HttpContext.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(key))
return Results.BadRequest();
var path = ctx.HttpContext.Request.Path.Value ?? "";
var existing = await db.IdempotencyKeys
.SingleOrDefaultAsync(x => x.Key == key && x.Path == path);
if (existing is not null)
return Results.Json(existing.ResponseBody, statusCode: existing.StatusCode);
var result = await next(ctx);
// SaveChanges, iş kaydı + anahtar satırı aynı işlemde
return result;
}
}
Filtre iskelet. Asıl iş, cevabı ve iş satırını aynı transaction'da yazmak. Önce iş, sonra anahtar; arada çökünce ikinci istek işi tekrarlar. Önce anahtar 'in-progress', sonra iş: ikinci istek bekler veya 409. İkisini de gördüm; yazma parasındaysa in-progress daha güvenli.
Tabloda tek gerçek
Hafızadaki dictionary, ikinci pod'u tanımaz. Anahtar veritabanında durur. Unique, tartışmayı bitirir. Yarışta biri kazanır, diğeri aynı satırı okur.
CREATE TABLE idempotency_keys (
key text NOT NULL,
path text NOT NULL,
request_hash text NOT NULL,
status_code int NOT NULL,
response_body jsonb NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (key, path)
);
{path} 'i anahtara katıyorum çünkü aynı UUID'yi iki uçta kullanmak istemiyorum. Kullanırlarsa, yanlış cevabı yanlış işe yapıştırmayalım. {request_hash} çatışmayı yakalar. Gövdeyi sonsuza kadar tutmuyorum; 24-72 saat, işin itiraz penceresine göre. Daha eski anahtar, yeni iş sayılabilir — bunu da ürünle konuşuyorum, sessizce vermiyorum.
Worker tarafı
HTTP bitince iş bitmez. Outbox rölesi aynı satırı iki kez basabilir, broker aynı mesajı iki kez verebilir. Tüketici {inbox} tutmazsa, 'sipariş alındı' maili iki gider. Kullanıcı bunu retry sanmaz; sistem saçmaladı sanır. Haklıdır.
public async Task HandleAsync(OrderPlaced ev, CancellationToken ct)
{
if (!await inbox.TryClaimAsync(ev.MessageId, ct))
return;
await mail.SendOrderPlacedAsync(ev.OrderId, ct);
}
{TryClaim} insert-if-not-exists. Unique çarparsa çık. Mail gönderimi claim'den sonra; mail gidip claim yazılmazsa ikinci denemede ikinci mail. İdeal değil. Daha sıkı olan, mail sağlayıcısının kendi idempotency'si veya 'gönderildi' satırının claim ile aynı işlemde olması. SMTP çoğu zaman o işleme girmez. Bu yüzden ikinci mail, ikinci çekimden daha kabul edilir — yine de ölçülür.
Retry policy ile evlilik
Emre'nin HttpClient yazısındaki retry, okumada duruyor. Yazmada retry varsa, ya metot idempotent ya da anahtar var. 500'e körlemesine üç deneme, üç sipariş. 408 ve 500'i aynı torbaya koymayın; 408 'bilmiyorum', 400 'yapma'.
Kullanıcıya gösterilen 'tekrar dene' butonu da aynı sözleşme. Buton yeni anahtar üretirse, bilinçli ikinci sipariş. Aynı anahtarı taşırsa, birincinin cevabı. Ekranın hangisini yaptığı, backend'den daha çok destek yükü belirler. Bunu tasarım toplantısında konuşmuyorsanız, destek konuşur.
Anahtarın yetmediği yer
Anahtar, aynı niyeti birleştirir. Farklı niyeti birleştirmez. Kullanıcı iki ayrı sepeti iki anahtarla gönderirse iki sipariş doğru. Bunu 'sistem çiftledi' diye okumak, ürünü yanlış cezalandırmak. Teşhis için anahtarı log'layın, gövdeyi değil; gövde kart numarası taşıyabilir.
Uzun işlerde anahtar tek başına yetmez. 'İşlem sürüyor' durumu gerekir. İkinci istek 200 ile eski sonucu değil, 202 ile aynı iş kimliğini almalı. Aksi halde kullanıcı bitmemiş işi başarılı sanır, üçüncü kez basar. Üçüncü basış, yeni anahtarsa yeni bela.
Bir ödemenin iki hayatı
Saat 14:02:11, kullanıcı ödeye basar. 14:02:12, yetki dışarı gider. 14:02:13, sağlayıcı 200 üretir, bizim tarafta 30 saniye timeout vardır, cevap düşer. 14:02:14, ekran 'tekrar dene' gösterir. 14:02:15, aynı kart, aynı sepet, yeni {POST}. 14:02:16, ikinci 201. Muhasebe iki {authId} görür. Kullanıcı bir koli bekler. Bu zaman çizelgesi, 'retry ekledik' cümlesinin düz metnidir.
Anahtar olsaydı 14:02:15 aynı niyeti taşırdı. Sunucu, 14:02:12'deki işi bulur, aynı gövdeyi veya 'hâlâ işleniyor' dönerdi. İkinci {authId} doğmazdı. Doğmayan şey, iade bileti de doğurmaz.
Bu çizelgeyi log'da görmek için anahtarı ve {authId} 'yi aynı satıra yazıyorum. Gövdeyi değil. Kartın ilk altısı bile gerekmez. Teşhis, niyet ve dış referans. Fazlası PII, eksiği sis.
Anahtarı kim üretir
Üreten, niyeti başlatandır. Tarayıcı, ödeme formunu açınca bir UUID üretir, {sessionStorage} 'da tutar, her {POST} 'ta gönderir. Sayfa yenilenirse aynı form, aynı anahtar. Yeni sepet, yeni anahtar. Bunu sunucunun 'yoksa üret' diye tamamlaması, ikinci isteği yeni niyet yapar. Tamamlama, deliktir.
Mobil zor. Process ölümü {sessionStorage} 'ı da götürebilir. O zaman anahtarı, henüz oluşmamış siparişin taslak kimliğiyle bağlarım: taslak satırı duruyorsa anahtar durur. Taslak yoksa, zayıf ağda ikinci deneme ikinci iştir. Bunu ürüne söylerim; gizlemem.
Worker'da üreten, yayınlayandır: {messageId}. Tüketici üretmez. Tüketici 'yoksa ben uydurayım' derse, her redelivery yeni id, yani anahtar yok. Uydurma, outbox'taki id'yi yok saymaktır.
{
"intent": "checkout",
"idempotencyKey": "8f3c0a1e-2b6d-4c11-9a44-0f2e7c1b9d20",
"draftOrderId": "drf_1044",
"producedBy": "web-checkout-form",
"ttlHours": 48
}
In-progress durumu
Uzun yetkide anahtar tek başına yetmez. Birinci istek hâlâ dışarıdayken ikinci istek gelirse, 'kayıtlı cevap' yoktur. Boş cevap üretmek, üçüncü basışa yol açar. {in_progress} satırı, ikinci isteğe 409 veya 202 verir. 202, aynı iş kimliği. Kullanıcı bekler, yeni niyet açmaz.
ALTER TABLE idempotency_keys
ADD COLUMN state text NOT NULL DEFAULT 'completed'
CHECK (state IN ('in_progress', 'completed', 'failed'));
-- birinci istek
INSERT INTO idempotency_keys (key, path, request_hash, status_code, response_body, created_at, state)
VALUES ($1, $2, $3, 0, '{}', now(), 'in_progress');
-- ikinci istek aynı key
-- state = in_progress ise 409 veya 202
-- state = completed ise kayıtlı gövde
{failed} 'i dikkatli kullanıyorum. Dışarı 500, içeride iş oluşmadıysa ikinci deneme yeni iş olabilir — aynı anahtarla. O zaman {failed} 'i silmek veya 'tekrar dene'ye açmak gerekir. İş oluşup cevap kaybolduysa {failed} yalan olur; {completed} ve gövde şart. Sis, {failed} ile çözülmez.
Redis yetmez, unique yeter
Anahtarı Redis'te tutmak cazip: hızlı, TTL kolay. İki sorun: Redis gittiğinde yazma kapısı ya açılır ya kapanır; ikisi de ürün kararı. İkincisi, iş satırı Postgres'te, anahtar Redis'te, yine iki yazma. Outbox yazısındaki kopma, buraya da gelir. Ciddi yazmada anahtar, işlemin içinde. Redis, okuma hızı için önbellek olabilir; kaynak değil.
Unique çarpması, yarışta hakemdir. {INSERT ... ON CONFLICT} ile kazanan belli olur. Kazanan işi bitirir, kaybeden kazananın satırını okur. Bunu uygulama kilidiyle çözmeye çalışmak, pod sayısında bozulur.
TTL, itiraz penceresi. 10 dakika, geç gelen mobil tekrarını yeni sipariş yapar. 30 gün, tablo şişer ve eski anahtar yanlış işe yapışabilir. 24-72 saat, çoğu ödeme itirazıyla örtüşür. Pencereyi finansle konuşun, DBA ile değil yalnızca.
Test etmeden retry açmayın
İki test yeter, ikisi de ucuz. Biri: aynı anahtar, aynı gövde, iki {POST}, tek satır. İkincisi: aynı anahtar, farklı gövde, 409. Üçüncü lüks: birinci istek {in_progress} iken ikinci 409. Bu üçü yoksa, policy'yi production'da keşfedersiniz. Keşif, ikinci kolidir.
Yük testinde timeout'u kasten üretiyorum. Karşı tarafı 35 saniye uyutup client 30'da kesiyorum. Anahtar yoksa satır sayısı iki, varsa bir. Bu testi bir kez yazınca, 'bizde olmaz' cümlesi biter. Olur. Ağ, olmasını ister.
Ne zaman anahtar istemiyorum
Saf GET'te istemiyorum. Idempotent PUT, anahtarsız da durabilir; aynı gövde aynı kaynağı yazar. Yine de zayıf ağda 409/412 ile evlenmek daha net. Asıl inat ettiğim yer {POST} ile yaratma: sipariş, ödeme, havale, kullanıcı daveti. Bunlar 'bir daha' yı kaldıramaz.
İç araçlarda da gevşemiyorum. Admin 'yeniden dene'ye basınca ikinci iade oluşmasın. İç araç, dışarıdan daha tehlikeli olabilir; yetki yüksek, tıklama emin. Emin tıklama, çift tıklama değildir — ama fare titrer.
Anahtarı log'da, kartı log'da değil
Destek 'iki kez çekildi' deyince ilk baktığım yer anahtar ve dış referans. İkisini aynı satırda görmezsem, sis devam eder. Gövdeyi dump etmek, PCI ve KVKK ile yeni bir iş açar. Anahtar, {draftOrderId}, {authId}, durum. Bu dörtlü çoğu tartışmayı bitirir.
Metric de aynı izi taşır. {idempotency_replay_total} bir, {idempotency_conflict_total} iki, {checkout_created_total} üç. Replay yükseliyorsa ağ kötü veya buton çift. Conflict yükseliyorsa client anahtarı yeniden kullanıp gövdeyi değiştiriyor. Created, replay'den bağımsız şişiyorsa anahtar hiç gelmiyordur. Üç sayıyı görmeden 'retry güvenli' demiyorum.
Anahtarı kullanıcıya göstermiyorum. Gösterirsem kopyalanır, başka sepete yapışır, 409 bir ürün bug'ı olur. Kullanıcıya sipariş no yeter. Anahtar, makineler arası bir fısıltı.
Retry'ı seviyorum. Anahtarsız retry'ı, üretimde bir kez gördükten sonra sevmiyorum. Timeout bir sis. Sis içinde ikinci adımı, birinciyle aynı izi bırakarak atın. İz yoksa, o adım yeni bir dünya açar. Yeni dünya, ikinci koli olarak kapıya gelir.
Yorumlar
Yorumlar (0)
Yorumlar üyelere açık. Üye ol · Giriş yap