Mert Özen Her Satırda Daha İleri
Backend gRPC ve GraphQL — #02

ASP.NET Core'da İlk gRPC Service'i ve Client'ı: Kestrel, Deadline ve RpcException

Mert Özen 14 Kas 2026 12 dk 2 görüntülenme
ASP.NET Core'da İlk gRPC Service'i ve Client'ı: Kestrel, Deadline ve RpcException

Bir gRPC çağrısını güvenli yapmak için channel'ı paylaşmak, her çağrıya deadline koymak ve EnableCallContextPropagation ile deadline'ı zincir boyunca taşımak gerekir; yoksa client vazgeçtikten sonra bile downstream service işi bitirip stok ayırır. Inventory'yi Kestrel'de h2c ile ayağa kaldırıyor, status code'ları, rich error model'i ve metadata'yı gerçek loglarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Grpc.AspNetCore 2.85, Grpc.Net.ClientFactory 2.85 ve Grpc.StatusProto 2.85 ile derlenip denenmiştir.

Önce Tahmin Et

Müşteri "Siparişi Tamamla"ya basıyor. Checkout tarafı Ordering'i bir saniyelik deadline ile çağırıyor, Ordering de stok ayırmak için Inventory'yi çağırıyor. O sırada Inventory'nin veritabanı yavaş ve rezervasyon üç saniye sürüyor. Bir saniye dolunca checkout "zaman aşımı" deyip vazgeçiyor.

Soru şu: iki saniye sonra Inventory'deki işlem ne oluyor? Bitip stoğu ayırıyor mu, yoksa o da vazgeçiyor mu? Cevap Ordering'in client'ını nasıl kaydettiğine bağlı ve iki durumun gerçek loglarını yazının sonunda yan yana göreceğiz.

Inventory'yi Ayağa Kaldırmak: AddGrpc ve MapGrpcService

Bir önceki yazıda yazdığımız inventory.proto'yu şimdi gerçek bir service'e çeviriyoruz. Solution'da üç proje var: Bookstore.Inventory (gRPC server), Bookstore.Ordering (hem server hem Inventory'nin client'ı) ve denemeler için küçük bir console aracı, Bookstore.StockCheck. Ordering'in de bir contract'ı oldu:

syntax = "proto3";

package bookstore.ordering.v1;

option csharp_namespace = "Bookstore.Ordering.V1";

import "bookstore/inventory/v1/inventory.proto";

service OrderingService {
  rpc PlaceOrder (PlaceOrderRequest) returns (PlaceOrderResponse);
}

message PlaceOrderRequest {
  string customer_id = 1;
  repeated bookstore.inventory.v1.ReservationLine lines = 2;
}

message PlaceOrderResponse {
  string order_id = 1;
  string reservation_id = 2;
}

Klasörde artık ordering contract'ı da olduğu için Inventory yalnız kendi ilgilendiği dosyaları derliyor:

<ItemGroup>
    <PackageReference Include="Grpc.AspNetCore" Version="2.85.0" />
    <PackageReference Include="Grpc.StatusProto" Version="2.85.0" />
</ItemGroup>

<ItemGroup>
    <Protobuf Include="..\..\protos\bookstore\common\**\*.proto;..\..\protos\bookstore\inventory\**\*.proto" ProtoRoot="..\..\protos" GrpcServices="Server" />
    <Compile Include="..\..\protos\DecimalValue.cs" Link="Protos\DecimalValue.cs" />
</ItemGroup>

DecimalValue'nun partial dönüşüm dosyasını protos/ klasörüne taşıyıp iki projeye link'ledik; her proje mesajları kendisi ürettiği için partial class'ın da her projede derlenmesi gerekiyor. Program.cs kısa:

using Bookstore.Inventory.Services;
using Bookstore.Inventory.Stock;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddGrpc();
builder.Services.Configure<InventoryOptions>(builder.Configuration.GetSection("Inventory"));
builder.Services.AddSingleton<InMemoryStockStore>();

var app = builder.Build();

app.MapGrpcService<InventoryGrpcService>();

app.Run();

AddGrpc gRPC altyapısını DI'ya (Dependency Injection; bağımlılıkların class'a dışarıdan verilmesi) kaydeder, MapGrpcService ise contract'taki her method için bir endpoint açar: /bookstore.inventory.v1.InventoryService/GetStock gibi. Route'u sen seçmiyorsun; package, service ve method adından geliyor. Service class'ı üretilen abstract base'den türüyor ve primary constructor ile bağımlılıklarını alıyor:

public sealed class InventoryGrpcService(InMemoryStockStore store, ILogger<InventoryGrpcService> logger)
    : InventoryService.InventoryServiceBase
{
    public override async Task<GetStockResponse> GetStock(GetStockRequest request, ServerCallContext context)
    {
        Guid bookId = ParseBookId(request.BookId, "book_id");
        string? correlationId = context.RequestHeaders.GetValue("x-correlation-id");
        logger.LogInformation("GetStock {BookId}, correlation {CorrelationId}", bookId, correlationId);

        StockItem item = await store.FindAsync(bookId, context.CancellationToken)
            ?? throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Book {bookId} has no stock record."));

        context.ResponseTrailers.Add("x-stock-source", "memory");

        return new GetStockResponse
        {
            BookId = item.BookId.ToString(),
            Available = item.Available,
            ReservedQuantity = item.Reserved,
            Status = item.Available switch
            {
                0 => StockStatus.OutOfStock,
                < 3 => StockStatus.Low,
                _ => StockStatus.InStock
            },
            UnitPrice = new Money { CurrencyCode = "TRY", Amount = DecimalValue.FromDecimal(item.UnitPrice) },
            UpdatedAt = Timestamp.FromDateTimeOffset(item.UpdatedAt)
        };
    }
}

Service class'ı varsayılan olarak her çağrıda yeniden oluşturulur (iki PlaceOrder çağrısının loglarına sayaç koyduğumda iki ayrı instance gördüm); Singleton, Scoped ya da Transient bağımlılık alabilir. Burada store singleton, çünkü stoğu bellekte tutuyor; gerçek hayatta bunun yerinde scoped bir DbContext olurdu. Grpc.Core.Status'u tam adıyla yazmamın sebebi birazdan kullanacağımız Google.Rpc.Status ile çakışması.

Kestrel ve HTTP/2: TLS, ALPN ve h2c

gRPC, HTTP'nin 2. sürümünü, yani HTTP/2'yi ister. Kestrel bir endpoint'te hem HTTP/1.1 hem HTTP/2 konuşabilir, ama hangisinin kullanılacağına TLS el sıkışması sırasında ALPN (TLS handshake'inde HTTP/2 gibi protokolün seçilmesi) ile karar verilir. Production'da endpoint TLS'li olmalı ve bu müzakere kendiliğinden işler. Lokalde ya da TLS'in önündeki bir proxy'de sonlandığı bir ortamda şifresiz HTTP/2 (h2c) kullanıyorsan, endpoint'in yalnız HTTP/2 konuştuğunu açıkça söylemen gerekir:

"Kestrel": {
  "Endpoints": {
    "Grpc": {
      "Url": "http://localhost:5101",
      "Protocols": "Http2"
    }
  }
}

Bunu Http1AndHttp2 yaparsan ne olduğunu denedim. TLS olmadığı için müzakere yok, Kestrel bağlantıyı HTTP/1.1 sayıyor ve client'ın HTTP/2 bağlantısını kapatıyor:

Internal: Error starting gRPC call. HttpRequestException: The HTTP/2 server closed the connection. HTTP/2 error code 'HTTP_1_1_REQUIRED' (0xd). (HttpProtocolError) HttpProtocolException: The HTTP/2 server closed the connection. HTTP/2 error code 'HTTP_1_1_REQUIRED' (0xd). (HttpProtocolError)

Bu hatayı görürsen ilk bakacağın yer endpoint'in Protocols ayarı. Aynı uygulamada hem REST (Representational State Transfer) hem gRPC sunmak istiyorsan iki yol var: TLS'li tek endpoint (ALPN ikisini ayırır) ya da iki ayrı port, biri Http1 biri Http2. HTTP/2'nin bağlantı düzeyindeki ayrıntılarını Networking ve HTTP Nasıl Çalışır serisinin HTTP/2 yazısında Kestrel üzerinden anlatmıştık.

Eski bir projede AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true) satırına rastlarsan, bu .NET Core 3.x'te client'ın h2c konuşabilmesi için gerekiyordu. .NET 5'ten beri http:// adresli bir GrpcChannel ek ayar olmadan h2c kullanır.

Client: Channel Pahalı, Client Ucuz

Client tarafında iki kavram var. GrpcChannel server'a giden uzun ömürlü bağlantıyı (altta bir HttpClient ve TCP bağlantıları) temsil eder. Üretilen InventoryServiceClient ise channel üzerine kurulmuş ince bir sarmalayıcıdır; oluşturması neredeyse bedava. Kural buradan çıkıyor: channel'ı bir kez oluştur ve paylaş, client'ı istediğin kadar oluştur.

// channel pahalı: bir kez oluştur, uygulama boyunca kullan
using GrpcChannel inventoryChannel = GrpcChannel.ForAddress("http://localhost:5101");
var inventory = new InventoryService.InventoryServiceClient(inventoryChannel);

Farkı ölçmek için aynı GetStock çağrısını 200 kez, önce paylaşılan channel'la, sonra her seferinde yeni bir channel'la yaptım:

for (int i = 0; i < 200; i++)
{
    // yanlış: her çağrıda yeni channel, yeni TCP bağlantısı
    using GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:5101");
    await new InventoryService.InventoryServiceClient(channel).GetStockAsync(request);
}
reused channel:   30 ms
channel per call: 93 ms
reused channel:   31 ms
channel per call: 95 ms
reused channel:   32 ms
channel per call: 94 ms

Release build, aynı makine, TLS yok: fark üç kat. Gerçek ağda her yeni bağlantıya bir TLS el sıkışması ve ağ gecikmesi eklenir; fark büyür, üstüne kapanan her bağlantı işletim sisteminde bir süre TIME_WAIT'te kalır.

ASP.NET Core uygulamasında channel'ı elle yönetmek yerine client factory kullanıyoruz. Ordering'in Program.cs'i:

builder.Services.AddGrpc();
builder.Services
    .AddGrpcClient<InventoryService.InventoryServiceClient>(options =>
    {
        options.Address = new Uri(builder.Configuration["Services:Inventory"]!);
    })
    .EnableCallContextPropagation();

AddGrpcClient, IHttpClientFactory'nin üstüne kurulu: client transient olarak inject edilir, alttaki HttpMessageHandler ise factory tarafından havuzlanır ve yenilenir. Service'e constructor'dan InventoryService.InventoryServiceClient istemen yeterli. EnableCallContextPropagation'a yazının sonunda döneceğiz; baştaki sorunun cevabı orada.

Status Code'lar ve RpcException

gRPC'de her çağrı bir status ile biter. Başarılıysa OK, değilse bir hata kodu ve isteğe bağlı bir açıklama. Server'da hata dönmek için RpcException fırlatırsın; client'ta aynı tip yakalanır. Bookstore'da en sık kullandıklarımız:

  • NotFound: istenen kayıt yok. Stok kaydı olmayan kitap.
  • InvalidArgument: istek, sistemin durumundan bağımsız olarak hatalı. GUID olmayan bir book_id.
  • FailedPrecondition: istek geçerli ama sistem şu an bu işlem için uygun durumda değil. Son kopyadan iki tane ayırmak. Durum değişmeden tekrar denemek anlamsız.
  • Unavailable: geçici bir sorun, server'a ulaşılamıyor. Retry için doğru aday.
  • DeadlineExceeded: çağrı verilen sürede bitmedi. Server'da iş tamamlanmış da olabilir, olmamış da.

Server'da RpcException dışında bir exception kaçarsa client Unknown status ve "Exception was thrown by handler." mesajı alır; asıl mesaj hassas bilgi sızdırmasın diye gönderilmez. Development'ta EnableDetailedErrors ile açabilirsin, production'da kapalı kalmalı.

Peki HTTP status nerede? Kayıtsız bir kitabı curl ile doğrudan sorduğumda yanıt şöyle:

< HTTP/2 200
< content-type: application/grpc
< date: Fri, 13 Nov 2026 21:13:55 GMT
< server: Kestrel
< content-length: 0
< grpc-status: 5
< grpc-message: Book 00000000-0000-0000-0000-000000000000 has no stock record.

HTTP status 200. Asıl sonuç grpc-status header'ında: 5, yani NotFound. Normalde status, gövdeden sonra gelen trailer'larda taşınır; burada gövde olmadığı için server "trailers-only" bir yanıt dönüp status'u doğrudan header'lara koymuş. Bu yüzden gRPC trafiğini HTTP status'una bakan bir monitoring aracıyla izlersen bütün hatalar başarılı görünür.

Ters yön de var. Client ile server arasındaki bir proxy (load balancer, API gateway) grpc-status taşımayan bir HTTP hatası dönerse, gRPC client'ı bunu bir eşleme tablosuyla status'a çevirir: 401 → Unauthenticated, 403 → PermissionDenied, 404 → Unimplemented, 429/502/503/504 → Unavailable, geri kalanı Unknown. Yani client'ta Unimplemented görüyorsan sebep çoğu zaman yanlış route ya da yanlış porttur, server'da eksik bir method değil.

Rich Error Model ve Metadata

Status kodu ve tek satırlık mesaj "ne oldu"yu söyler ama "hangi field, neden" sorusunu cevaplamaz. Bunun için Google'ın google.rpc.Status modeli var: kod, mesaj ve Any ile paketlenmiş detay mesajları. .NET'te Grpc.StatusProto paketi bu modeli RpcException'a çevirir. Hatalı id için:

private static Guid ParseBookId(string value, string field)
{
    if (Guid.TryParse(value, out Guid bookId))
    {
        return bookId;
    }

    throw new Google.Rpc.Status
    {
        Code = (int)Code.InvalidArgument,
        Message = "Invalid request.",
        Details =
        {
            Any.Pack(new BadRequest
            {
                FieldViolations =
                {
                    new BadRequest.Types.FieldViolation { Field = field, Description = $"'{value}' is not a valid UUID." }
                }
            })
        }
    }.ToRpcException();
}

Rezervasyonda stok yetmezse aynı şekilde FailedPrecondition ile bir PreconditionFailure detayı dönüyoruz. Client tarafında GetRpcStatus() ile modeli, GetDetail<T>() ile istediğin detayı alırsın:

catch (RpcException ex)
{
    Console.WriteLine($"{ex.StatusCode}: {ex.Status.Detail}");

    Google.Rpc.Status? rich = ex.GetRpcStatus();
    if (rich?.GetDetail<BadRequest>() is { } badRequest)
    {
        foreach (BadRequest.Types.FieldViolation violation in badRequest.FieldViolations)
        {
            Console.WriteLine($"  field {violation.Field}: {violation.Description}");
        }
    }

    if (rich?.GetDetail<PreconditionFailure>() is { } precondition)
    {
        foreach (PreconditionFailure.Types.Violation violation in precondition.Violations)
        {
            Console.WriteLine($"  {violation.Type} {violation.Subject}: {violation.Description}");
        }
    }
}

Üç senaryonun gerçek çıktıları:

== notfound
NotFound: Book 00000000-0000-0000-0000-000000000000 has no stock record.
== invalid
InvalidArgument: Invalid request.
  field book_id: '42' is not a valid UUID.
== reserve-too-many
FailedPrecondition: Insufficient stock.
  STOCK books/8b1d4e6a-2c3f-4a5b-9e7d-1f0a2b3c4d5e: Requested 2, available 1.

Detaylar grpc-status-details-bin trailer'ında binary olarak gider. Trailer'lar header gibi işlendiği için toplam boyutları çoğu ortamda 8 KB ile sınırlı; yüzlerce field ihlalini tek hataya doldurma.

Detayın dışında kalan bağlam için metadata var. İstekte header, yanıtın sonunda trailer gönderebilirsin. Server yukarıdaki GetStock'ta x-correlation-id header'ını okuyup x-stock-source trailer'ını ekliyordu. Client'ta trailer'lara ulaşmak için çağrının kendisini tutman gerekiyor:

using (AsyncUnaryCall<GetStockResponse> call = inventory.GetStockAsync(
    new GetStockRequest { BookId = KnownBook },
    headers: new Metadata { { "x-correlation-id", "chk-1001" } },
    deadline: DateTime.UtcNow.AddSeconds(2)))
{
    GetStockResponse stock = await call.ResponseAsync;
    Console.WriteLine($"available={stock.Available} status={stock.Status} price={stock.UnitPrice.Amount.ToDecimal()} {stock.UnitPrice.CurrencyCode}");
    Console.WriteLine($"trailer x-stock-source={call.GetTrailers().GetValue("x-stock-source")}");
}
available=12 status=InStock price=249.9 TRY
trailer x-stock-source=memory

Server logu da header'ın ulaştığını gösteriyor: GetStock 3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13, correlation chk-1001. HTTP/2 header'ları küçük harfli olduğu için Metadata anahtarları küçük harfe çevrilir; binary değer taşıyacaksan anahtar -bin ile bitmeli.

Deadline: Sınırsız Beklemenin Sonu

gRPC çağrılarının varsayılan deadline'ı yok. Deadline vermezsen client server cevap verene kadar bekler; server takılırsa client da takılır, onu çağıran da. Deadline bir süre değil, bir zaman noktasıdır (UTC); kütüphane bunu grpc-timeout header'ında kalan süre olarak gönderir ve iki taraf da ayrı ayrı izler.

Denemek için Inventory'yi her store çağrısında 3 saniye bekleyecek şekilde başlattım (Inventory__SimulatedLatencyMs=3000) ve 500 ms deadline ile çağırdım:

case "deadline":
    var watch = Stopwatch.StartNew();
    try
    {
        await inventory.GetStockAsync(new GetStockRequest { BookId = KnownBook }, deadline: DateTime.UtcNow.AddMilliseconds(500));
    }
    finally
    {
        Console.WriteLine($"elapsed: {watch.ElapsedMilliseconds} ms");
    }
    break;
elapsed: 534 ms
DeadlineExceeded:
elapsed: 3064 ms

İlk iki satır deadline'lı çağrı: client yarım saniyede vazgeçti. Üçüncü satır aynı çağrının deadline'sız hâli; üç saniyenin tamamını bekledi. Server tarafında ise deadline dolduğu anda context.CancellationToken iptal edildi ve store'daki Task.Delay yarıda kesildi:

info: Grpc.AspNetCore.Server.ServerCallHandler[28]
      Service method 'GetStock' canceled.
      System.Threading.Tasks.TaskCanceledException: A task was canceled.
         at Bookstore.Inventory.Stock.InMemoryStockStore.FindAsync(Guid bookId, CancellationToken cancellationToken)

Bu kesinti kendiliğinden olmadı. HTTP isteği iptal edilse de service method'u sen bitirene kadar çalışmaya devam eder; işi durduran şey token'ı store'a, oradan veritabanı sorgusuna kadar geçirmiş olman. Mekanizma ASP.NET Core Web API serisinin CancellationToken yazısındakiyle aynı: token'ı geçirmediğin her await iptali duymaz.

Deadline'ı Zincir Boyunca Taşımak

Şimdi baştaki senaryo. Checkout, Ordering'in PlaceOrder'ını bir saniyelik deadline ile çağırıyor; Ordering de Inventory'nin ReserveStock'unu çağırıyor:

public override async Task<PlaceOrderResponse> PlaceOrder(PlaceOrderRequest request, ServerCallContext context)
{
    string orderId = $"o-{Guid.NewGuid():N}"[..10];
    logger.LogInformation("PlaceOrder {OrderId}, deadline {Deadline:HH:mm:ss.fff}", orderId, context.Deadline);

    // deadline ve CancellationToken'ı EnableCallContextPropagation taşıyor
    ReserveStockResponse reservation = await inventory.ReserveStockAsync(
        new ReserveStockRequest { OrderId = orderId, Lines = { request.Lines } });

    return new PlaceOrderResponse { OrderId = orderId, ReservationId = reservation.ReservationId };
}

İç çağrıya ne deadline ne token veriyoruz. EnableCallContextPropagation açıkken Inventory'nin logları:

info: Bookstore.Ordering.Services.OrderingGrpcService[0]
      PlaceOrder o-b5e94785, deadline 21:14:30.691

info: Bookstore.Inventory.Services.InventoryGrpcService[0]
      ReserveStock for o-b5e94785, deadline 21:14:30.716
warn: Bookstore.Inventory.Services.InventoryGrpcService[0]
      ReserveStock for o-b5e94785 cancelled by the caller

Ordering'e gelen deadline Inventory'ye de geçmiş (aradaki 25 ms, deadline'ın kalan süre olarak gönderilmesinden kaynaklanan küçük kayma). Bir saniye dolunca Inventory işi bıraktı, stok ayrılmadı. Ordering'in iç çağrısı Cancelled ile bitti, client DeadlineExceeded aldı, toplam süre 1028 ms.

Aynı kodu EnableCallContextPropagation satırını silip çalıştırdım:

info: Bookstore.Ordering.Services.OrderingGrpcService[0]
      PlaceOrder o-2b254506, deadline 21:14:49.499
info: System.Net.Http.HttpClient.InventoryServiceClient.ClientHandler[101]
      Received HTTP response headers after 3018.1468ms - 200

info: Bookstore.Inventory.Services.InventoryGrpcService[0]
      ReserveStock for o-2b254506, deadline 23:59:59.999

Client yine bir saniyede DeadlineExceeded aldı. Ama Inventory'nin gördüğü deadline DateTime.MaxValue; işi üç saniyede bitirdi ve stoğu, müşterinin çoktan vazgeçtiği bir sipariş için ayırdı. Baştaki sorunun cevabı bu: propagation yoksa zincirin aşağısı yukarının vazgeçtiğinden haberdar olmaz, hem kaynak harcar hem de yan etki üretir.

Propagation yalnız bir gRPC çağrısının içinden yapılan çağrılarda çalışır; context'i HttpContext üzerindeki ServerCallContext'ten okur. Ordering aynı client'ı bir minimal API endpoint'inden ya da background service'ten kullanırsa propagation bir hata fırlatır; bu durumda ya SuppressContextNotFoundErrors seçeneğini açarsın ya da deadline'ı ve CancellationToken'ı elle verirsin. İç çağrıda daha kısa bir deadline verirsen ikisinden küçük olan kullanılır.

Üretimde Dikkat

Her çağrıya bir deadline koy ve değerini ölçümle seç. p99 gecikmesi 80 ms olan bir çağrıya 30 saniye vermek deadline'sız olmakla neredeyse aynı. İç service çağrılarında zinciri propagation ile kur, en dıştaki deadline bütçeyi belirlesin.

gRPC status'larını metrik olarak izle. HTTP status hep 200 olduğu için HTTP status'una bakan dashboard'lar hatayı göstermez. Trace ve metriklerde gRPC status kodunu ayrı bir boyut olarak kaydet; alarmlarını Unavailable, DeadlineExceeded ve Internal oranlarına kur.

DeadlineExceeded'ı "başarısız" değil "bilinmiyor" diye yorumla. Server işi tamamlamış ama yanıt geç kalmış olabilir. Rezervasyon gibi yan etkili çağrıları idempotent tasarla (aynı order_id ile ikinci rezervasyon yeni stok ayırmasın), yoksa client'ın tekrar denemesi çift kayıt üretir.

Downstream status'unu olduğu gibi yukarı taşıma. Ordering'in Inventory'den aldığı FailedPrecondition'ı checkout'a aynen iletmek bazen doğru, ama iç hostname'ler, iç id'ler ya da başka bir service'in hata mesajları dışarıya sızabilir. Sınırda hatayı kendi contract'ına göre yeniden üret.

Sık Düşülen Tuzaklar

Her çağrıda yeni channel. Yukarıda üç katlık farkı lokalde, TLS'siz gördük; gerçek ağda daha kötü. using var channel satırını bir method'un içinde görüyorsan büyük ihtimalle yanlış yerdedir.

Deadline'sız gRPC çağrısı. Yavaşlayan tek bir downstream, onu bekleyen bütün thread'leri ve bağlantıları tutar; hata zincir boyunca yukarı yayılır.

context.CancellationToken'ı geçirmemek. Deadline dolduğunda client gider ama server'daki sorgu sonuna kadar çalışır. Log'da "canceled" yazar, veritabanı ise hâlâ çalışıyordur.

TLS'siz endpoint'te Http1AndHttp2. HTTP_1_1_REQUIRED hatası tam olarak bunu söylüyor.

Her hatayı Internal ya da Unknown ile dönmek. Client'ın retry kararı status koduna bakar; geçici bir sorunu Internal diye, kalıcı bir doğrulama hatasını Unavailable diye dönersen ya gereksiz retry ya da gereksiz vazgeçme olur.

Küçük Bir Deneme

★☆☆ GetStock'u curl ile --http2-prior-knowledge kullanarak başarılı bir kitap için çağır ve gövdeden sonra gelen trailer'ları gör. Hatalı yanıttaki "trailers-only" yapıyla karşılaştır.

★★☆ Ordering'e GET /orders/check/{bookId} diye bir minimal API endpoint'i ekle ve aynı InventoryServiceClient'ı oradan çağır. Propagation'ın verdiği hatayı gör, sonra SuppressContextNotFoundErrors ile ve HttpContext.RequestAborted'ı geçirerek düzelt.

★★★ ReserveStock'u idempotent yap: aynı order_id ile gelen ikinci çağrı yeni rezervasyon açmak yerine ilk rezervasyonun id'sini dönsün. Propagation'sız senaryoyu tekrar çalıştırıp client'ın retry'ının artık çift rezervasyon yaratmadığını göster.

Şimdiye kadarki bütün çağrılar tek istek, tek yanıt. Bir sonraki yazıda stok değişikliklerini canlı izlemek, CSV dosyasından toplu güncelleme göndermek ve kasada barkod okutmak için server, client ve bidirectional streaming'e geçiyor; yavaş bir consumer'ın server'ı nasıl frenlediğini görüyoruz.