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

IHttpClientFactory Derinlemesine: Named, Typed ve Keyed Client'lar, Handler Ömrü ve DelegatingHandler'lar

Mert Özen 14 Kas 2026 12 dk 10 görüntülenme
IHttpClientFactory Derinlemesine: Named, Typed ve Keyed Client'lar, Handler Ömrü ve DelegatingHandler'lar

IHttpClientFactory her CreateClient çağrısında yeni bir HttpClient döner ama handler zincirini varsayılan 2 dakika boyunca paylaştırır; .NET 9'dan beri keyed client'lar typed client'ların yerine öneriliyor. Handler ömrünü, singleton'a giren client tuzağını, handler'ın ayrı scope'unu, OAuth token cache'leyen DelegatingHandler'ı, log redaction'ı ve HttpClient.Timeout ile CancellationToken farkını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve WireMock.Net 2.31 ile derlenip denenmiştir.

Önce Tahmin Et

Üç kısa soru. Birincisi: bir typed client'ı (AddHttpClient<QuoteService>()) bir singleton service'in constructor'ına verdin. Uygulama açılırken DI (Dependency Injection; bağımlılıkların constructor'dan dışarıdan verilmesi) container'ı hata verir mi?

İkincisi: HttpClient.Timeout'u 2 saniye yaptın ve bir de 1 saniyelik CancellationToken verdin. İkisi de TaskCanceledException fırlatıyor. Hangisinin tetiklendiğini nasıl ayırırsın?

Üçüncüsü: BaseAddress = new Uri("http://localhost:9092/api/v2") ve GetAsync("quotes"). İstek hangi URL'ye gider?

Cevaplar sırasıyla: hata vermez ve sessizce DNS değişikliklerini kaçırmaya başlarsın; inner exception'a bakarak ayırırsın; istek /api/quotes'a gider. Bu yazıda üçünü de gerçek çıktıyla göreceğiz. Bir önceki yazıda Bookstore'un ödeme ve kargo adapter'larını AddHttpClient ile kaydettik; şimdi arkasındaki IHttpClientFactory'ye bakıyoruz ve kargo API'si için OAuth token'ını cache'leyen bir handler yazıyoruz.

Yazıdaki deneylerin her biri repo'daki Bookstore.ResilienceLab console uygulamasında bir komut ve kendi fake sunucusunu kendisi başlatıyor; Bookstore API'sinin log'ları için fake'leri ve API'yi ilk yazıdaki gibi iki terminalde çalıştırman yeterli. Komutlar:

dotnet run --project samples/Bookstore.ResilienceLab -- lifetime
dotnet run --project samples/Bookstore.ResilienceLab -- captive
dotnet run --project samples/Bookstore.ResilienceLab -- scope
dotnet run --project samples/Bookstore.ResilienceLab -- logging
dotnet run --project samples/Bookstore.ResilienceLab -- timeout
dotnet run --project samples/Bookstore.ResilienceLab -- singleton-handler
dotnet run --project samples/Bookstore.ResilienceLab -- base-address
dotnet run --project samples/Bookstore.ResilienceLab -- stream

Factory Neden Var: Handler Havuzu ve Ömrü

Her istekte new HttpClient() yapmanın neden socket'leri tükettiğini Networking ve HTTP Nasıl Çalışır serisinin TCP yazısında gördük. Tersi de sorunlu: uygulama boyunca tek bir static HttpClient kullanırsan bağlantılar hiç kapanmaz ve karşı tarafın DNS kaydı değiştiğinde eski IP'ye gitmeye devam edersin. IHttpClientFactory ikisinin arasında duruyor. CreateClient her çağrıda yeni bir HttpClient döner, ama altındaki handler zinciri (asıl bağlantı havuzu orada) belli bir süre boyunca tekrar kullanılır. Bu süre HandlerLifetime, varsayılanı 2 dakika.

Bunu görmek için handler ömrünü 2 saniyeye indirip farklı anlarda client istedim. ConfigurePrimaryHttpMessageHandler callback'i yalnız yeni bir zincir kurulurken çalışıyor:

services.AddHttpClient("carrier", c => c.BaseAddress = new Uri(carrier.Url!))
    .SetHandlerLifetime(TimeSpan.FromSeconds(2))
    .ConfigurePrimaryHttpMessageHandler((handler, _) =>
    {
        var sockets = (SocketsHttpHandler)handler;
        Console.WriteLine($"  new chain #{++chains}: {handler.GetType().Name}, PooledConnectionLifetime={sockets.PooledConnectionLifetime}");
    });
  new chain #1: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=0.0s CreateClient -> 45638969
t=1.0s CreateClient -> 35320229
  new chain #2: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=2.5s CreateClient -> 42194754
t=3.0s CreateClient -> 12036987
  new chain #3: SocketsHttpHandler, PooledConnectionLifetime=00:00:02
t=5.0s CreateClient -> 36963566

Her CreateClient farklı bir HttpClient instance'ı döndü, ama ilk iki saniyede aynı zincir kullanıldı. Süre dolunca factory yeni bir zincir kurdu; eskisi, onu kullanan son client da bırakınca dispose ediliyor. Çıktıda iki ayrıntı daha var. Birincisi, primary handler bir SocketsHttpHandler. .NET 9'dan önce factory varsayılan olarak HttpClientHandler kullanıyordu; bu değişiklik .NET 9'un breaking change listesinde yer alıyor ve primary handler'ı HttpClientHandler'a cast eden eski kod artık InvalidCastException alıyor. İkincisi, factory PooledConnectionLifetime'ı HandlerLifetime ile aynı değere ayarlamış. Yani zincir yenilenmese bile tek bir bağlantı bu süreden uzun yaşamıyor.

Named, Typed ve Keyed Client

Factory'den client almanın üç yolu var. Aynı kargo client'ı için üçü yan yana:

YöntemKayıtKullanımDikkat
NamedAddHttpClient("legacy-carrier", ...)factory.CreateClient("legacy-carrier")Her yerde factory ve string isim taşınır
TypedAddHttpClient<LegacyCarrierClient>(...)Constructor'da HttpClientTransient kaydolur; singleton'a girerse captive olur
Keyed (.NET 9+)AddHttpClient("legacy-carrier", ...).AddAsKeyed()[FromKeyedServices("legacy-carrier")] HttpClientVarsayılan lifetime Scoped

Keyed yaklaşım .NET 9 ile geldi ve Microsoft Learn'deki keyed DI sayfası şu an typed client yerine bunu öneriyor. Bookstore'da iki client'ı da keyed yaptık. Typed client'tan farkı, HttpClient'ın hangi isimle geldiğinin constructor'da açıkça yazması ve service'in kendisinin sıradan bir DI kaydı olması:

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"]);
        client.Timeout = TimeSpan.FromSeconds(10);
    })
    .AddAsKeyed();
builder.Services.AddScoped<IPaymentGateway, ProviderPaymentGateway>();

public sealed class ProviderPaymentGateway(
    [FromKeyedServices(ProviderPaymentGateway.ClientName)] HttpClient httpClient,
    ILogger<ProviderPaymentGateway> logger) : IPaymentGateway
{
    public const string ClientName = "payment-provider";
    // ...
}

AddAsKeyed()'ı her client için yazmak istemiyorsan ConfigureHttpClientDefaults(b => b.AddAsKeyed()) ile hepsini açabilirsin. Bunun bir bedeli var: bu kayıt KeyedService.AnyKey ile yapıldığı için yanlış yazılmış bir isim de hata vermiyor, ayarsız bir HttpClient geliyor. İsimleri bizim gibi const olarak tutmak bu riski azaltır.

Captive Dependency: Singleton'a Giren Client

İlk tahmin sorusuna dönelim. Aynı container'da bir typed client'ı ve bir keyed client'ı birer singleton'a verdim, scope doğrulaması da açık:

services.AddHttpClient<QuoteService>();
services.AddSingleton<PriceCache>();           // PriceCache(QuoteService quotes)
services.AddHttpClient("carrier").AddAsKeyed();
services.AddSingleton<KeyedPriceCache>();      // KeyedPriceCache([FromKeyedServices("carrier")] HttpClient client)
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
Typed client captured by singleton: 26753075 (no error)
Keyed client in singleton: Cannot consume scoped service 'System.Net.Http.HttpClient' from singleton 'Bookstore.ResilienceLab.Demos.FactoryDemos+KeyedPriceCache'.

Typed client transient olarak kaydolduğu için singleton'a girmesi DI açısından yasal; container ses çıkarmıyor. Ama o HttpClient artık uygulama kapanana kadar aynı handler zincirini kullanıyor; factory'nin handler yenileme mekanizması devre dışı kalıyor. Keyed client ise varsayılan olarak Scoped kaydolduğu için aynı hata, Development ortamında ilk resolve anında yakalanıyor. Captive dependency'nin genel mekaniğini ASP.NET Core Web API serisinin Dependency Injection yazısında anlattık; HttpClient özelinde sonucu "eski IP'ye gitmeye devam etmek".

Bir singleton'da gerçekten uzun ömürlü bir client gerekiyorsa iki doğru yol var: ya IHttpClientFactory'yi enjekte edip her işte CreateClient çağırmak ya da client'ı UseSocketsHttpHandler ile kurup PooledConnectionLifetime'ı açıkça vermek. Bookstore'daki CarrierTokenProvider singleton ve birinci yolu kullanıyor.

Handler'ın Kendi Scope'u

Factory her handler zinciri için ayrı bir DI scope'u açıyor. Bu scope, ASP.NET Core'un request scope'undan bağımsız ve zincir yaşadığı sürece yaşıyor. Bunu bir scoped RequestContext ve onu okuyan bir handler ile denedim. Her döngüde yeni bir uygulama scope'u açıp Name'i ayarlıyorum, sonra o scope'tan client alıp istek atıyorum:

foreach (var customer in new[] { "Ada", "Alan" })
{
    await using var scope = provider.CreateAsyncScope();
    scope.ServiceProvider.GetRequiredService<RequestContext>().Name = customer;
    var client = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("carrier");
    Console.WriteLine($"Request scope for {customer}:");
    using var _ = await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850");
}
Request scope for Ada:
  handler sees RequestContext.Name = unset
Request scope for Alan:
  handler sees RequestContext.Name = unset

Handler, request scope'undaki RequestContext'i değil, kendi scope'undaki boş instance'ı görüyor. Daha kötüsü de olabilir: handler'ın scope'unda bir değer bir kez set edilirse, o zincir yaşadığı sürece başka kullanıcıların isteklerinde de aynı değer kullanılır. Microsoft'un dokümanı bu sorunun .NET 9 itibarıyla çözülmediğini açıkça yazıyor. Kural basit: handler'ın içinde HttpContext'ten ya da scoped bir service'ten kullanıcıya özel bilgi okuma. Gerekli bilgiyi request'in kendisine koy ya da Activity.Current gibi akışla taşınan bir değerden al.

ConfigureHttpClientDefaults ve DelegatingHandler'lar

DelegatingHandler, giden istekler için middleware gibi çalışıyor. Bookstore'da iki handler var. Birincisi her dış servise trace id'mizi gönderiyor; sağlayıcının destek ekibine "şu istek" diyebilmek için. Activity.Current scope'a bağlı olmadığı için yukarıdaki tuzağa düşmüyor:

// Her dış servise mevcut trace id'yi gönderir; sağlayıcının destek ekibi isteğimizi bulabilsin
public sealed class CorrelationIdHandler : DelegatingHandler
{
    public const string HeaderName = "X-Correlation-ID";

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (Activity.Current is { } activity && !request.Headers.Contains(HeaderName))
        {
            request.Headers.Add(HeaderName, activity.TraceId.ToString());
        }

        return base.SendAsync(request, cancellationToken);
    }
}

builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.ConfigureHttpClientDefaults(http => http.AddHttpMessageHandler<CorrelationIdHandler>());

ConfigureHttpClientDefaults .NET 8 ile geldi ve factory'nin oluşturduğu bütün client'lara uygulanıyor; tek tek her kayda handler eklemeyi unutma riskini kaldırıyor. Correlation ID'nin log tarafını .NET'te Logging serisinin Correlation ID yazısında anlattık.

İkinci handler kargo API'sinin OAuth 2.0 client credentials akışı için. Kargo firması her istekte bir Bearer token istiyor ve token'ı /oauth/token'dan alıyorsun. Her istekte yeni token almak hem yavaş hem de çoğu sağlayıcıda token endpoint'inin rate limit'ine takılmanın yolu. Token'ı process boyunca cache'leyen singleton bir provider yazdık:

// Singleton: process'te tek bir cache'lenmiş token, süresi dolmadan biraz önce yenilenir
public sealed class CarrierTokenProvider(
    IHttpClientFactory httpClientFactory,
    IOptions<LegacyCarrierOptions> options,
    TimeProvider timeProvider,
    ILogger<CarrierTokenProvider> logger)
{
    public const string AuthClientName = "legacy-carrier-auth";
    private static readonly TimeSpan RefreshBeforeExpiry = TimeSpan.FromSeconds(30);
    private readonly SemaphoreSlim _lock = new(1, 1);
    private (string Value, DateTimeOffset ExpiresAt)? _token;

    public async ValueTask<string> GetTokenAsync(CancellationToken cancellationToken)
    {
        if (_token is { } cached && timeProvider.GetUtcNow() < cached.ExpiresAt - RefreshBeforeExpiry)
        {
            return cached.Value;
        }

        await _lock.WaitAsync(cancellationToken);
        try
        {
            // Biz beklerken başka bir istek token'ı yenilemiş olabilir
            if (_token is { } fresh && timeProvider.GetUtcNow() < fresh.ExpiresAt - RefreshBeforeExpiry)
            {
                return fresh.Value;
            }

            var client = httpClientFactory.CreateClient(AuthClientName);
            using var response = await client.PostAsync("oauth/token", new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "client_credentials",
                ["client_id"] = options.Value.ClientId,
                ["client_secret"] = options.Value.ClientSecret
            }), cancellationToken);
            response.EnsureSuccessStatusCode();

            var body = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken)
                ?? throw new InvalidOperationException("Empty token response.");
            _token = (body.AccessToken, timeProvider.GetUtcNow().AddSeconds(body.ExpiresIn));
            logger.LogInformation("Fetched a new carrier token, expires in {ExpiresIn} s", body.ExpiresIn);
            return body.AccessToken;
        }
        finally
        {
            _lock.Release();
        }
    }
}

// Transient handler; bağımlı olduğu token cache singleton, bu yüzden hiçbir scope yakalanmıyor
public sealed class CarrierAuthHandler(CarrierTokenProvider tokens) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var token = await tokens.GetTokenAsync(cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await base.SendAsync(request, cancellationToken);
    }
}

Üç ayrıntı önemli. Token endpoint'i ayrı bir named client (legacy-carrier-auth) ile çağrılıyor; aynı client'ı kullansaydık auth handler kendi kendini çağırırdı. SemaphoreSlim içindeki ikinci kontrol, aynı anda gelen 50 isteğin 50 ayrı token istemesini önlüyor. Zaman için DateTimeOffset.UtcNow yerine TimeProvider kullanıyoruz; süre dolumunu test ederken saati ileri sarabilmek için (C# Asenkron serisinin TimeProvider yazısı). Kayıt şöyle:

builder.Services.AddSingleton<CarrierTokenProvider>();
builder.Services.AddTransient<CarrierAuthHandler>();
builder.Services.AddHttpClient(CarrierTokenProvider.AuthClientName, (sp, client) =>
    client.BaseAddress = sp.GetRequiredService<IOptions<LegacyCarrierOptions>>().Value.BaseUrl);
builder.Services.AddHttpClient(LegacyCarrierClient.ClientName, (sp, client) =>
    {
        client.BaseAddress = sp.GetRequiredService<IOptions<LegacyCarrierOptions>>().Value.BaseUrl;
        client.Timeout = TimeSpan.FromSeconds(5);
    })
    .AddHttpMessageHandler<CarrierAuthHandler>()
    .AddAsKeyed();

Üç checkout isteği attığımda log'da token yalnız bir kez alınıyor:

info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[100]
      Start processing HTTP request GET http://localhost:9092/api/v2/quotes?*
info: System.Net.Http.HttpClient.legacy-carrier-auth.LogicalHandler[100]
      Start processing HTTP request POST http://localhost:9092/oauth/token
info: System.Net.Http.HttpClient.legacy-carrier-auth.LogicalHandler[101]
      End processing HTTP request after 188.4081ms - 200
info: Bookstore.Shipping.LegacyCarrier.CarrierTokenProvider[0]
      Fetched a new carrier token, expires in 120 s
info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[101]
      End processing HTTP request after 198.1277ms - 200
...
info: System.Net.Http.HttpClient.legacy-carrier.LogicalHandler[101]
      End processing HTTP request after 0.9553ms - 200

İlk kargo isteği token alımıyla birlikte 198 ms sürdü; ikincisi 1 ms'nin altında. Handler'ı transient kaydetmenin de bir nedeni var. Aynı handler'ı singleton kaydedip iki client'a ekledim:

payments: chain built
carrier : The 'InnerHandler' property must be null. 'DelegatingHandler' instances provided to 'HttpMessageHandlerBuilder' must not be reused or cached.

Her zincir kendi handler instance'ını ister; zincirin InnerHandler'ı instance'ın üzerinde tutuluyor. Singleton handler ikinci zincirde, ya da handler ömrü dolup zincir yeniden kurulduğunda patlıyor. İkinci durum production'da ilk 2 dakikadan sonra ortaya çıktığı için testlerde kolayca kaçıyor.

Log'larda Ne Görünür: Header ve Query Redaction

Yukarıdaki log'da URL'nin query kısmı ?* olarak görünüyor. .NET 9'dan beri factory'nin log'ları query string'i maskeliyor, user info ve fragment kısımlarını da siliyor. Header değerleri de aynı şekilde: RedactLoggedHeaders hiç çağrılmadıysa bütün değerler * yazılıyor. Header'lar Trace seviyesinde loglanıyor; seviyeyi açıp önce varsayılanla, sonra yalnız Authorization'ı maskeleyecek şekilde çalıştırdım:

--- default
trce: System.Net.Http.HttpClient.carrier.ClientHandler[102]
      Request Headers:
      Authorization: *
      X-Correlation-ID: *

--- RedactLoggedHeaders(Authorization)
trce: System.Net.Http.HttpClient.carrier.ClientHandler[102]
      Request Headers:
      Authorization: *
      X-Correlation-ID: 4bf92f3577b34da6a3ce929d0e0e4736

RedactLoggedHeaders(["Authorization"]) "yalnız bunları maskele, gerisini göster" anlamına geliyor. Yani bu satırı eklemek, varsayılandan daha az güvenli bir duruma geçmek demek. Sağlayıcı API key'i X-Api-Key gibi başka bir header'da istiyorsa ve listeye eklemeyi unutursan, key log'a düz metin olarak yazılır. Query string'i loglamak için System.Net.Http.DisableUriRedaction AppContext switch'i var; kart token'ını ya da e-posta adresini query'de taşıyan sağlayıcılar yüzünden onu açmamanı öneririm.

HttpClient.Timeout, CancellationToken ve ResponseHeadersRead

HttpClient.Timeout'un varsayılanı 100 saniye. Kullanıcının beklediği bir checkout için çok uzun; Bookstore'da ödeme client'ı 10, kargo client'ı 5 saniye kullanıyor. Timeout dolduğunda ve çağıranın token'ı iptal edildiğinde ne olduğunu 5 saniye geciken fake'e karşı denedim:

var client = new HttpClient { BaseAddress = new Uri(carrier.Url!), Timeout = TimeSpan.FromSeconds(2) };
// 1) yalnız HttpClient.Timeout
await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850");
// 2) 1 saniyelik caller token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await client.GetAsync("api/v2/quotes?from=34000&to=06100&weight=850", cts.Token);
Default HttpClient.Timeout: 00:01:40
Timeout     : TaskCanceledException, inner=TimeoutException
              The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing.
Caller token: TaskCanceledException, inner=TaskCanceledException, token cancelled=True
              The operation was canceled.

İkisi de TaskCanceledException. Ayırt etmenin güvenilir yolu, bir önceki yazıdaki adapter'da yaptığımız gibi kendi token'ına bakmak: cancellationToken.IsCancellationRequested false ise iptal senden gelmedi, timeout oldu. Inner exception'ın TimeoutException olması da ikinci bir işaret. Bir sonraki yazıda resilience pipeline'ı kendi timeout'larını getirecek; HttpClient.Timeout ile o timeout'ların ilişkisine orada gerçek bir sürprizle bakacağız.

Büyük yanıtlarda bir ayar daha önemli. GetAsync varsayılan olarak bütün gövdeyi belleğe okuduktan sonra döner. HttpCompletionOption.ResponseHeadersRead ise header'lar gelince döner ve gövdeyi stream olarak okumana izin verir. GetFromJsonAsAsyncEnumerable içeride bu seçeneği kullanıyor ve JSON array'in elemanlarını geldikçe veriyor. Kargo takip event'leri için:

await foreach (var e in client.GetFromJsonAsAsyncEnumerable<TrackingEvent>("api/v2/shipments/TRK-48213/events"))
{
    Console.WriteLine($"{e!.AT:u} {e.CODE} {e.PLACE}");
}
2026-11-12 09:14:00Z 01 Istanbul hub
2026-11-12 21:40:00Z 03 Ankara hub
2026-11-13 08:05:00Z 05 Cankaya branch

ResponseHeadersRead kullandığında HttpClient.Timeout yalnız header'lar gelene kadar işliyor; gövdeyi okumak için ayrı bir sınır koymak, yani okumaya bir CancellationToken vermek senin işin.

Üretimde Dikkat

Client başına timeout'u açıkça ayarla. 100 saniyelik varsayılan, yavaşlayan bir sağlayıcıda Kestrel'in bütün isteklerini 100 saniye bekletmek demek.

Handler ömrünü değiştirmeden önce neden değiştirdiğini yaz. DNS kaydı sık değişen bir sağlayıcı (blue-green deploy yapan bir API gibi) için 2 dakika uzun olabilir; çok kısa bir değer ise her yenilemede yeni TCP ve TLS (Transport Layer Security; HTTPS bağlantısını şifreleyen protokol) handshake'i demek.

Token cache'i, token endpoint'inin hata verdiği durumu da düşünmeli. Bizim provider'da token alınamazsa exception yukarı çıkıyor ve checkout 500 dönüyor. Bir sonraki yazılarda bu çağrıya da kısa bir timeout ve retry ekleyeceğiz.

Header ve query redaction'ı kapatma kararını bir code review'da ver, bir debug oturumunda değil. Kapatılan maskeleme genelde geri açılmıyor.

Sık Düşülen Tuzaklar

using var client = new HttpClient(). Her istekte yeni handler ve yeni bağlantı demek; yük altında socket'ler TIME_WAIT'te birikir. Factory'den al ya da uygulama boyunca tek bir client'ı PooledConnectionLifetime ile kullan.

DelegatingHandler'ı singleton kaydetmek. Yukarıdaki hata mesajını üretir, ama genelde ilk handler yenilemesinde, yani production'da.

BaseAddress'in sonundaki /'ı unutmak. Uri, adresleri URL standardının (RFC 3986) kurallarıyla birleştiriyor:

http://localhost:9092/api/v2   + quotes   -> http://localhost:9092/api/quotes
http://localhost:9092/api/v2/  + quotes   -> http://localhost:9092/api/v2/quotes
http://localhost:9092/api/v2/  + /quotes  -> http://localhost:9092/quotes

Kural: BaseAddress / ile biter, relative path / ile başlamaz.

Handler'da kullanıcıya özel scoped bilgi okumak. Handler'ın scope'u request'in scope'u değil; yukarıdaki unset çıktısı bunun en zararsız hâli.

Küçük Bir Deneme

★☆☆ lifetime demosunda SetHandlerLifetime(Timeout.InfiniteTimeSpan) ve UseSocketsHttpHandler((h, _) => h.PooledConnectionLifetime = TimeSpan.FromMinutes(1)) kullan. Kaç zincir kuruluyor, PooledConnectionLifetime ne gösteriyor?

★★☆ CarrierTokenProvider için bir unit test yaz: FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) ile saati 89 ve 91 saniye ileri sar, token endpoint'inin kaç kez çağrıldığını doğrula. Unit Test serisinin zaman ve async test yazısı yardımcı olur.

★★★ CarrierAuthHandler'a 401 yanıtında token'ı bir kez yenileyip isteği tekrar gönderen bir mantık ekle. Bu GET için güvenli; aynı handler POST'ta neden riskli olabilir? İstek gövdesini ikinci kez gönderebilmek için ne yapman gerekiyor?

Bu yazıda client'lara timeout verdik ama her şey tek denemeden ibaret. Bir sonraki yazıda Microsoft.Extensions.Http.Resilience ile standard pipeline'ı ekliyoruz: varsayılan değerleri, POST'ta retry'ı kapatmak, appsettings ile ayar ve hedging.