Mert Özen Her Satırda Daha İleri
Backend External Service Integration ve Resilience — #04

Polly v8 Strategy'leri Derinlemesine: Retry, Timeout, Circuit Breaker, Rate Limiter ve Fallback

Mert Özen 14 Kas 2026 13 dk 9 görüntülenme
Polly v8 Strategy'leri Derinlemesine: Retry, Timeout, Circuit Breaker, Rate Limiter ve Fallback

Polly v8'de strategy sırası davranışı belirler: toplam timeout dışta, retry ortada, deneme timeout'u içte olmalı; MaxDelay Retry-After'ı sınırlamaz ve timeout token'ı dinlemeyen kodu durduramaz. Bookstore'da SMTP için isimli pipeline, Retry-After'a uyan retry, elle açılabilen ve health check'e bağlı circuit breaker, concurrency limiter ve kargo fiyatı için sabit tarife fallback'ini gerçek çıktılarla kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Polly 8.9, Microsoft.Extensions.Http.Resilience 10.11, MailKit 5.0 ve WireMock.Net 2.31 ile derlenip denenmiştir.

Önce Tahmin Et

Bir pipeline'a 1 saniyelik timeout ekledin ve içinde CancellationToken almayan eski bir SDK çağrısı var; çağrı 3 saniye sürüyor. Pipeline 1. saniyede TimeoutRejectedException fırlatır mı?

İkinci soru: retry'a MaxDelay = TimeSpan.FromSeconds(3) verdin. Kargo firması bakımda ve 503 ile birlikte Retry-After: 600 gönderiyor. Retry en fazla 3 saniye mi bekler?

Üçüncü soru: aynı pipeline'da timeout'u retry'ın dışına koydun. İlk deneme takılırsa retry ikinci denemeyi yapma fırsatı bulur mu?

Üç cevap da "hayır" ve üçü de gerçek çalıştırmalardan geliyor. Bir önceki yazıda standard pipeline'ı hazır bir paket olarak kullandık. Bu yazıda Polly v8'in strategy'lerini tek tek kuruyoruz: sıralarını, ayarlarını, HTTP dışındaki bir çağrıyı korumayı ve kargo fiyatı alınamadığında checkout'u durdurmadan sabit tarifeye düşmeyi.

Bu yazının deneyleri de Bookstore.ResilienceLab içinde; Bookstore API'si ile ilgili çıktılar için fake'leri ve API'yi ilk yazıdaki gibi başlatıyoruz:

dotnet run --project samples/Bookstore.ResilienceLab -- order
dotnet run --project samples/Bookstore.ResilienceLab -- context
dotnet run --project samples/Bookstore.ResilienceLab -- optimistic-timeout
dotnet run --project samples/Bookstore.ResilienceLab -- retry-after
dotnet run --project samples/Bookstore.ResilienceLab -- circuit-breaker
dotnet run --project samples/Bookstore.ResilienceLab -- bulkhead

ResiliencePipelineBuilder: Sıra Her Şeydir

Polly v8'de her şey bir ResiliencePipeline. Builder'a eklediğin ilk strategy en dışta, son eklediğin en içte duruyor. Aynı iki strategy'yi iki farklı sırayla denedim. Çağrılan fonksiyon ilk seferde 5 saniye takılıyor, ikincisinde hemen dönüyor:

var retry = new RetryStrategyOptions { MaxRetryAttempts = 2, Delay = TimeSpan.FromMilliseconds(200) };

await RunAsync("timeout outside retry", new ResiliencePipelineBuilder()
    .AddTimeout(TimeSpan.FromSeconds(2))
    .AddRetry(retry)
    .Build());

await RunAsync("timeout inside retry ", new ResiliencePipelineBuilder()
    .AddRetry(retry)
    .AddTimeout(TimeSpan.FromSeconds(2))
    .Build());

await RunAsync("inside, HttpRequestException only", new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 2,
        Delay = TimeSpan.FromMilliseconds(200),
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
    })
    .AddTimeout(TimeSpan.FromSeconds(2))
    .Build());
[ 2.03s] timeout outside retry: TimeoutRejectedException in 2.0 s
[ 4.25s] timeout inside retry : quote after 2 call(s) in 2.2 s
[ 6.25s] inside, HttpRequestException only: TimeoutRejectedException in 2.0 s

Timeout dışarıdayken bütün retry'ları kapsayan tek bir süre oluyor; ilk deneme bu süreyi tüketince retry'a sıra gelmiyor. İçerideyken her denemenin kendi süresi var ve ikinci deneme başarıyla dönüyor. Üçüncü satır en sık yapılan hata: timeout içeride ama retry'ın ShouldHandle'ı yalnız HttpRequestException'ı tanıyor. TimeoutRejectedException bir HttpRequestException değil, retry onu görmüyor. RetryStrategyOptions'ın varsayılan ShouldHandle'ı OperationCanceledException dışındaki bütün exception'ları ele aldığı için ilk iki pipeline'da bu sorun yok; sorun, ShouldHandle'ı daralttığın an başlıyor.

Doğru sıra standard pipeline'daki sıra: en dışta toplam süre, sonra retry, sonra circuit breaker, en içte deneme süresi. Kendi pipeline'ını kurarken bu sıradan ayrılıyorsan nedenini bir yorumla yaz.

Pipeline'a giren CancellationToken ve kendi bilgilerin ResilienceContext ile taşınıyor. Context'i pool'dan alıp bir property ekledim ve retry callback'inde okudum:

private static readonly ResiliencePropertyKey<int> OrderIdKey = new("bookstore.order-id");

var context = ResilienceContextPool.Shared.Get("carrier-quote", cts.Token);
context.Properties.Set(OrderIdKey, 1001);
try
{
    await pipeline.ExecuteAsync(ctx => /* ... */, context);
}
finally
{
    ResilienceContextPool.Shared.Return(context);
}
[ 0.00s] attempt 1: operation key=carrier-quote, same token=True
[ 0.01s] OnRetry for order 1001, attempt 1
[ 0.01s] attempt 2: operation key=carrier-quote, same token=True

OperationKey log'larda ve metric'lerde görünüyor; aynı pipeline'ı farklı işler için kullanıyorsan hangisinin retry yaptığını buradan ayırırsın. Context'i pool'a geri vermeyi unutma, Polly allocation'ı azaltmak için bu nesneleri tekrar kullanıyor.

HTTP Dışında: SMTP İçin AddResiliencePipeline

Sipariş onay e-postası SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) ile gidiyor; ortada HttpClient ve dolayısıyla IHttpClientFactory yok. Polly.Extensions paketi pipeline'ları DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) container'ına isimle kaydetmeyi sağlıyor:

// DI'daki registry'ye isimli, generic olmayan bir pipeline (Polly.Extensions)
services.AddResiliencePipeline(SmtpEmailSender.PipelineName, builder => builder
    .AddTimeout(TimeSpan.FromSeconds(15))
    .AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 2,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        Delay = TimeSpan.FromSeconds(1),
        // Yalnız geçici olanları tekrar dene: ağ hataları, 4xx SMTP yanıtları, kendi deneme timeout'umuz
        ShouldHandle = new PredicateBuilder()
            .Handle<SocketException>()
            .Handle<IOException>()
            .Handle<ServiceNotConnectedException>()
            .Handle<SmtpProtocolException>()
            .Handle<SmtpCommandException>(ex => (int)ex.StatusCode is >= 400 and < 500)
            .Handle<Polly.Timeout.TimeoutRejectedException>()
    })
    .AddTimeout(TimeSpan.FromSeconds(4)));

SMTP'de 4xx yanıtları "şimdi olmaz, sonra dene", 5xx yanıtları "bu e-posta hiç gitmeyecek" anlamına geliyor; ShouldHandle bu ayrımı yapıyor. Pipeline'ı ResiliencePipelineProvider<string> üzerinden alıp MailKit çağrısını sarıyoruz:

var pipeline = pipelines.GetPipeline(PipelineName);
await pipeline.ExecuteAsync(async token =>
{
    using var smtp = new SmtpClient();
    await smtp.ConnectAsync(options.Value.Host, options.Value.Port, SecureSocketOptions.None, token);
    await smtp.SendAsync(message, token);
    await smtp.DisconnectAsync(quit: true, token);
}, cancellationToken);

Repo'daki Bookstore.FakeProviders artık 2525 portunda küçük bir fake SMTP sunucusu da başlatıyor. Sunucu hiç çalışmazken (--smtp Error) ve her bağlantıya 421 dönerken (--smtp Throttle) checkout yaptım:

Resilience event occurred. EventName: 'OnRetry', Source: 'smtp/(null)/Retry', Operation Key: '', Result: 'Connection refused'
Resilience event occurred. EventName: 'OnRetry', Source: 'smtp/(null)/Retry', Operation Key: '', Result: 'Connection refused'
Order 1001 confirmation e-mail could not be sent

Resilience event occurred. EventName: 'OnRetry', Source: 'smtp/(null)/Retry', Operation Key: '', Result: '4.3.2 Service not available, try again later'
Resilience event occurred. EventName: 'OnRetry', Source: 'smtp/(null)/Retry', Operation Key: '', Result: '4.3.2 Service not available, try again later'
Order 1001 confirmation e-mail could not be sent

İki durumda da sipariş Paid döndü; e-posta hatası CheckoutService içinde yakalanıp loglanıyor, çünkü ödemesi alınmış bir siparişi e-posta yüzünden hata gibi göstermek yanlış olur. Ama checkout yanıtı bu yüzden 2,7 saniye sürdü. E-postayı request'in içinde göndermek doğru tasarım değil; Background Job'lar serisinde anlattığımız gibi outbox'a yazıp arka planda göndermek gerekir. Burada pipeline'ın HTTP dışı bir çağrıda nasıl kurulduğunu görmek için bu şekilde bıraktık.

Retry: Retry-After ve MaxDelay'in Sınırı

Kendi retry'ını yazıyorsan Retry-After header'ına uymak DelayGenerator ile yapılıyor. Generator null dönerse Polly kendi Delay ve BackoffType hesabını kullanıyor:

DelayGenerator = args =>
{
    // Retry-After: 2 (saniye) ya da bir HTTP tarihi; null "Delay/BackoffType'ı kullan" demek
    var retryAfter = args.Outcome.Result?.Headers.RetryAfter;
    var wait = retryAfter?.Delta ?? (retryAfter?.Date - DateTimeOffset.UtcNow);
    return ValueTask.FromResult(wait);
},
[ 0.00s] retry 1 after 429, waiting 2.00 s
[ 2.01s] retry 2 after 429, waiting 2.00 s
[ 4.01s] final status 200 after 3 attempts

Taban gecikme 200 ms olduğu hâlde iki bekleme de sağlayıcının istediği 2 saniye. HttpRetryStrategyOptions bunu ShouldRetryAfterHeader ile zaten yapıyor; generic pipeline'larda kendin yazman gerekiyor.

Burada bir tuzak var. Kargo client'ının ilk sürümünde MaxDelay = TimeSpan.FromSeconds(3) koymuştum ve "sağlayıcı ne derse desin en fazla 3 saniye beklerim" diye düşünmüştüm. Kargo firması bakımdayken Retry-After: 600 gönderiyor:

warn: Polly[0]
      Resilience event occurred. EventName: 'OnRetry', Source: 'legacy-carrier-carrier//Retry', Operation Key: '', Result: '503'
fail: Polly[0]
      Resilience event occurred. EventName: 'OnTimeout', Source: 'legacy-carrier-carrier//Timeout', Operation Key: '', Result: ''
info: Polly[2]
      Resilience pipeline executed. Source: 'legacy-carrier-carrier/', Operation Key: '', Result: 'The operation didn't complete within the allowed timeout of '00:00:06'.', Execution Time: 6008.2294ms

Retry 600 saniye beklemeye başladı ve 6 saniyelik toplam timeout onu kesti. Polly'nin dokümanı bunu açıkça yazıyor: MaxDelay, DelayGenerator'ın döndürdüğü süreyi sınırlamıyor. Http.Resilience'ın Retry-After desteği de aynı şekilde davrandı. Toplam timeout olmasaydı checkout 10 dakika bekleyecekti. Çözüm, uzun bir Retry-After'ı hiç retry etmemek: o artık geçici bir hata değil, bakım:

.AddRetry(new HttpRetryStrategyOptions
{
    MaxRetryAttempts = 2,
    BackoffType = DelayBackoffType.Exponential,
    UseJitter = true,
    Delay = TimeSpan.FromMilliseconds(200),
    // MaxDelay Retry-After'ı sınırlamıyor; uzun bir Retry-After (bakım) hiç tekrar denenmiyor
    ShouldHandle = args => ValueTask.FromResult(
        HttpClientResiliencePredicates.IsTransient(args.Outcome) &&
        !(args.Outcome.Result?.Headers.RetryAfter?.Delta > TimeSpan.FromSeconds(3)))
})

HttpClientResiliencePredicates.IsTransient standard pipeline'ın kullandığı hazır kontrol; biz yalnız bir koşul ekledik. Aynı bakım senaryosunda checkout artık 0,18 saniyede, sabit tarifeyle tamamlanıyor (fallback'e aşağıda geleceğiz).

Timeout'un İyimser Olması

Polly'nin timeout'u "optimistic": süre dolunca callback'e verdiği token'ı iptal ediyor ve callback'in bu iptale uymasını bekliyor. Token'ı dinlemeyen bir kodu durduramıyor:

await pipeline.ExecuteAsync(async token =>
{
    if (honorsToken)
    {
        await Task.Delay(TimeSpan.FromSeconds(3), token);
    }
    else
    {
        Thread.Sleep(TimeSpan.FromSeconds(3)); // CancellationToken almayan eski bir SDK çağrısı
    }
});
[ 1.02s] honors token=True: TimeoutRejectedException in 1.0 s
[ 4.03s] honors token=False: completed in 3.0 s

İkinci satır tahmin sorusunun cevabı: exception bile yok. Callback 3 saniye sonra normal şekilde bittiği için pipeline sonucu başarılı sayıyor. Bu yüzden pipeline'a verdiğin her çağrıya token'ı iletmen gerekiyor; HttpClient, MailKit ve Entity Framework Core bunu destekliyor. Token almayan bir SDK'yı sınırlamanın tek yolu onu ayrı bir process'te ya da en azından ayrı bir Task içinde çalıştırıp beklemeyi bırakmak, ki bu da arkada çalışmaya devam eden işler bırakıyor. C# Asenkron serisinin CancellationToken yazısında cooperative cancellation'ın mantığını anlattık.

Circuit Breaker: Ayarlar, Elle Kontrol ve Health Check

Circuit breaker, belli bir süre içinde hata oranı eşiği aşınca çağrıları bir süre hiç göndermiyor. Polly'de dört durum var:

04-circuit-breaker-tr.png

Geçişleri görmek için küçük değerlerle bir breaker kurdum: 10 saniyede en az 4 çağrı ve %50 hata. Kırık kalma süresini sabit vermek yerine BreakDurationGenerator ile hata sayısına bağladım:

var manual = new CircuitBreakerManualControl();
var state = new CircuitBreakerStateProvider();
var pipeline = new ResiliencePipelineBuilder()
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,
        MinimumThroughput = 4,
        SamplingDuration = TimeSpan.FromSeconds(10),
        // Daha çok hata, daha uzun kırık kalma: 4 hata -> 2 sn, en fazla 30 sn
        BreakDurationGenerator = args => ValueTask.FromResult(TimeSpan.FromSeconds(Math.Min(30, args.FailureCount * 0.5))),
        ManualControl = manual,
        StateProvider = state,
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>(),
        OnOpened = args => { Log($"OnOpened   break={args.BreakDuration.TotalSeconds}s manual={args.IsManual}"); return default; },
        OnHalfOpened = _ => { Log("OnHalfOpened"); return default; },
        OnClosed = args => { Log($"OnClosed   manual={args.IsManual}"); return default; }
    })
    .Build();
[ 0.00s] call 1: HttpRequestException (state Closed)
[ 0.00s] call 2: HttpRequestException (state Closed)
[ 0.00s] call 3: HttpRequestException (state Closed)
[ 0.00s] OnOpened   break=2s manual=False
[ 0.00s] call 4: HttpRequestException (state Open)
[ 0.00s] call 5: BrokenCircuitException (state Open)
[ 2.21s] OnHalfOpened
[ 2.21s] OnClosed   manual=False
[ 2.21s] call 6: ok (state Closed)
[ 2.21s] call 7: ok (state Closed)
[ 2.21s] maintenance window starts: IsolateAsync()
[ 2.21s] OnOpened   break=0s manual=True
[ 2.21s] call 8: IsolatedCircuitException (state Isolated)
[ 2.21s] OnClosed   manual=True
[ 2.21s] call 9: ok (state Closed)

İlk üç hata MinimumThroughput'a ulaşmadığı için breaker'ı açmadı; dördüncü hata eşiği geçti. Beşinci çağrı sağlayıcıya hiç gitmedi, BrokenCircuitException aldı. 2 saniye sonraki ilk çağrı HalfOpen'da deneme olarak geçti, başarılı olunca breaker kapandı. IsolateAsync() ise breaker'ı süre sınırı olmadan açık tutuyor ve bu sırada IsolatedCircuitException geliyor; CloseAsync() ile kapanıyor.

Bookstore'un kargo client'ında standard handler yerine kendi pipeline'ımızı kurmamızın nedeni bu iki nesne. CarrierCircuit singleton'ı ManualControl ve StateProvider'ı tutuyor, AddResilienceHandler bunları breaker'a veriyor:

carrier.AddResilienceHandler("carrier", (pipeline, context) =>
{
    var circuit = context.ServiceProvider.GetRequiredService<CarrierCircuit>();
    pipeline
        .AddTimeout(TimeSpan.FromSeconds(6))
        .AddRetry(new HttpRetryStrategyOptions { /* yukarıdaki retry */ })
        .AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            MinimumThroughput = 10,
            SamplingDuration = TimeSpan.FromSeconds(30),
            BreakDuration = TimeSpan.FromSeconds(15),
            ManualControl = circuit.ManualControl,
            StateProvider = circuit.StateProvider
        })
        .AddTimeout(TimeSpan.FromSeconds(2));
});

MinimumThroughput'u 100'den 10'a indirdik: kargo fiyatı dakikada birkaç düzine kez soruluyor ve 100 istek 30 saniyelik pencerede hiç birikmiyor. State provider bir health check'e bağlı, manual control iki admin endpoint'ine:

public sealed class CarrierCircuitHealthCheck(CarrierCircuit circuit) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        var state = circuit.StateProvider.CircuitState;
        return Task.FromResult(state == CircuitState.Closed
            ? HealthCheckResult.Healthy("Carrier circuit is closed.")
            : HealthCheckResult.Degraded($"Carrier circuit is {state}; shipping uses the flat tariff."));
    }
}

app.MapPost("/admin/carrier-circuit/isolate", async (CarrierCircuit circuit) =>
{
    await circuit.ManualControl.IsolateAsync();
    return Results.NoContent();
});

Kargo fake'i sürekli 500 dönerken arka arkaya checkout yaptım ve her seferinde /health'e baktım:

checkout 1: 200 [0.872034s] Healthy
checkout 2: 200 [0.587929s] Healthy
checkout 3: 200 [0.549593s] Degraded
checkout 4: 200 [0.016205s] Degraded
checkout 5: 200 [0.014696s] Degraded

Her checkout'ta üç deneme (bir istek, iki retry) breaker'ın penceresine yazılıyor; benim denememde üçüncü checkout sırasında eşik aşıldı ve log'a OnCircuitOpened düştü. Sonraki checkout'lar kargo firmasına hiç gitmeden 15 ms'de tamamlandı. Health check'in Unhealthy değil Degraded dönmesi bilinçli: kargo fiyatı alınamıyor ama satış devam ediyor, load balancer'ın bu instance'ı trafikten çıkarmasını istemiyoruz. Bakım penceresi önceden biliniyorsa POST /admin/carrier-circuit/isolate ile breaker'ı açıp kargo firmasının ilk 500'lerini hiç yaşamadan sabit tarifeye geçiyoruz.

Bulkhead: Concurrency Limiter

Yavaşlayan bir sağlayıcının asıl zararı, beklerken tuttuğu kaynaklar. Concurrency limiter aynı anda en fazla N çağrının içeri girmesine izin veriyor ve gerisini hemen reddediyor; System Design serisindeki bulkhead fikrinin karşılığı. Polly.RateLimiting paketinden:

var pipeline = new ResiliencePipelineBuilder()
    .AddConcurrencyLimiter(permitLimit: 2, queueLimit: 0)
    .Build();
call 1: ok
call 2: ok
call 3: RateLimiterRejectedException
call 4: RateLimiterRejectedException
call 5: RateLimiterRejectedException

Aynı anda başlayan beş çağrıdan ikisi geçti, üçü beklemeden reddedildi. Standard pipeline'ın en dışındaki rate limiter da bu: varsayılan 1000 eşzamanlı istek, queue 0. Ödeme sağlayıcısına aynı anda en fazla 50 istek göndermek istiyorsan bu sayıyı değiştirmen yeterli. Reddedilen çağrıyı adapter'da PaymentFailureKind.Transient'a çevirmek, kullanıcının "biraz sonra tekrar dene" mesajı görmesi demek.

Fallback: Kargo Fiyatı Yoksa Sabit Tarife

Kargo fiyatı alınamadığında iki seçenek var: checkout'u 503 ile durdurmak ya da sabit bir tarife ile devam edip farkı şirket olarak üstlenmek. Bookstore ikincisini seçti. Fallback, HTTP pipeline'ının dışında, IShippingCarrier'ı saran bir decorator'da duruyor; çünkü konusu HTTP yanıtı değil, ShippingQuote:

// Decorator: kargo firması hata verdiğinde checkout hata yerine sabit tarifeyle devam eder
public sealed class ResilientShippingCarrier(
    [FromKeyedServices(ResilientShippingCarrier.InnerKey)] IShippingCarrier inner,
    ResiliencePipelineProvider<string> pipelines) : IShippingCarrier
{
    public const string InnerKey = "carrier-direct";
    public const string PipelineName = "shipping-quote";

    public static readonly ShippingQuote FlatTariff = new("FLAT", 79.90m, "TRY", 3) { IsFlatRate = true };

    public async Task<ShippingQuote> GetQuoteAsync(ShippingQuoteRequest request, CancellationToken cancellationToken) =>
        await pipelines.GetPipeline<ShippingQuote>(PipelineName)
            .ExecuteAsync(async token => await inner.GetQuoteAsync(request, token), cancellationToken);

    public static void Configure(ResiliencePipelineBuilder<ShippingQuote> builder, ILogger logger) =>
        builder.AddFallback(new FallbackStrategyOptions<ShippingQuote>
        {
            // Yalnız kesintiler fallback'e düşer; contract ihlali hata olarak kalmalı ve alarm üretmeli
            ShouldHandle = new PredicateBuilder<ShippingQuote>()
                .Handle<ShippingCarrierException>(ex => ex.Kind is ShippingFailureKind.Transient or ShippingFailureKind.Unavailable),
            FallbackAction = _ => Outcome.FromResultAsValueTask(FlatTariff),
            OnFallback = args =>
            {
                logger.LogWarning(args.Outcome.Exception, "Carrier quote failed, using the flat tariff");
                return default;
            }
        });
}

ShouldHandle burada önemli bir karar veriyor: ilk yazıdaki şema değişikliği (ContractViolation) fallback'e düşmüyor. Sağlayıcı yanlış veri gönderiyorsa bunu sabit tarifeyle örtmek, sorunu günlerce kimsenin fark etmemesi demek. IsFlatRate bayrağı checkout yanıtına kadar taşınıyor, yani arayüz "kargo ücreti tahminidir" uyarısı gösterebiliyor:

{"orderId":1001,"status":"Paid","total":538.90,"providerPaymentId":"ch_3MtwBwLkdIwHu7ix","declineReason":null,"shippingIsFlatRate":true}

Üretimde Dikkat

Her pipeline için toplam süreyi ve en kötü durumda kaç deneme yapılacağını yaz. Kargo client'ında bu 6 saniye ve 3 deneme; checkout'un en kötü süresi bunların ve ödeme timeout'unun toplamı.

Circuit breaker'ın eşiğini trafiğe göre ayarla ve breaker'ın açıldığı anı alarma bağla. Açık bir breaker kullanıcıya hata göstermiyorsa (fallback varsa) sorunu fark etmenin tek yolu bu alarm. Metric'leri bir sonraki yazıda bağlayacağız.

Manual control'ü bir operasyon aracı olarak düşün. Admin endpoint'lerini yetkilendirmeden açık bırakma; bu yazıdaki örnekte authorization yok, gerçek uygulamada yalnız Admin rolü çağırabilmeli.

Fallback'in maliyetini iş tarafıyla konuş. Sabit tarife 79,90 TL ve gerçek fiyat bunun üstündeyse fark şirketten çıkıyor; bu kararı bir developer tek başına vermemeli.

Sık Düşülen Tuzaklar

ShouldHandle'ı daraltıp TimeoutRejectedException'ı unutmak. Yukarıdaki üçüncü satırda olduğu gibi timeout'lar hiç tekrar denenmez.

MaxDelay'in Retry-After'ı da sınırladığını sanmak. Sınırlamıyor; uzun bir Retry-After'ı ya retry etmeyin ya da toplam timeout'a güvenin.

Token'ı callback'e iletmemek. Polly'nin timeout'u iptal edemediği kodu durduramaz; "timeout var" sanırken 30 saniye bekleyen çağrılar olur.

Her katmanda retry. Adapter'da, HTTP pipeline'ında ve checkout'u çağıran istemcide ayrı ayrı retry varsa deneme sayıları çarpılır. System Design serisinin Reliability Pattern'leri yazısında bunun yük üzerindeki etkisini anlattık.

Fallback ile contract hatalarını örtmek. Fallback kesintiler içindir; yanlış veri bir alarm üretmeli.

Küçük Bir Deneme

★☆☆ circuit-breaker demosunda BreakDurationGenerator'ı kaldırıp BreakDuration = TimeSpan.FromSeconds(3) ver. 2,2 saniyelik bekleme sonrası 6. çağrı ne alıyor?

★★☆ SmtpEmailSender pipeline'ına bir circuit breaker ekle (MinimumThroughput = 5, BreakDuration = 30 sn). Fake SMTP --smtp Error modundayken arka arkaya altı checkout yap; altıncı checkout'un süresi ilkine göre nasıl değişiyor?

★★★ Ödeme sağlayıcısı için AddConcurrencyLimiter(permitLimit: 5, queueLimit: 10) içeren bir pipeline kur ve 50 eşzamanlı checkout gönderen bir test yaz. Kaç istek reddediliyor, kuyruktakiler ne kadar bekliyor? Reddedilen isteğin adapter'da hangi PaymentFailureKind'a çevrilmesi gerekir?

Bu yazıdaki her davranışı elle, fake'leri farklı modlarda başlatarak gördük. Bir sonraki yazıda aynı senaryoları otomatik testlere çeviriyoruz: WireMock.Net ile integration test, beklemeden backoff test etmek için FakeTimeProvider, Polly'nin chaos strategy'leri ve retry ile breaker'ı metric'lerle izlemek.