Idempotent Dış Çağrılar: Idempotency-Key Göndermek, "Bilinmeyen Sonuç" ve Reconciliation
Timeout'tan sonra ödemenin alınıp alınmadığını bilemezsin; çözüm, ödeme niyeti başına bir Idempotency-Key üretip ilk istekten önce saklamak, retry'larda aynı key'i göndermek ve açık kalanları reconciliation job'uyla sağlayıcıya sormak. Bookstore'da timeout, 409 ve tekrar oynatılan yanıtı gerçek koşularla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.Http.Resilience 10.11, Polly 8.9, WireMock.Net 2.31 ve xUnit v3 4.0 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) ödeme sağlayıcısına POST /v1/charges gönderiyor. Sağlayıcı isteği alıyor, kartı çekiyor ama yanıtı 5 saniye sonra dönüyor. Bizim attempt timeout'umuz 3 saniye. Üç soru:
Birincisi: 3. saniyede TimeoutRejectedException aldın. Müşterinin kartından para çekildi mi?
İkincisi: aynı isteği aynı Idempotency-Key ile 4. saniyede tekrar gönderirsen sağlayıcı ne döner?
Üçüncüsü: 05. yazıda ödeme POST'unun hiç tekrar denenmediğini test ettik. Retry'ı açınca o testin yerine ne yazmalısın?
Birinci sorunun cevabı "bilmiyoruz". Bu yazının konusu tam olarak bu cevap. İkincisinin cevabı, aşağıdaki gerçek koşuda görüleceği gibi 409 Conflict: ilk istek sağlayıcıda hâlâ işleniyor. Üçüncüsünün cevabı da "retry'ları say ama hepsinin aynı key'i taşıdığını doğrula".
Timeout Bir Cevap Değildir
Bu seriye başlarken çıkardığımız failure mode tablosunda en tehlikeli satır "timeout sonrası bilinmeyen sonuç"tu. HTTP isteği bir kez kablodan çıktıktan sonra bir hata almak, karşı tarafın isteği işlemediği anlamına gelmiyor. Bağlantı kopabilir, yanıt yolda kaybolabilir, ya da bizim sabrımız sağlayıcının işlem süresinden kısa olabilir. Her durumda elimizde aynı exception var, ama sağlayıcı tarafındaki gerçek üç farklı şey olabilir.
RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 9110'un 9.2.2 bölümü bu yüzden method'ları ayırıyor: GET, HEAD, OPTIONS, TRACE gibi safe method'lar ve PUT ile DELETE idempotent sayılıyor; aynı isteği iki kez göndermenin etkisi bir kez göndermekle aynı. POST böyle değil. RFC'ye göre bir istemci idempotent olmayan bir isteği, isteğin aslında idempotent olduğunu bilmesini sağlayan bir yol ya da ilk isteğin uygulanmadığını anlamasını sağlayan bir yol yoksa otomatik olarak tekrar göndermemeli. Standard pipeline yazısında DisableForUnsafeHttpMethods ile tam bunu yaptık: ödeme POST'unu hiç tekrar denemedik.
Bu güvenli ama eksik bir çözüm. Retry yoksa her geçici hata müşteriye "ödeme alınamadı" olarak dönüyor; bilinmeyen sonuçta da 202 dönüp neyi beklediğimizi bilmeden bekliyoruz. RFC'nin bıraktığı kapıyı kullanmak gerekiyor: POST'u, sağlayıcıyla birlikte idempotent yapmak.
Idempotency-Key: Sağlayıcıyla Bir Sözleşme
Ödeme sağlayıcılarının çoğu bunu bir request header'ı ile çözüyor: istemci her ödeme niyeti için benzersiz bir key üretip gönderiyor, sağlayıcı key'i ilk isteğin sonucuyla birlikte saklıyor ve aynı key'le gelen tekrarlara işlemi yeniden yapmadan ilk sonucu dönüyor. IETF'in (Internet Engineering Task Force; internet standartlarını geliştiren kuruluş) HTTPAPI (HTTP API'leri üzerine standart hazırlayan) çalışma grubunda bu header için bir taslak var: draft-ietf-httpapi-idempotency-key-header'ın 22 Ekim 2026 tarihli 08 sürümü. Taslak Standards Track hedefliyor ama henüz RFC değil, yani sağlayıcılar kendi dokümanlarındaki kuralları uyguluyor. Yine de taslak, iki tarafın nasıl davranması gerektiğini iyi özetliyor:
| Durum | Taslağın önerdiği yanıt | Bookstore'un yorumu |
|---|---|---|
| Key zorunlu ama gönderilmemiş | 400 Bad Request | Bizim hatamız, düzeltilmeden tekrar denenmez |
| Aynı key, farklı istek gövdesi | 422 Unprocessable Content | Bizim hatamız: ContractViolation |
| Aynı key, ilk istek hâlâ işleniyor | 409 Conflict | Biraz bekleyip aynı key'le tekrar sor |
| Aynı key, ilk istek tamamlanmış | İlk isteğin sonucuyla tutarlı yanıt | Normal sonuç gibi işle |
İki ayrıntı daha önemli. Taslağa göre header değeri bir Structured Field String; yani tırnak içinde gönderiliyor: Idempotency-Key: "8e03978e-...". Birçok sağlayıcı tırnaksız değeri de kabul ediyor ama taslağa uymak bedava. İkincisi, sağlayıcı key'leri sonsuza kadar saklamıyor; taslak bu süreyi dokümanda yayımlamasını istiyor. Sağlayıcının saklama süresinden sonra aynı key'le gelen istek yeni bir ödeme olarak işlenebilir. Bu, aşağıdaki reconciliation ayarlarını belirleyen sayı.
Server tarafının nasıl yazılacağını, yani bizim API'mize gelen Idempotency-Key'i bir endpoint filter ve bir tabloyla karşılamayı, Çözüm Desenleri serisinin Idempotency Key yazısında anlattık; hassas akışlardaki güvenlik rolü de API Güvenliği serisinin hassas akışlar yazısında. Burada masanın öbür tarafındayız: key'i gönderen, saklayan ve sağlayıcının yanıtlarını yorumlayan biziz.
Key Ödeme Niyeti Başına Bir Kez Üretilir
İlk karar key'in neyi temsil ettiği. HTTP isteği başına key üretirsen retry'lar farklı key taşır ve idempotency hiçbir işe yaramaz. Sipariş başına sabit key de yanlış: müşteri kartı reddedilince başka bir kartla tekrar ödeyebilmeli. Doğru birim "ödeme niyeti": aynı sipariş, aynı tutar, aynı kart. Bookstore'da bunu PaymentAttempt olarak modelledik:
// Bir ödeme niyeti: aynı sipariş, tutar ve kart. Bu niyet için giden her HTTP isteği aynı key'i taşır
public sealed class PaymentAttempt
{
public required Guid Id { get; init; }
public required int OrderId { get; init; }
public required string IdempotencyKey { get; init; }
public required decimal Amount { get; init; }
public required string Currency { get; init; }
public required string CardTokenHash { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public PaymentAttemptStatus Status { get; set; } = PaymentAttemptStatus.Open;
public string? ProviderPaymentId { get; set; }
public DateTimeOffset? LastCheckedAt { get; set; }
// Sağlayıcının merchant_reference olarak gördüğü değer; reconciliation bununla arar
public string Reference => $"bookstore-{OrderId}-{Id:N}";
}
PaymentAttemptStore.GetOrStart açık bir attempt varsa ve tutar ile kart aynıysa onu geri veriyor, yoksa Guid.CreateVersion7 ile yeni bir key üretiyor. Kart token'ını saklamıyoruz, yalnız SHA-256 hash'ini karşılaştırıyoruz. Açık bir attempt varken müşteri başka bir kartla ödemeye çalışırsa yeni key üretmiyoruz:
var open = _attempts.SingleOrDefault(a => a.OrderId == orderId && a.Status == PaymentAttemptStatus.Open);
if (open is not null && open.Amount == amount && open.CardTokenHash == tokenHash)
{
reused = true;
return open;
}
if (open is not null)
{
// Eski attempt hâlâ çekilmiş olabilirken farklı kart ya da tutar: reddet
throw new PaymentAttemptConflictException(orderId);
}
Bu kural çift çekimi önlüyor. İlk kartla yapılan ödemenin sonucu bilinmezken ikinci kartla ödeme alırsan, ilk ödeme de başarılı çıkarsa müşteri iki kez ödemiş olur. Bunun yerine müşteriye 409 dönüp "önceki ödemen doğrulanıyor" diyoruz.
Key'in ilk istekten önce saklanması da kuralın parçası. Bookstore'un bu serideki siparişleri bellekte duruyor, attempt'ler de öyle. Gerçek uygulamada PaymentAttempts bir tablo ve attempt kaydı, siparişin PaymentPending durumuna geçişiyle aynı transaction'da yazılıyor. Process istek giderken çökerse yeniden açıldığında key'i oradan okuyor. Bu, outbox'ın mantığıyla aynı: önce kaydet, sonra dışarıya konuş. Mekaniğini Message Queue'larla Çalışmak serisinin Transactional Outbox yazısında anlattık.
Adapter: Header'ı Göndermek ve Yanıtları Yorumlamak
ProviderPaymentGateway artık PostAsJsonAsync yerine kendi HttpRequestMessage'ını kuruyor, çünkü header'ı isteğe eklemesi gerekiyor:
var body = new ProviderChargeRequest(
Amount: ToMinorUnits(request.Amount),
Currency: request.Currency,
Source: request.CardToken,
Description: $"Bookstore order {request.OrderId}",
MerchantReference: request.Reference);
using var message = new HttpRequestMessage(HttpMethod.Post, "v1/charges")
{
Content = JsonContent.Create(body, options: Json)
};
// Bu ödeme için her denemede aynı key; taslak değeri tırnaklı bir string olarak tanımlıyor
message.Headers.Add(IdempotencyKeyHeader, $"\"{request.IdempotencyKey}\"");
Yanıt tarafında iki yeni status code'u kendi hata modelimize çevirdik:
return response.StatusCode switch
{
HttpStatusCode.Created or HttpStatusCode.OK => await ReadApprovedAsync(response, cancellationToken),
// Bu key'le gelen ilk istek sağlayıcıda hâlâ çalışıyor
HttpStatusCode.Conflict => throw new PaymentGatewayException(
PaymentFailureKind.UnknownOutcome, "Payment with this key is still being processed."),
// Aynı key, farklı gövde: bizim tarafta bir bug, asla tekrar deneme
HttpStatusCode.UnprocessableEntity => throw new PaymentGatewayException(
PaymentFailureKind.ContractViolation, "Idempotency-Key was reused with a different request."),
HttpStatusCode.PaymentRequired => await ReadDeclinedAsync(response, cancellationToken),
// ... 429, 503 ve 5xx önceki yazılardaki gibi
};
409'u UnknownOutcome saymamızın nedeni şu: sağlayıcı "bu key'le bir işlem başladı ama bitmedi" diyor. Sonuç başarılı da olabilir, red de; elimizdeki bilgi timeout'takinden fazla değil.
Fake ödeme sağlayıcısını da buna göre genişlettik. Bookstore.FakeProviders içindeki FakePaymentProvider artık key'leri, isteğin SHA-256 parmak izini ve sonucu saklıyor; tamamlanmış bir key'e saklanan yanıtı Idempotent-Replayed: true header'ıyla dönüyor, işlenmekte olan bir key'e 409, farklı gövdeyle gelen bir key'e 422 veriyor. GET /v1/charges?merchant_reference=... ile de bir referansın charge'larını listeliyor. Yeni Drop modu ise isteğin yolda kaybolmasını taklit ediyor: hiçbir şey işlenmiyor ve yanıt timeout'tan sonra geliyor.
POST'u Yalnız Key Varken Tekrar Denemek
Artık ödeme POST'unu tekrar deneyebiliriz, ama yalnız key taşıyorsa. DisableForUnsafeHttpMethods'un yerine kendi ShouldHandle'ımızı yazdık:
// POST yalnız Idempotency-Key taşıyorsa tekrar denenir; 409 "hâlâ çalışıyor, sonra tekrar sor" demek
public static bool ShouldRetry(Outcome<HttpResponseMessage> outcome, HttpRequestMessage? request)
{
if (request is null || (request.Method == HttpMethod.Post && !request.Headers.Contains(IdempotencyKeyHeader)))
{
return false;
}
return HttpClientResiliencePredicates.IsTransient(outcome)
|| outcome.Result?.StatusCode == HttpStatusCode.Conflict;
}
payments.AddStandardResilienceHandler()
.Configure(builder.Configuration.GetSection("Resilience:PaymentProvider"))
.Configure(options => options.Retry.ShouldHandle = args =>
ValueTask.FromResult(ProviderPaymentGateway.ShouldRetry(args.Outcome, args.Context.GetRequestMessage())));
Timeout'ta Outcome.Result boş olduğu için isteğe outcome'dan değil, ResilienceContext'ten ulaşıyoruz. GetRequestMessage() Microsoft.Extensions.Http.Resilience'ın bir extension method'u; paketin kendi DisableFor implementasyonu da isteği önce Outcome.Result.RequestMessage'tan, o yoksa context'ten alıyor. appsettings.json'da attempt timeout 3 saniye kaldı, total timeout 4'ten 10 saniyeye çıktı ve retry gecikmesi 1 saniye oldu; 4 saniyelik total timeout'la timeout'tan sonra bir retry'a yer kalmıyordu.
Fake sağlayıcıyı 5 saniyelik işlem süresiyle başlatıp checkout'u çağırdım:
dotnet run --project src/Bookstore.FakeProviders -- --payments Slow --payments-delay 5
dotnet run --project src/Bookstore.Api
API log'undaki ödeme satırları (kısalık için Operation Key: '' alanını çıkardım):
Resilience event occurred. EventName: 'OnTimeout', Source: 'payment-provider-standard//Standard-AttemptTimeout'
Execution attempt. Source: 'payment-provider-standard//Standard-Retry', Result: 'The operation didn't complete within the allowed timeout of '00:00:03'.', Handled: 'True', Attempt: '0', Execution Time: 3011.1853ms
Received HTTP response headers after 11.0755ms - 409
Execution attempt. Source: 'payment-provider-standard//Standard-Retry', Result: '409', Handled: 'True', Attempt: '1', Execution Time: 11.2502ms
Received HTTP response headers after 1.6699ms - 201
Execution attempt. Source: 'payment-provider-standard//Standard-Retry', Result: '201', Handled: 'False', Attempt: '2', Execution Time: 1.7926ms
Resilience pipeline executed. Source: 'payment-provider-standard/', Result: '201', Execution Time: 5490.9235ms
Provider replayed the stored response for key 01a1d96c-e23d-77bf-801e-9df969c1d378
Payment approved by provider: ch_000001
Üç istek gitti, tek bir charge oluştu. İlk deneme 3. saniyede timeout'a düştü; sağlayıcı ise işlemeye devam etti. Yaklaşık 1 saniye sonraki retry 409 aldı, çünkü ilk istek sağlayıcıda hâlâ bitmemişti. 5,49. saniyedeki üçüncü deneme saklanmış 201'i aldı. Müşteri 5,5 saniye bekledi ama kartından bir kez para çekildi. Key olmasaydı aynı akış ya üç ayrı charge ya da müşteriye "ödeme alınamadı" demek anlamına gelirdi.

Pipeline Pes Ettiğinde: 202 ve Aynı Key'le Tekrar
Retry'lar da her zaman yetmiyor. Sağlayıcının işlem süresini 15 saniyeye çıkardım; 10 saniyelik total timeout bu kez sonucu göremiyor. CheckoutService bu durumda exception fırlatmıyor, attempt'i açık bırakıyor ve siparişi PaymentPending döndürüyor:
catch (PaymentGatewayException ex) when (ex.Kind == PaymentFailureKind.UnknownOutcome)
{
// Çekilmiş de olabilir, çekilmemiş de: attempt açık kalır, bir retry ya da reconciliation öğrenir
logger.LogWarning("Order {OrderId}: payment outcome unknown, key {IdempotencyKey}", order.Id, attempt.IdempotencyKey);
return new CheckoutResult(order.Id, order.Status, total, null) { ShippingIsFlatRate = quote.IsFlatRate };
}
Endpoint bu sonucu 202 Accepted ve Location: /api/orders/1001 ile dönüyor; istemci tekrar ödemek yerine siparişin durumunu soruyor. Bookstore.Api.http'deki istekleri sırayla gönderdim: önce tok_visa, hemen ardından aynı sipariş için tok_mastercard, sonra sipariş durumu, 8 saniye sonra yine tok_visa:
$ checkout tok_visa
{"orderId":1001,"status":"PaymentPending","total":548.90,"providerPaymentId":null,"declineReason":null,"shippingIsFlatRate":false} -> 202
$ checkout tok_mastercard
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"A previous payment for this order is still being verified.","status":409,"traceId":"00-c0fcdfbc30189867952bc7745a69a188-e213d374dcca3db5-02"} -> 409
$ GET order
{"id":1001,"status":"PaymentPending","providerPaymentId":null,"attempts":[{"idempotencyKey":"01a1d96d-4807-7712-a456-9925d2b2a082","status":"Open"}]}
$ checkout tok_visa (again)
{"orderId":1001,"status":"Paid","total":548.90,"providerPaymentId":"ch_000001","declineReason":null,"shippingIsFlatRate":false} -> 200
İlk checkout'un pipeline'ı bir timeout ve üç 409 ile 8,6 saniyede pes etti ve 202 döndü. Farklı kartla gelen ikinci deneme 409 aldı. Müşteri aynı kartla tekrar denediğinde GetOrStart açık attempt'i buldu, istek aynı key'le gitti ve sağlayıcı o arada tamamlanmış charge'ı Idempotent-Replayed ile döndü. Log'da bu sırayla görünüyor:
Order 1001: payment outcome unknown, key 01a1d96d-4807-7712-a456-9925d2b2a082
Order 1001: resending payment with the stored key 01a1d96d-4807-7712-a456-9925d2b2a082
Provider replayed the stored response for key 01a1d96d-4807-7712-a456-9925d2b2a082
Payment approved by provider: ch_000001
Yani müşterinin "tekrar dene" düğmesi artık güvenli bir retry. Ödeme sayfasında çift tıklama ya da sayfayı yenileyip tekrar göndermek ikinci bir charge üretmiyor.
Reconciliation: Müşteri Geri Gelmezse
Müşteri her zaman tekrar denemiyor; tarayıcıyı kapatıp gidebilir. Açık kalan attempt'leri sağlayıcıya soran bir job gerekiyor. PaymentReconciliationJob bir BackgroundService; TimeProvider ile çalışan bir PeriodicTimer'la her dakika uyanıyor ve 2 dakikadan eski açık attempt'leri tek tek sorguluyor:
foreach (var attempt in attempts.OpenOlderThan(now - options.Value.MinAge))
{
var order = orders.Find(attempt.OrderId)!;
attempt.LastCheckedAt = now;
var charge = await payments.FindChargeAsync(attempt.Reference, cancellationToken);
if (charge is not null)
{
attempt.Status = PaymentAttemptStatus.Succeeded;
attempt.ProviderPaymentId = charge.ProviderPaymentId;
order.Status = OrderStatus.Paid;
order.ProviderPaymentId = charge.ProviderPaymentId;
logger.LogInformation("Reconciled order {OrderId}: provider has charge {ProviderPaymentId}", order.Id, charge.ProviderPaymentId);
}
else if (now - attempt.CreatedAt >= options.Value.AbandonAfter)
{
attempt.Status = PaymentAttemptStatus.Abandoned;
order.Status = OrderStatus.Placed;
logger.LogWarning("Reconciled order {OrderId}: no charge after {Age}, attempt abandoned", order.Id, now - attempt.CreatedAt);
}
else
{
logger.LogInformation("Order {OrderId}: no charge at the provider yet, will check again", order.Id);
}
}
FindChargeAsync bir GET ve standard pipeline onu her GET gibi tekrar deniyor. Sağlayıcı key'e göre arama sunmuyorsa (çoğu sunmuyor) kendi referansını her istekte göndermek bu yüzden önemli: merchant_reference ile arayabildiğimiz için job hangi charge'ın bizim attempt'imize ait olduğunu biliyor.
İki senaryoyu denemek için job'un sürelerini komut satırından kısalttım (Interval=5 sn, MinAge=10 sn, AbandonAfter=40 sn). Sağlayıcı 15 saniyede işlerken:
Order 1001: payment outcome unknown, key 01a1d96d-cc25-737a-b939-dfd362d275f1
Order 1001: no charge at the provider yet, will check again
Reconciled order 1001: provider has charge ch_000001
Sağlayıcı Drop modundayken, yani istek hiç işlenmediğinde:
Order 1001: payment outcome unknown, key 01a1d96e-5f4b-74c6-8543-5b75ee37b64f
Order 1001: no charge at the provider yet, will check again
...
Reconciled order 1001: no charge after 00:00:44.8674120, attempt abandoned
Abandoned attempt'ten sonra sipariş Placed'e dönüyor ve müşteri yeni bir key'le tekrar ödeyebiliyor. AbandonAfter'ın iki sınırı var: sağlayıcıda gecikmiş bir isteğin hâlâ işlenebileceği süreden uzun, sağlayıcının key saklama süresinden de kısa olmalı. Varsayılan olarak 30 dakika seçtik; sağlayıcı key'leri 24 saat saklıyorsa geniş bir pay kalıyor.
Job'un kendisi bellekte ve tek instance'ta çalışıyor. Birden çok API instance'ı varsa aynı attempt'i iki instance sorgulayabilir; sorgu GET olduğu için bu zararsız, ama durum güncellemesini veritabanında koşullu yapmak gerekir. Kalıcı ve dağıtık job'lar için Background Job'lar serisindeki Hangfire yazılarına bak; aynı serinin retry ve idempotency yazısı da e-posta sağlayıcısı örneğiyle aynı key mantığını job tarafından anlatıyor.
Bir de muhasebe tarafı var. Sağlayıcılar günlük ya da haftalık bir settlement raporu yayımlıyor: hangi charge'lar alındı, hangileri iade edildi, komisyon ne kadar. Bu rapordaki her satırın bizde bir Succeeded attempt'i, her Succeeded attempt'in de raporda bir satırı olmalı. Bizim reconciliation job'umuz dakikalar içinde siparişi doğru duruma getiriyor; muhasebe mutabakatı ise günlük olarak, kalan istisnaları (bizde olmayan bir charge, raporda olmayan bir ödeme) bir insanın önüne koyuyor.
Testler: "Hiç Tekrar Deneme" Yerine "Aynı Key'le Dene"
05. yazıdaki Payment_post_is_never_retried testi retry'ı açtığımız anda kırmızıya döndü; zaten bunun için yazmıştık. Yerine davranışın yeni tanımını yazdık:
[Fact]
public async Task Payment_retries_reuse_one_idempotency_key()
{
FakePaymentProvider.Configure(_payments, FakeMode.Error);
await using var api = new BookstoreApiFactory(_payments, _carrier, settings: FastPaymentRetries);
using var response = await CheckoutAsync(api.CreateClient());
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal(4, ChargeRequests); // 1 deneme + 3 retry
var key = Assert.Single(ChargeKeys.Distinct());
}
ChargeKeys, WireMock'un LogEntries'inden her POST'un Idempotency-Key header'ını okuyor; FastPaymentRetries testte retry gecikmesini 10 ms'ye indiriyor. %20 hata testini de 100 ayrı siparişle yeniden yazdık, çünkü artık ödenmiş bir sipariş tekrar ödenmiyor. Sağlayıcının kaç charge oluşturduğunu fake'in liste endpoint'inden soruyoruz:
passed Bookstore.Api.Tests.CheckoutResilienceTests.Payment_retries_reuse_one_idempotency_key (732ms)
charge requests=4, distinct keys=1, key="01a1d970-dc54-712e-b971-20f3eab27bc5"
passed Bookstore.Api.Tests.CheckoutResilienceTests.Checkout_with_twenty_percent_payment_failures (659ms)
responses: 200 x 70, 503 x 30; charge requests: 103; distinct keys: 71; charges at provider: 70
passed Bookstore.Api.Tests.CheckoutResilienceTests.Unknown_outcome_is_settled_by_reconciliation (1s 934ms)
checkout=202, after reconciliation: Paid ch_000001, charge requests=5
İlk satırda WireMock'un kaydettiği header değeri tırnaklarıyla görünüyor; taslaktaki string biçimi bu. Üçüncü test bilinmeyen sonucu uçtan uca kuruyor: sağlayıcı 1 saniyede işliyor, biz 0,5 saniyede pes ediyoruz, checkout 202 dönüyor ve job'un RunOnceAsync'i elle çağrıldığında sipariş Paid oluyor. İkinci satırdaki sonuç beni şaşırttı. Retry'larla neredeyse bütün siparişlerin ödenmesini bekliyordum ama 30 sipariş 503 aldı ve sağlayıcı yalnız 71 farklı key gördü. Log'da nedeni vardı: OnCircuitOpened. Standard pipeline'ın circuit breaker'ı %10 hata oranında ve en az 100 istekte açılıyor. 05. yazıda retry olmadığı için 100 checkout tam 100 istek üretiyordu ve eşiğe ancak son istekte varılıyordu; retry'lar istek sayısını artırınca %20 hata breaker'ı açtı ve kalan siparişler ağa hiç çıkmadan 503 aldı. Doğru davranış bu: sağlayıcının beşte bir isteği hata veriyorsa onu yüklemeye devam etmek istemeyiz. Testin doğruladığı asıl şey de bozulmadı: 70 başarılı yanıt ve sağlayıcıda 70 charge, yani hiçbir sipariş iki kez ödenmedi.
Üretimde Dikkat
Key'in saklama süresini sağlayıcının dokümanından öğren ve AbandonAfter'ı ona göre ayarla. Saklama süresinden sonra aynı key'le gelen bir istek, ilk istek işlenmiş olsa bile yeni bir ödeme olarak işlenebilir.
Idempotent-Replayed gibi header'lar sağlayıcıdan sağlayıcıya değişiyor, bazıları hiç göndermiyor. Mantığını bu header'a bağlama; onu yalnız log ve metric için kullan. Asıl karar status code'a ve gövdeye göre verilmeli.
Açık attempt sayısını ve yaşını metric olarak izle. Reconciliation job'u çalışmayı bırakırsa müşteriler "ödedim ama siparişim beklemede" diye aramaya başlar; bundan önce "30 dakikadan eski açık attempt sayısı 0'dan büyük" alarmının çalması gerekir.
Breaker eşiklerini retry'larla birlikte düşün. Retry'lar breaker'ın gördüğü istek sayısını artırıyor; ödeme gibi düşük hacimli bir client'ta MinimumThroughput'u ve FailureRatio'yu gerçek trafiğinle ölç.
Sık Düşülen Tuzaklar
Key'i her HTTP isteğinde yeniden üretmek. HttpClient'a bir DelegatingHandler ekleyip her isteğe Guid.NewGuid() koymak idempotency gibi görünür ama hiçbir retry'ı korumaz.
Key'i göndermeden önce saklamamak. Process istek yoldayken çökerse key kaybolur ve aynı ödeme yeni bir key'le tekrar gönderilir.
409'u hata ya da başarı saymak. 409 "henüz bilmiyorum" demek; ödeme başarısız sayılıp müşteriye tekrar ödetilirse çift çekim olur.
Bilinmeyen sonucu müşteriye hata olarak göstermek. "Ödeme alınamadı" mesajını gören müşteri başka kartla tekrar öder. 202 ve "ödemen doğrulanıyor" mesajı daha doğru.
Küçük Bir Deneme
★☆☆ Fake'i --payments Slow --payments-delay 5 ile başlat ve Resilience:PaymentProvider:Retry:Delay değerini önce 00:00:00.200, sonra 00:00:03 yap. Her seferinde kaç 409 görüyorsun ve checkout toplam ne kadar sürüyor?
★★☆ ShouldRetry'ı değiştirmeden ödeme client'ına her isteğe yeni bir Guid ekleyen bir DelegatingHandler ekle. Payment_retries_reuse_one_idempotency_key testi hangi assert'te kırılıyor?
★★★ PaymentReconciliationJob için FakeTimeProvider kullanan bir test yaz: Drop modunda checkout 202 dönsün, saati AbandonAfter kadar ilerlettiğinde sipariş Placed'e, attempt Abandoned'a dönsün. İpucu: job'un PeriodicTimer'ı ve pipeline'ın timeout'ları aynı TimeProvider'ı kullanıyor.
Bu yazıda bilinmeyen sonucu iki yoldan çözdük: aynı key'le tekrar sormak ve durumu periyodik olarak sağlayıcıdan sorgulamak. Üçüncü bir yol daha var: sağlayıcının, ödeme bittiğinde bize haber vermesi. Bunun için önce webhook'un öbür tarafına geçeceğiz. Bir sonraki yazıda Bookstore'un kendisi webhook gönderiyor: kurumsal müşterilerine order.shipped event'ini imzalı, outbox'tan ve bir retry takvimiyle teslim ediyoruz.