Microsoft.Extensions.Http.Resilience ile Standard Pipeline: Varsayılanlar, Ayarlar ve Tuzaklar
AddStandardResilienceHandler dıştan içe rate limiter, 30 sn total timeout, 3 retry, circuit breaker ve 10 sn attempt timeout ekler; varsayılan olarak POST'u da tekrar dener ve HttpClient.Timeout'u sonsuza çeker. Bookstore'un ödeme, kargo ve kur client'ları için ayrı pipeline'ları, appsettings ile ayarı, validation kurallarını, iki handler'ın retry'ı çarpmasını ve hedging'i gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.Http.Resilience 10.11, Polly 8.9 ve WireMock.Net 2.31 ile derlenip denenmiştir.
Önce Tahmin Et
Kargo client'ına AddStandardResilienceHandler() ekledin ve aynı kayıtta client.Timeout = TimeSpan.FromSeconds(3) yazdın. Kargo API'si her isteğe 5 saniyede cevap veriyor. İstek kaç saniye sonra biter?
Bir ekip arkadaşın "ne olur ne olmaz" diyerek aynı client'a ikinci bir AddStandardResilienceHandler() ekliyor. Sağlayıcı sürekli 500 dönüyorsa sağlayıcıya kaç istek gider?
Ödeme sağlayıcısının POST /v1/charges çağrısı bir kez 503 döndü. Standard pipeline varsayılan ayarlarla bu POST'u tekrar dener mi?
Cevaplar: 3 saniye değil, 16,8 saniye; 4 değil, 16 istek; evet, dener. Üçünü de bu yazıda gerçek çalıştırmalarla göreceğiz. Bir önceki yazıda client'larımızı IHttpClientFactory ile kurduk ama her çağrı tek denemeydi. Şimdi Microsoft.Extensions.Http.Resilience paketinin standard pipeline'ını ekliyoruz.
Deneyler yine Bookstore.ResilienceLab içinde birer komut; her biri kendi fake sunucusunu başlatıyor:
dotnet run --project samples/Bookstore.ResilienceLab -- standard-defaults
dotnet run --project samples/Bookstore.ResilienceLab -- standard-retry
dotnet run --project samples/Bookstore.ResilienceLab -- standard-post
dotnet run --project samples/Bookstore.ResilienceLab -- standard-validation
dotnet run --project samples/Bookstore.ResilienceLab -- client-timeout
dotnet run --project samples/Bookstore.ResilienceLab -- stacked
dotnet run --project samples/Bookstore.ResilienceLab -- hedging
Standard Pipeline Ne Getiriyor
Paket, IHttpClientBuilder üzerine Polly v8 ile kurulmuş bir DelegatingHandler ekliyor. AddStandardResilienceHandler() tek satırda beş strategy'yi dıştan içe sıralıyor. Değerleri dokümandan değil, HttpStandardResilienceOptions'ın kendisinden okudum:
var o = new HttpStandardResilienceOptions();
Console.WriteLine($"RateLimiter : permits={o.RateLimiter.DefaultRateLimiterOptions.PermitLimit}, queue={o.RateLimiter.DefaultRateLimiterOptions.QueueLimit}");
Console.WriteLine($"TotalRequestTimeout: {o.TotalRequestTimeout.Timeout}");
Console.WriteLine($"Retry : max={o.Retry.MaxRetryAttempts}, backoff={o.Retry.BackoffType}, jitter={o.Retry.UseJitter}, delay={o.Retry.Delay}, retryAfter={o.Retry.ShouldRetryAfterHeader}");
Console.WriteLine($"CircuitBreaker : ratio={o.CircuitBreaker.FailureRatio}, minThroughput={o.CircuitBreaker.MinimumThroughput}, sampling={o.CircuitBreaker.SamplingDuration}, break={o.CircuitBreaker.BreakDuration}");
Console.WriteLine($"AttemptTimeout : {o.AttemptTimeout.Timeout}");
RateLimiter : permits=1000, queue=0
TotalRequestTimeout: 00:00:30
Retry : max=3, backoff=Exponential, jitter=True, delay=00:00:02, retryAfter=True
CircuitBreaker : ratio=0.1, minThroughput=100, sampling=00:00:30, break=00:00:05
AttemptTimeout : 00:00:10

Sıralama önemli. Rate limiter en dışta, yani aynı anda en fazla 1000 istek pipeline'a girebiliyor ve queue 0 olduğu için 1001. istek beklemeden reddediliyor. Total timeout, retry'ın dışında: bütün denemeler ve aralarındaki beklemeler toplam 30 saniyeyi geçemez. Circuit breaker ve attempt timeout retry'ın içinde: her deneme ayrı ayrı 10 saniyeyle sınırlı ve her deneme breaker'ın istatistiğine ayrı bir kayıt olarak giriyor. ShouldRetryAfterHeader true olduğu için, sağlayıcı Retry-After gönderirse retry kendi hesapladığı süre yerine o süreyi bekliyor.
Retry ve circuit breaker aynı hataları ele alıyor: 500 ve üstü, 408 ve 429 status code'ları ile HttpRequestException ve Polly'nin TimeoutRejectedException'ı. 400, 401, 404 gibi yanıtlar tekrar denenmiyor; aynı isteği tekrar göndermek aynı sonucu verir.
Microsoft.Extensions.Http.Resilience 11.0 da .NET 11 ile birlikte yayınlandı ve net10.0'ı da destekliyor. Bu seride .NET 10 LTS (Long Term Support; uzun süreli destek) tabanında kaldığımız için 10.x hattını kullanıyoruz; bu yazıdaki API'ler iki sürümde de aynı.
İlk Retry: Exponential Backoff ve Jitter
Kur servisini taklit eden bir fake, ilk iki isteğe 503, üçüncüsüne 200 dönüyor. Client'a yalnız standard handler'ı ekledim ve Polly'nin log'larını açtım:
services.AddHttpClient("rates", c => c.BaseAddress = server.Url)
.AddStandardResilienceHandler();
12:07:20.448 warn: Polly[0] Resilience event occurred. EventName: 'OnRetry', Source: 'rates-standard//Standard-Retry', Operation Key: '', Result: '503'
12:07:21.610 warn: Polly[0] Resilience event occurred. EventName: 'OnRetry', Source: 'rates-standard//Standard-Retry', Operation Key: '', Result: '503'
12:07:24.610 info: Polly[3] Execution attempt. Source: 'rates-standard//Standard-Retry', Operation Key: '', Result: '200', Handled: 'False', Attempt: '2', Execution Time: 1.5283ms
12:07:24.614 info: Polly[2] Resilience pipeline executed. Source: 'rates-standard/', Operation Key: '', Result: '200', Execution Time: 4435.7822ms
GET -> 200 after 3 attempts in 4.4 s
İlk bekleme yaklaşık 1,2 saniye, ikincisi 3 saniye. Taban gecikme 2 saniye ve exponential, ama jitter açık olduğu için süreler tam 2 ve 4 değil. Polly exponential backoff'ta jitter için "decorrelated jitter" adlı bir formül kullanıyor; amaç, aynı anda hata alan yüzlerce client'ın aynı saniyede tekrar gelmesini önlemek. Neden önemli olduğunu System Design serisinin Reliability Pattern'leri yazısında anlattık.
Log'daki Source alanının biçimi <client-name>-standard//Standard-Retry. Bu isim 05. yazıda metric'lerde ve alarmlarda da göreceğimiz isim; hangi client'ın hangi strategy'sinin çalıştığını buradan okuyorsun.
POST'ta Retry: DisableForUnsafeHttpMethods
Standard pipeline varsayılan olarak bütün HTTP method'larını tekrar dener. Ödeme için bu tehlikeli: ilk istek sağlayıcıya ulaşıp kartı çekmiş, yanıt dönerken bir proxy 503 üretmiş olabilir. Aynı fake'e POST gönderdim, önce varsayılanla, sonra retry'ı unsafe method'lar için kapatarak:
.AddStandardResilienceHandler(o =>
{
o.Retry.Delay = TimeSpan.FromMilliseconds(100);
if (disable)
{
o.Retry.DisableForUnsafeHttpMethods();
}
})
DisableForUnsafeHttpMethods=False: POST -> 200, provider saw 3 request(s)
DisableForUnsafeHttpMethods=True : POST -> 503, provider saw 1 request(s)
Varsayılan ayarla sağlayıcı aynı ödeme isteğini üç kez gördü. DisableForUnsafeHttpMethods, RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 9110'un "safe" saymadığı POST, PATCH, PUT, DELETE ve CONNECT için retry'ı kapatıyor. Belirli method'ları seçmek istersen DisableFor(HttpMethod.Post) da var. RFC 9110'a göre PUT ve DELETE idempotent, yani tekrar göndermek tanım gereği güvenli; ama karşı tarafın bunu gerçekten öyle uyguladığından emin değilsen hepsini kapatmak daha az sürpriz üretir.
POST'u güvenle tekrar denemenin yolu isteği idempotent yapmak: sağlayıcıya bir Idempotency-Key göndermek ve sağlayıcının aynı key'li ikinci isteği tekrar işlememesi. Bu, serinin 06. yazısının konusu. O zamana kadar Bookstore'un ödeme client'ında retry kapalı.
Bookstore'un Pipeline'ları: appsettings ve Kod
Bookstore'da üç client'ın üç ayrı ihtiyacı var. Ödeme POST'u tekrar denenmemeli ve kullanıcı checkout ekranında beklediği için kısa sürmeli. Kargo fiyatı bir GET; iki kez tekrar denemek makul, ama kullanıcı yine bekliyor. Kur bilgisi okuma ağırlıklı ve iki bölgede çalışan bir servisten geliyor. Değerleri appsettings'e koyduk ki ortam bazında değiştirilebilsin, ama "POST'ta retry yok" kuralı kodda kaldı; config ile kapatılamaması gereken bir karar:
"Resilience": {
"PaymentProvider": {
"AttemptTimeout": { "Timeout": "00:00:03" },
"TotalRequestTimeout": { "Timeout": "00:00:04" }
},
"LegacyCarrier": {
"AttemptTimeout": { "Timeout": "00:00:02" },
"TotalRequestTimeout": { "Timeout": "00:00:06" },
"Retry": { "MaxRetryAttempts": 2, "Delay": "00:00:00.200" }
}
}
// Ödeme: POST henüz idempotent değil (06. yazı), retry yok; kısa timeout'lar appsettings'ten
var payments = builder.Services.AddHttpClient(ProviderPaymentGateway.ClientName, client =>
{
client.BaseAddress = new Uri(builder.Configuration["Payments:Provider:BaseUrl"]!);
client.DefaultRequestHeaders.Authorization = new("Bearer", builder.Configuration["Payments:Provider:ApiKey"]);
})
.AddAsKeyed();
payments.AddStandardResilienceHandler()
.Configure(builder.Configuration.GetSection("Resilience:PaymentProvider"))
.Configure(options => options.Retry.DisableForUnsafeHttpMethods());
var carrier = builder.Services.AddHttpClient(LegacyCarrierClient.ClientName, (sp, client) =>
client.BaseAddress = sp.GetRequiredService<IOptions<LegacyCarrierOptions>>().Value.BaseUrl)
.AddAsKeyed();
carrier.AddStandardResilienceHandler()
.Configure(builder.Configuration.GetSection("Resilience:LegacyCarrier"));
// Resilience handler'dan sonra (içeride) ekleniyor: her retry güncel token'ı ister
carrier.AddHttpMessageHandler<CarrierAuthHandler>();
Handler sırasına dikkat. CarrierAuthHandler resilience handler'dan sonra eklendiği için zincirde onun içinde duruyor. Retry her denemede auth handler'dan tekrar geçiyor ve token o arada yenilendiyse yeni token gidiyor. Ters sırada, token bir kez eklenir ve bütün retry'lar aynı token'la gider.
Pipeline artık Polly'nin kendi exception'larını da fırlatıyor ve adapter'ın bunları kendi hata modelimize çevirmesi gerekiyor. Ödeme adapter'ına iki catch ekledik:
catch (BrokenCircuitException ex)
{
// Circuit açık: istek process'ten hiç çıkmadı
throw new PaymentGatewayException(PaymentFailureKind.Unavailable, "Payment provider circuit is open.", ex)
{ RetryAfter = ex.RetryAfter };
}
catch (TimeoutRejectedException ex)
{
// Resilience pipeline vazgeçti; istek sağlayıcıya ulaşmış olabilir
throw new PaymentGatewayException(PaymentFailureKind.UnknownOutcome, "Payment provider did not answer in time.", ex);
}
İlk yazıdaki TaskCanceledException yakalayan blok artık tek başına yetmiyor: attempt timeout dolduğunda gelen exception TimeoutRejectedException. Bunu unutsaydık "bilinmeyen sonuç" durumu 500 olarak dönerdi. Ödeme sağlayıcısı 5 saniyede cevap verirken checkout'u denedim:
HTTP/1.1 202 Accepted
{"title":"Payment is being verified.","status":202,"traceId":"00-ca7e0baa8b311fc77f09210106d4bb8d-905daef5fce3105b-00"}
Yanıt 4,05 saniyede geldi: yaklaşık 1 saniye kargo çağrısı (ilk istek olduğu için token alımı dahil) ve 3 saniye ödeme denemesi. Log'da Standard-AttemptTimeout'un OnTimeout event'i ve Execution Time: 3006.5167ms satırı var.
Validation: Timeout'lar Birbirini Tutmalı
Options'lar ilk client oluşturulurken doğrulanıyor. Attempt timeout'u total timeout'tan büyük verdim:
.AddStandardResilienceHandler(o =>
{
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(20);
o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(15);
})
Total request timeout resilience strategy must have a greater timeout than the attempt resilience strategy. Total Request Timeout: 15s, Attempt Timeout: 20s; The sampling duration of circuit breaker strategy needs to be at least double of an attempt timeout strategy’s timeout interval, in order to be effective. Sampling Duration: 30s,Attempt Timeout: 20s
İki kural birden yakalandı. Birincisi açık: tek bir deneme toplam süreden uzun olamaz. İkincisi daha az bilinen: circuit breaker'ın örnekleme süresi attempt timeout'un en az iki katı olmalı. 20 saniyelik bir deneme 30 saniyelik pencerede en fazla bir kez timeout'a düşebilir; breaker anlamlı bir oran hesaplayamaz. Exception OptionsValidationException ve uygulamanın açılışında değil, client'ın ilk oluşturulduğu anda geliyor. Bu yüzden config değerlerini değiştiren bir deploy'dan sonra ilk isteği bir smoke test ile tetiklemek iyi bir alışkanlık.
HttpClient.Timeout Pipeline'ı Nasıl Etkiler
Tahmin sorusundaki sürpriz burada. Kargo client'ına 3 saniyelik HttpClient.Timeout ve 2 saniyelik attempt timeout verdim, fake her isteği 5 saniye bekletiyor. Önce timeout'u AddHttpClient'in içinde, sonra handler'dan sonra ConfigureHttpClient ile ayarladım:
var http = s.AddHttpClient("carrier", c =>
{
c.BaseAddress = server.Url;
c.Timeout = TimeSpan.FromSeconds(3);
});
http.AddStandardResilienceHandler(o => o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2));
if (setAfterHandler)
{
http.ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(3));
}
Timeout set before the handler: HttpClient.Timeout=-00:00:00.0010000
TimeoutRejectedException after 16.8 s, server saw 4 attempt(s)
Timeout set after the handler: HttpClient.Timeout=00:00:03
TaskCanceledException after 3.0 s, server saw 2 attempt(s)
İlk durumda HttpClient.Timeout -1 ms, yani Timeout.InfiniteTimeSpan. AddStandardResilienceHandler, kaynak kodundaki yorumda yazdığı gibi, timeout kontrolünü pipeline'a bırakmak için HttpClient.Timeout'u sonsuza çekiyor. Bunu kendi ConfigureHttpClient çağrısıyla yapıyor ve configure action'ları kayıt sırasıyla çalıştığı için, ondan önce verdiğin değer sessizce eziliyor. Sonuç: dört deneme, 16,8 saniye. İkinci durumda değer handler'dan sonra verildi ve 3. saniyede retry'lar dahil her şeyi kesti.
Pratik kural: standard handler kullanıyorsan süreleri AttemptTimeout ve TotalRequestTimeout ile ver, HttpClient.Timeout'a dokunma. Bir önceki yazıda client'lara verdiğimiz 5 ve 10 saniyelik değerleri bu yüzden kaldırdık. Yalnız kendi AddResilienceHandler pipeline'ını kuruyorsan HttpClient.Timeout'un toplam süreden büyük olduğundan emin ol; onu otomatik ayarlayan kod yok.
Tek Handler Kuralı ve Hedging
Microsoft'un dokümanı bir client'a yalnız bir resilience handler eklemeyi söylüyor. Bunu atlayınca ne olduğunu sürekli 500 dönen bir fake ile denedim:
1 handler(s): 500, server saw 4 requests
2 handler(s): 500, server saw 16 requests
Dış handler'ın her denemesi iç handler'ın dört denemesini çalıştırıyor: 4 × 4 = 16. Üç katmanlı bir mikroservis zincirinde her katman böyle retry yapıyorsa sayı 64'e çıkar. Bu, System Design serisinde anlattığımız retry amplification'ın kod hâli. Uygulama genelinde ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler()) ile varsayılan bir pipeline koyup bir client'ta başka bir strategy istiyorsan, önce RemoveAllResilienceHandlers() ile mevcut handler'ı kaldırman gerekiyor. Bu method 10.11'de experimental işaretli; derleme EXTEXP0001 hatası veriyor ve kullanmak için uyarıyı bilinçli olarak kapatman gerekiyor.
Kur servisi bu ihtiyacın örneği. Servis iki bölgede çalışıyor ve okuma yapıyoruz; bir bölge yavaşsa beklemek yerine diğerine de sormak mantıklı. Hedging tam olarak bunu yapıyor: ilk istek belirlenen süre içinde dönmezse ikinci bir istek paralel olarak başlıyor, önce dönen kazanıyor, diğeri iptal ediliyor. Birincil bölge 3 saniye geç cevap verirken:
s.ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler());
#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers experimental
s.AddHttpClient("exchange-rates", c => c.BaseAddress = new Uri(primary.Url!))
.RemoveAllResilienceHandlers()
#pragma warning restore EXTEXP0001
.AddStandardHedgingHandler(routing => routing.ConfigureOrderedGroups(groups =>
{
groups.Groups.Add(new UriEndpointGroup { Endpoints = { new WeightedUriEndpoint { Uri = new Uri(primary.Url!) } } });
groups.Groups.Add(new UriEndpointGroup { Endpoints = { new WeightedUriEndpoint { Uri = new Uri(secondary.Url!) } } });
}))
.Configure(o => o.Hedging.Delay = TimeSpan.FromMilliseconds(500));
12:07:49.997 warn: Polly[0] Resilience event occurred. EventName: 'OnHedging', Source: 'exchange-rates-standard-hedging//StandardHedging-Hedging', Operation Key: '', Result: ''
{"pair":"EUR-TRY","rate":44.1250,"source":"secondary"} in 630 ms
500 ms sonra ikinci bölgeye istek gitti ve yanıt 630 ms'de geldi; birincil bölgedeki istek iptal edildi. Standard hedging handler'da circuit breaker, rate limiter ve attempt timeout endpoint başına ayrı tutuluyor, yani yavaş bölgenin breaker'ı açılırsa hedging o bölgeyi atlıyor. Hedging'in bedeli ek yük: her yavaş istekte sağlayıcıya iki istek gidiyor. Bu yüzden yalnız idempotent okumalarda ve birden çok endpoint'in olduğu yerde mantıklı. Ödeme POST'unda hedging, iki kez kart çekmenin hızlı bir yolu olur.
Üretimde Dikkat
Microsoft.Extensions.Http.Resilience 10.11, Polly 8.4.2'ye bağımlı. NuGet'teki son sürüm 8.9.1 ve BrokenCircuitException.RetryAfter gibi property'ler bu arada geldi. Bookstore'da Polly.Extensions ve Polly.RateLimiting paketlerini açıkça 8.9.1 sürümüyle referansladık. Transitive bağımlılıkları açıkça yükseltirken release notlarını okuyup testleri çalıştırmak şart.
Varsayılan değerleri kendi durumuna göre gözden geçir. 30 saniyelik toplam süre, kullanıcının beklediği bir ekran için çok uzun; arka planda çalışan bir senkronizasyon job'ı için makul. Circuit breaker'ın 100 istekten oluşan minimum throughput'u düşük trafikli bir sağlayıcıda breaker'ın hiç açılmaması demek: dakikada 20 istek atıyorsan 30 saniyelik pencerede bu sayıya ulaşamazsın. Breaker ayarlarına bir sonraki yazıda ayrıntılı bakacağız.
Polly her retry'ı ve timeout'u Polly kategorisinde logluyor. Production'da bu log'ları Warning seviyesinde tutmak, "checkout neden 4 saniye sürdü" sorusunun cevabını verir. Metric'lerle birlikte 05. yazıda ele alacağız.
Sık Düşülen Tuzaklar
POST'a retry vermek. Varsayılan pipeline her method'u tekrar dener; idempotent olmayan çağrılarda DisableForUnsafeHttpMethods kullan.
İki resilience handler eklemek. Retry sayısı çarpılır; yukarıdaki 16 istek bunun en küçük örneği.
HttpClient.Timeout'u standard handler'dan önce ayarlayıp çalıştığını sanmak. Değer sessizce sonsuza çekiliyor.
Adapter'da TimeoutRejectedException ve BrokenCircuitException'ı ele almamak. Bu exception'lar HttpRequestException değil ve ayrı yakalanmazsa kullanıcıya 500 olarak gider.
Auth handler'ı resilience handler'dan önce eklemek. Token bir kez eklenir; token yenilendiğinde bile retry'lar eski token'la gider.
Küçük Bir Deneme
★☆☆ standard-retry demosunda o.Retry.UseJitter = false yap. İki bekleme süresi tam olarak kaç saniye oluyor? Sonra BackoffType = DelayBackoffType.Linear dene.
★★☆ Fake kargo firmasını --carrier Throttle modunda (429 + Retry-After: 2) başlat ve checkout'u çağır. API log'undaki OnRetry satırlarının zamanlarına bak: beklemeler 2 saniye mi, exponential hesap mı? Sonra ShouldRetryAfterHeader'ı appsettings'ten false yap ve tekrar ölç.
★★★ Kur servisi için hedging yerine standard pipeline kullanan ikinci bir client yaz. Birincil bölge 3 saniye geciktiğinde iki client'ın p50 ve p99 sürelerini 100 istek üzerinden karşılaştır. Hedging'in sağlayıcıya gönderdiği toplam istek sayısını da say.
Standard pipeline hazır bir paket ve çoğu durumda iyi bir başlangıç. Ama kargo fiyatı alınamadığında sabit tarifeye düşmek, bakım penceresinde breaker'ı elle açmak ya da SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) gibi HTTP olmayan bir çağrıyı korumak için Polly'yi doğrudan kullanmak gerekiyor. Bir sonraki yazıda ResiliencePipelineBuilder ile strategy'leri tek tek kuruyoruz.