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

Dış Servis Entegrasyonunun Haritası: Failure Mode'lar, Anti-Corruption Layer ve Client Seçimi (Elle, Refit, Kiota)

Mert Özen 14 Kas 2026 13 dk 9 görüntülenme
Dış Servis Entegrasyonunun Haritası: Failure Mode'lar, Anti-Corruption Layer ve Client Seçimi (Elle, Refit, Kiota)

Dış servis entegrasyonunda ilk iş, her failure mode için beklenen davranışı yazmak ve sağlayıcının modelini ve hatalarını bir port + adapter ile kendi tiplerimize çevirmektir. Bookstore'un fake ödeme sağlayıcısı ve kargo API'si üzerinden hata modelini, ProblemDetails eşlemesini ve elle yazılmış client, Refit ve Kiota'nın şema değişikliğinde gerçekte nasıl davrandığını gösteriyoruz.

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

Önce Tahmin Et

Checkout sırasında Bookstore iki dış servise gidiyor: önce kargo firmasından fiyat alıyor, sonra ödeme sağlayıcısından kartı çekiyor. Bir cuma akşamı ödeme sağlayıcısı yavaşlıyor ve her istek 5 saniyede cevap veriyor. Hiçbir istek hata dönmüyor. Sence ne olur?

İkinci soru: kargo firması sessizce bir sürüm çıkarıyor ve fiyat alanının adı AMT iken PRICE.AMOUNT oluyor. OpenAPI dosyasından üretilmiş client kullanıyorsun. Exception alır mısın?

Üçüncü soru: ödeme isteğini gönderdin, 30 saniye bekledin, cevap gelmedi. Müşterinin kartından para çekildi mi?

İlk sorunun cevabı "hiçbir şey patlamaz ama her checkout 5 saniye sürer, thread'ler ve bağlantılar dolar, sonra her şey yavaşlar". İkincinin cevabı "çoğu zaman hayır, alan sadece null olur" ve bunu birazdan gerçek çıktıyla göreceğiz. Üçüncünün cevabı "bilmiyorsun" ve bu serinin 06. yazısının konusu. Bu yazı serinin haritası: dış servisin başına neler gelebilir, Bookstore bunlara nasıl bir sınır çizer ve client'ı nasıl yazarız.

Dış Servisin Başına Neler Gelir

System Design serisinin Reliability Pattern'leri yazısında timeout, retry ve circuit breaker'ı kavram olarak anlattık. Burada aynı konuya entegrasyonu yazan kişinin gözünden bakıyoruz: karşı taraf kontrolümüzde değil, SLA (Service Level Agreement; sağlayıcının taahhüt ettiği hizmet seviyesi) ne yazarsa yazsın bazı günler kötü geçecek. İşe hangi hataların gelebileceğini ve her biri için Bookstore'un ne yapması gerektiğini yazarak başlıyoruz. Bu tablo serinin geri kalanının da içindekiler listesi:

DurumNasıl görünürBookstore'un beklenen davranışı
YavaşlamaYanıt geliyor ama 5 sn sürüyorHer çağrıya bir zaman bütçesi; bütçe dolunca vazgeç (02, 03)
Geçici sunucu hatası500, 502, 504GET'te kısa ve sınırlı retry; ödeme POST'unda retry yok (03)
Throttling429 + Retry-AfterHeader'daki süreye uy, isteği yavaşlat (04)
Bilinmeyen sonuçİstek gitti, yanıt gelmeden timeout"Ödeme doğrulanıyor" de, sonra durumu sorgula (06)
Şema değişikliğiAlan adı veya tipi değiştiSessizce devam etme; contract hatası ver, alarm üret
Bakım penceresi503 + uzun Retry-AfterCircuit'i elle aç, kullanıcıya net mesaj ver (04)
Sertifika süresi dolduHandshake hatası, HttpRequestExceptionRetry işe yaramaz; sertifika bitiş tarihini izle
İş kuralı reddi402 + card_declinedBu bir hata değil, sonuç; retry yapma

Son satır önemli. Kartın reddedilmesi teknik bir arıza değil, bir iş sonucu. Bunu exception ile taşıyıp retry'a sokan kod, reddedilen kartı üç kez daha dener ve bazı sağlayıcılarda bu, kartın fraud nedeniyle bloklanmasına bile yol açar. TLS (Transport Layer Security; HTTPS bağlantısını şifreleyen protokol) sertifikası süresi dolan bir servise de retry yapmanın anlamı yok: saniyeler içinde aynı hatayı alırsın. Hangi hatanın geçici, hangisinin kalıcı olduğunu ayırmak, bütün resilience ayarlarından önce gelir.

Bookstore'un Dış Dünyası

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) bu seride dört dış sistemle konuşuyor: kart ödemelerini alan bir ödeme sağlayıcısı, fiyat ve gönderi API'si olan bir kargo firması, e-fatura kesen bir entegratör ve e-posta gönderen bir SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) sunucusu. Her birinin riski farklı:

01-external-systems-tr.png

Gerçek bir sağlayıcıya bağlanmıyoruz. Seri boyunca bütün dış servisler WireMock.Net ile yazılmış fake'ler. WireMock.Net, bir porta HTTP sunucusu açıp "şu istek gelirse şu yanıtı ver" kurallarıyla çalışan bir kütüphane. Repo'daki Bookstore.FakeProviders projesi iki fake başlatıyor ve hangi davranışı göstereceklerini komut satırından alıyor. Kargo firmasının fake'i şöyle:

// Eski kargo firması: alan adları büyük harf, fiyat string olarak geliyor
public static void Configure(WireMockServer server, FakeMode mode)
{
    server.Reset();
    var quotes = Request.Create().WithPath("/api/v2/quotes").UsingGet();

    var response = mode switch
    {
        FakeMode.Error => Response.Create().WithStatusCode(500).WithBody("Internal Server Error"),
        FakeMode.Throttle => Response.Create().WithStatusCode(429).WithHeader("Retry-After", "2"),
        FakeMode.Maintenance => Response.Create().WithStatusCode(503).WithHeader("Retry-After", "600")
            .WithHeader("Content-Type", "application/json").WithBodyAsJson(new { ERR_CODE = "MAINTENANCE", ERR_MSG = "Service unavailable until 03:00 UTC" }),
        FakeMode.SchemaChanged => Response.Create().WithStatusCode(200)
            .WithHeader("Content-Type", "application/json").WithBodyAsJson(new { QUOTE_ID = "Q-77812", PRICE = new { AMOUNT = 89.90m, CUR = "TRY" }, SVC = "STD", ETA_DAYS = 2 }),
        _ => Response.Create().WithStatusCode(200)
            .WithHeader("Content-Type", "application/json").WithBodyAsJson(new { QUOTE_ID = "Q-77812", AMT = "89.90", CUR = "TRY", SVC = "STD", ETA_DAYS = 2 })
    };

    if (mode == FakeMode.Slow)
    {
        response = response.WithDelay(TimeSpan.FromSeconds(5));
    }

    server.Given(quotes).RespondWith(response);
}

İki terminalde fake'leri ve API'yi başlatıyoruz:

dotnet run --project src/Bookstore.FakeProviders -- --carrier Maintenance
dotnet run --project src/Bookstore.Api
Fake payment provider : http://localhost:9091 (Normal)
Fake legacy carrier   : http://localhost:9092 (Maintenance)
Press Ctrl+C to stop.

Fake'in yanıtına Content-Type header'ını açıkça koymamızın bir nedeni var. İlk denemede bu header'ı koymamıştım; Refit ile yazılmış client sorunsuz çalıştı, Kiota ile üretilmiş client ise hata vermeden boş bir model döndü. Kiota yanıtı hangi deserializer ile okuyacağını Content-Type'a bakarak seçiyor. Fake ne kadar gerçeğe yakınsa test o kadar anlamlı; bu ayrıntı ileride 05. yazıda testlerde de karşımıza çıkacak.

Port ve Adapter: Dış Modeli İçeri Sokmamak

Domain-Driven Design serisinin Context Mapping yazısında aynı kargo firmasının eski SOAP (XML tabanlı eski bir web servis protokolü) takip servisine bir ACL (Anti-Corruption Layer; dış sistemin modelini kendi modelimize çeviren katman) yazmıştık. Fikir aynı: dış sistemin kelimeleri Bookstore'un içine girmez. Bu seride ACL'in bir parçasına daha fazla odaklanıyoruz: hata modeli. Önce port, yani Bookstore'un ödeme için kendi dili:

namespace Bookstore.Payments;

// Port: Bookstore'un ödeme almak için kendi dili
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken);
}

public sealed record PaymentRequest(int OrderId, decimal Amount, string Currency, string CardToken);

public abstract record PaymentResult
{
    public sealed record Approved(string ProviderPaymentId) : PaymentResult;

    public sealed record Declined(DeclineReason Reason) : PaymentResult;
}

Bu interface'te ödeme sağlayıcısına ait hiçbir şey yok. Tutar decimal olarak geliyor; sağlayıcı ise kuruş cinsinden long istiyor. Sağlayıcının status, source, card_error gibi alanları yok. Kartın reddedilmesi exception değil, PaymentResult.Declined: checkout kodu bunu switch ile ele alıyor ve sipariş PaymentDeclined durumuna geçiyor.

Teknik arızalar ise kendi exception tipimizle geliyor. Burada önemli olan, exception'ın neyin ters gittiğini değil, çağıranın ne yapması gerektiğini anlatması:

public enum PaymentFailureKind
{
    // Sonra tekrar denemek işe yarayabilir (5xx, 429, bağlantı reddedildi)
    Transient,
    // Sağlayıcı uzun süre "şimdi olmaz" diyor (bakım, circuit açık)
    Unavailable,
    // İstek işlenmiş de olabilir, işlenmemiş de (gönderdikten sonra timeout)
    UnknownOutcome,
    // Sağlayıcı anlayamadığımız bir şey döndü
    ContractViolation
}

public sealed class PaymentGatewayException(PaymentFailureKind kind, string message, Exception? innerException = null)
    : Exception(message, innerException)
{
    public PaymentFailureKind Kind { get; } = kind;

    public TimeSpan? RetryAfter { get; init; }
}

UnknownOutcome'ı ayrı tutmak bu enum'un en değerli kısmı. Bağlantı hiç kurulamadıysa (HttpRequestException) para çekilmemiştir, güvenle tekrar denenebilir. İstek gittikten sonra timeout olduysa durum belirsizdir ve bunu "ödeme başarısız" diye müşteriye göstermek, ikinci denemede kartı iki kez çekmenin en kısa yoludur.

Hata Modelini Çevirmek

Adapter, sağlayıcının JSON'unu, status code'larını ve hata kodlarını bilen tek class. Tipik bir typed client olarak yazıldı, HttpClient'ı constructor'dan alıyor:

public sealed class ProviderPaymentGateway(HttpClient httpClient, ILogger<ProviderPaymentGateway> logger) : IPaymentGateway
{
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
    };

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken)
    {
        var body = new ProviderChargeRequest(
            Amount: ToMinorUnits(request.Amount),
            Currency: request.Currency,
            Source: request.CardToken,
            Description: $"Bookstore order {request.OrderId}");

        HttpResponseMessage response;
        try
        {
            response = await httpClient.PostAsJsonAsync("v1/charges", body, Json, cancellationToken);
        }
        catch (HttpRequestException ex)
        {
            // Sağlayıcıya hiçbir şey ulaşmadı (DNS, bağlantı reddi, TLS): sonra tekrar denemek güvenli
            throw new PaymentGatewayException(PaymentFailureKind.Transient, "Payment provider is unreachable.", ex);
        }
        catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            // İstek process'ten çıktı; kartın çekilip çekilmediğini bilmiyoruz
            throw new PaymentGatewayException(PaymentFailureKind.UnknownOutcome, "Payment provider did not answer in time.", ex);
        }

        using (response)
        {
            return response.StatusCode switch
            {
                HttpStatusCode.Created or HttpStatusCode.OK => await ReadApprovedAsync(response, cancellationToken),
                HttpStatusCode.PaymentRequired => await ReadDeclinedAsync(response, cancellationToken),
                HttpStatusCode.TooManyRequests => throw new PaymentGatewayException(
                    PaymentFailureKind.Transient, "Payment provider is throttling us.")
                    { RetryAfter = response.Headers.RetryAfter?.Delta },
                HttpStatusCode.ServiceUnavailable => throw new PaymentGatewayException(
                    PaymentFailureKind.Unavailable, "Payment provider is unavailable.")
                    { RetryAfter = response.Headers.RetryAfter?.Delta },
                >= HttpStatusCode.InternalServerError => throw new PaymentGatewayException(
                    PaymentFailureKind.Transient, $"Payment provider returned {(int)response.StatusCode}."),
                _ => throw new PaymentGatewayException(
                    PaymentFailureKind.ContractViolation, $"Unexpected status {(int)response.StatusCode} from payment provider.")
            };
        }
    }

    // Sağlayıcının contract'ı: internal kalıyor, domain'e sızmıyor
    private sealed record ProviderChargeRequest(long Amount, string Currency, string Source, string Description);

    private sealed record ProviderCharge(string Id, string Status, long Amount, string Currency);
    // ...
}

catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) satırına dikkat: HttpClient, kendi timeout'u dolduğunda da TaskCanceledException fırlatır. Kullanıcının isteği iptal ettiği durum ile sağlayıcının cevap vermediği durumu bu filtre ayırıyor. Bu ilişkiyi bir sonraki yazıda gerçek çıktıyla açacağız.

Exception'ların HTTP yanıtına çevrilmesi API'nin kenarında, bir IExceptionHandler içinde. Mekaniğini ASP.NET Core Web API serisinin Global Hata Yönetimi yazısında anlattık; burada önemli olan eşleme. Müşteri, sağlayıcının hata mesajını görmüyor:

(int status, string title)? mapped = exception switch
{
    PaymentGatewayException { Kind: PaymentFailureKind.UnknownOutcome } =>
        (StatusCodes.Status202Accepted, "Payment is being verified."),
    PaymentGatewayException { Kind: PaymentFailureKind.Transient or PaymentFailureKind.Unavailable } =>
        (StatusCodes.Status503ServiceUnavailable, "Payment service is temporarily unavailable."),
    ShippingCarrierException { Kind: ShippingFailureKind.Transient or ShippingFailureKind.Unavailable } =>
        (StatusCodes.Status503ServiceUnavailable, "Shipping service is temporarily unavailable."),
    PaymentGatewayException or ShippingCarrierException =>
        (StatusCodes.Status502BadGateway, "An external service returned an unexpected response."),
    _ => null
};

Repo'daki Bookstore.Api.http dosyasındaki checkout isteğini fake'lerin farklı modlarıyla gönderince şu yanıtları aldım. Kargo firması bakımdayken:

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Shipping service is temporarily unavailable.","status":503,"traceId":"00-a0139010768c527b6a2e245fa2a405c0-2864805a92cd9ba7-00"}

Ödeme sağlayıcısı bakımdayken (--payments Maintenance) yanıta sağlayıcının Retry-After: 600 değeri de taşınıyor; istemci ne zaman tekrar deneyeceğini biliyor. Kart reddedildiğinde ise yanıt 200 ve gövdede iş sonucu var:

HTTP/1.1 503 Service Unavailable
Retry-After: 600

HTTP/1.1 200 OK
{"orderId":1001,"status":"PaymentDeclined","total":548.90,"providerPaymentId":null,"declineReason":"CardDeclined"}

Kargo firmasının şema değiştirdiği modda ise 502 aldık ve log'da tam olarak neyin bozulduğu yazıyordu:

warn: Bookstore.Infrastructure.ExternalServiceExceptionHandler[0]
      External service failure mapped to 502
      Bookstore.Shipping.ShippingCarrierException: Carrier quote could not be mapped: QUOTE_ID=Q-77812, AMT=null.

Bu kötü bir sonuç gibi görünebilir ama değil. Alternatifi, fiyatı 0 ya da null kabul edip siparişi kargo ücretsiz tamamlamak. Mapping'deki decimal.TryParse kontrolü, contract değişikliğini ilk istekte yakalayıp alarm üretiyor.

Client Seçimi: Elle, Refit, Kiota

Adapter'ın içindeki HTTP kodunu yazmanın üç yaygın yolu var. Kargo firması için yukarıdaki gibi elle yazılmış bir typed client kullandık. Aynı API'yi Refit ve Kiota ile de çağırıp şema değişikliğinde nasıl davrandıklarına bakalım. Refit'te sen bir interface yazıyorsun, source generator implementasyonu derleme sırasında üretiyor:

// Refit: interface'i biz yazıyoruz, implementasyonu source generator yazıyor
public interface ILegacyCarrierApi
{
    [Get("/api/v2/quotes")]
    Task<RefitQuote> GetQuoteAsync(string from, string to, int weight, CancellationToken cancellationToken = default);
}

services.AddRefitGeneratedClient<ILegacyCarrierApi>()
    .ConfigureHttpClient(c => c.BaseAddress = baseAddress);

Burada bir tuzağa düştüm. İlk denemede Microsoft Learn'deki örneklerde olduğu gibi AddRefitClient kullandım ve Refit 16.4'te uygulama ilk çağrıda şu hatayla durdu:

Unhandled exception. System.NotSupportedException: This interface needs the reflection request builder, which is not installed. Add a reference to the Refit.Reflection NuGet package to opt in to it, or change the interface so every method generates inline (the RF006 diagnostic reports the methods that cannot) and use a generated client via RestService.ForGenerated or AddRefitGeneratedClient.

Yeni sürümlerde reflection ile çalışan request builder ayrı bir pakete taşınmış. Source generator'ın ürettiği client'ı AddRefitGeneratedClient ile kaydetmek hem bu paketi gereksiz kılıyor hem de trimming ve Native AOT (Ahead-of-Time; uygulamanın yayın sırasında doğrudan makine koduna derlenmesi) ile uyumlu.

Kiota ise ters yönden çalışıyor: elinde OpenAPI dosyası var, client'ı ondan üretiyorsun. Kiota'yı repo'nun local tool manifest'ine kurduk, global kurulum gerekmiyor:

dotnet tool restore
dotnet kiota generate -l CSharp -d specs/legacy-carrier.openapi.yaml -c LegacyCarrierApiClient -n Bookstore.ResilienceLab.Generated.LegacyCarrier -o samples/Bookstore.ResilienceLab/Generated/LegacyCarrier
Generation completed successfully
Client base url set to http://localhost:9092

Üretilen client, URL'nin parçalarını takip eden bir builder zinciri:

var quote = await kiota.Api.V2.Quotes.GetAsync(q =>
{
    q.QueryParameters.From = "34000";
    q.QueryParameters.To = "06100";
    q.QueryParameters.Weight = 850;
});

İkisini de önce normal modda, sonra şema değişmiş modda çağırıyoruz:

dotnet run --project samples/Bookstore.ResilienceLab -- clients
--- carrier mode: Normal
Refit : QUOTE_ID=Q-77812, AMT=89.90
Kiota : QUOTE_ID=Q-77812, AMT=89.90, unknown fields=[]
--- carrier mode: SchemaChanged
Refit : QUOTE_ID=Q-77812, AMT=null
Kiota : QUOTE_ID=Q-77812, AMT=null, unknown fields=[PRICE]

Tahmin sorusunun cevabı bu: iki client da exception fırlatmadı. Refit eksik alanı null bıraktı. Kiota da null bıraktı, ama tanımadığı PRICE alanını AdditionalData sözlüğünde tuttu; en azından loglayıp alarm üretebileceğin bir iz bırakıyor. Hangi client'ı seçersen seç, "bu yanıt benim için geçerli mi" sorusunu soran kod senin mapping'in. Üretilmiş client ACL'in yerini tutmaz, ACL'in içinde kullanılan bir araçtır.

Seçim için kısa bir kural: endpoint sayısı az ve sağlayıcının OpenAPI dokümanı yoksa ya da güvenilmezse elle typed client yaz. Sen interface'i tasarlamak istiyorsan ve endpoint sayısı orta düzeydeyse Refit kullan. Sağlayıcı düzgün bir OpenAPI dokümanı yayınlıyor ve onlarca endpoint varsa (e-fatura entegratörlerinin API'leri genelde böyledir) Kiota ya da NSwag ile üret. NSwag da OpenAPI'den C# client üreten, daha uzun süredir kullanılan bir araç; ürettiği kod tek dosyada ve HttpClient'ı doğrudan kullanıyor.

Üretilen Kodu Repoda Tutmak ve Versioning

Kiota'nın ürettiği dosyaları repo'ya commit'ledik. kiota-lock.json dosyası hangi OpenAPI dosyasından, hangi Kiota sürümüyle ve hangi ayarlarla üretildiğini tutuyor; dotnet kiota update aynı ayarlarla yeniden üretiyor. Kodu build sırasında üretmek de mümkün, ama bu durumda build'in sonucu dış bir dosyaya ve tool sürümüne bağlı oluyor. Commit'lenmiş üretilmiş kod ise sağlayıcı OpenAPI dosyasını değiştirdiğinde bir pull request'te diff olarak görünüyor ve code review'dan geçiyor. Şema değişikliğini production'da değil, pull request'te yakalamanın en ucuz yolu bu.

Sağlayıcının API sürümünü de config'te değil, adapter'ın içinde sabit tut. Bizim kargo client'ı api/v2/quotes path'ini kullanıyor; v3'e geçmek yeni bir adapter ve testleri demek, bir appsettings değeri değil. Bazı sağlayıcılar sürümü path yerine bir header ile istiyor; o header'ı da adapter'ın içinde, sabit olarak gönder.

Üretimde Dikkat

Her dış servis için en az üç sayıyı ölç: istek sayısı, hata oranı (status code'a göre) ve gecikme dağılımı (p50, p99). Ortalama gecikme yanıltır; sağlayıcının yavaşladığını ilk p99 gösterir. Bu ölçümlerin nasıl alınacağını 05. yazıda göreceğiz.

Log'da sağlayıcının hata gövdesini tut ama kullanıcıya gösterme. Yukarıdaki 502 yanıtında yalnız genel bir başlık var; neyin bozulduğu log'da. Kart token'ı, API key ve tam istek gövdesi log'a girmemeli. .NET'in HttpClient log'larının bu konuda varsayılan olarak ne yaptığına bir sonraki yazıda bakacağız.

Para tutarını sağlayıcının biriminde çevirirken yuvarlamayı açıkça yaz. ToMinorUnits MidpointRounding.AwayFromZero kullanıyor; sağlayıcıya 548,90 TL için 54890 gidiyor. Burada yapılan bir yuvarlama hatası, muhasebe mutabakatında aylar sonra ortaya çıkar.

Sık Düşülen Tuzaklar

Sağlayıcının DTO'larını (Data Transfer Object; yalnız veri taşıyan, davranışı olmayan tip) domain'de kullanmak. ProviderCharge tipini sipariş entity'sinde bir property olarak tutan kod, sağlayıcı değiştiğinde veritabanı migration'ı gerektirir. DTO'lar adapter'ın içinde private ya da internal kalmalı.

Her hatayı aynı exception ile taşımak. HttpRequestException hem "bağlantı kurulamadı" hem "404 döndü" için kullanılırsa, üstteki kod retry yapıp yapmamaya karar veremez. Hata türü, çağıranın vereceği karara göre tasarlanmalı.

Kartın reddedilmesini exception yapmak. İş sonucu olan bir şeyi exception ile taşımak, onu genel retry ve circuit breaker mantığına sokar. Reddedilen kartlar breaker'ın hata oranını artırır ve breaker, sağlıklı bir sağlayıcıya giden yolu kapatır.

Üretilmiş client'a güvenip doğrulamayı atlamak. Yukarıdaki çıktıda gördüğün gibi şema değişikliği exception üretmiyor. Zorunlu alanları mapping'de kontrol et.

Küçük Bir Deneme

★☆☆ Fake ödeme sağlayıcısına insufficient_funds kodu dönen yeni bir kural ekle (tok_insufficient_funds token'ı için) ve checkout'un InsufficientFunds döndüğünü Bookstore.Api.http ile doğrula.

★★☆ LegacyCarrierClient.Map için, AMT alanının "89,90" (virgüllü) geldiği bir durumu düşün. Mevcut kod bunu nasıl yorumluyor? Bir test yazıp gerçek sonucu gör, sonra davranışın doğru olup olmadığına karar ver.

★★★ Kiota client'ını IShippingCarrier'ın ikinci bir implementasyonu olarak yaz (KiotaLegacyCarrierClient). AdditionalData boş değilse bir uyarı logla. İki implementasyonu aynı fake'e karşı çalıştıran bir karşılaştırma komutu ekle.

Bu yazıda client'ları AddHttpClient ile kaydettik ama arkasında ne olduğuna bakmadık. Bir sonraki yazıda IHttpClientFactory'nin içine giriyoruz: named, typed ve keyed client farkı, handler'ların ömrü, DelegatingHandler ile token eklemek ve HttpClient.Timeout'un CancellationToken ile ilişkisi.