gRPC Clients and Streaming

Call gRPC services through AddGrpcClient with deadlines and channel retries, consume server and client streams, and know when gRPC fits.

Syntaxusing var call = client.WatchStock(request, deadline: DateTime.UtcNow.AddMinutes(5), cancellationToken: ct); await foreach (var change in call.ResponseStream.ReadAllAsync(ct)) { }

The generated Inventory.InventoryClient is a thin wrapper; the expensive object is the GrpcChannel, which owns the HTTP/2 connections. Create one channel per target and reuse it, never one per call. In an ASP.NET Core app, AddGrpcClient<Inventory.InventoryClient>() from Grpc.Net.ClientFactory registers the client as transient and builds its channel on an IHttpClientFactory handler, so it is configured like a typed HttpClient.

Call shapes

ShapeProtoClient side
Unaryrpc GetStock (Req) returns (Reply)await client.GetStockAsync(req, deadline: ...)
Server streamingreturns (stream StockChanged)call.ResponseStream.ReadAllAsync(ct)
Client streaming(stream StockCount) returns (Summary)RequestStream.WriteAsync, CompleteAsync, then await call
Bidirectionalstream both waysRead and write concurrently

Streaming calls are IDisposable. Wrap them in using: disposing an unfinished call cancels it and releases its HTTP/2 stream; forgetting leaks the stream until the server ends it.

Deadlines and cancellation

The default is no deadline, so a call to a stuck server waits forever while holding a stream. Pass deadline: DateTime.UtcNow.AddSeconds(2) (UTC) on every unary call and the incoming request's CancellationToken. Expiry surfaces as RpcException with DeadlineExceeded, cancellation as Cancelled. Inside a gRPC service, .EnableCallContextPropagation() copies the incoming call's deadline and token to outgoing calls; outside one it throws unless SuppressContextNotFoundErrors is set.

Retries belong in the channel

A gRPC failure is an HTTP 200 with a status in the trailers, so an HTTP resilience handler cannot see it and must not replay a streaming body. Configure a RetryPolicy in the channel's ServiceConfig: it retries only the codes you list (normally Unavailable), with exponential backoff, at most 5 attempts (MaxRetryAttempts). A call stops being retried once response headers arrive, or when a streaming call's sent messages exceed the 1 MB per-call retry buffer. Only retry methods that are safe to repeat.

Watch for a second, invisible retry layer. AddGrpcClient builds on IHttpClientFactory, so ConfigureHttpClientDefaults applies to it too: with Aspire service defaults or any global AddStandardResilienceHandler(), the gRPC client inherits the standard HTTP pipeline (see net-aspire-service-defaults and net-http-resilience). Its retries stack on the channel's, and its 10-second attempt and 30-second total timeouts cut long calls such as a 5-minute WatchStock stream. Either add the standard handler per client instead of globally, or call RemoveAllResilienceHandlers() (experimental, EXTEXP0001) on the builder AddGrpcClient returns.

Connections and load balancing

Kestrel allows 100 concurrent streams per HTTP/2 connection. Long-running streams fill those slots and further calls queue; SocketsHttpHandler.EnableMultipleHttp2Connections = true lets the client open more connections. Worse, a Kubernetes ClusterIP Service balances connections, not calls, so every call from one client pod lands on one server pod. Use client-side balancing (a dns:/// address against a headless Service with round_robin) or an HTTP/2-aware proxy or mesh.

When gRPC fits

Internal service-to-service calls where you own both ends: typed contracts across languages, compact payloads, real streaming. Keep JSON over HTTP for public and browser APIs: browsers cannot speak native gRPC (they need gRPC-Web), payloads are not readable in logs or curl, and caches and gateways understand REST better.

Example

Example · csharp
using System.Runtime.CompilerServices;
using Grpc.Core;
using Grpc.Net.Client.Configuration;
using Shop.Inventory.V1;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddGrpcClient<Inventory.InventoryClient>(options =>
    {
        options.Address = new Uri(builder.Configuration["Services:Inventory"] ?? "http://inventory:8081");
    })
    .ConfigureChannel(channel =>
    {
        channel.ServiceConfig = new ServiceConfig
        {
            MethodConfigs =
            {
                new MethodConfig
                {
                    // Only the idempotent read; MethodName.Default would make every method retryable.
                    Names = { new MethodName { Service = "shop.inventory.v1.Inventory", Method = "GetStock" } },
                    RetryPolicy = new RetryPolicy
                    {
                        MaxAttempts = 3,
                        InitialBackoff = TimeSpan.FromMilliseconds(200),
                        MaxBackoff = TimeSpan.FromSeconds(2),
                        BackoffMultiplier = 2,
                        RetryableStatusCodes = { StatusCode.Unavailable }
                    }
                }
            }
        };
    })
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,
        PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
        KeepAlivePingDelay = TimeSpan.FromSeconds(60),
        KeepAlivePingTimeout = TimeSpan.FromSeconds(30)
    });

var app = builder.Build();

// Unary call with a deadline, translated into HTTP results for the REST caller.
app.MapGet("/products/{sku}/stock", async (string sku, Inventory.InventoryClient inventory, CancellationToken ct) =>
{
    try
    {
        var reply = await inventory.GetStockAsync(
            new StockRequest { Sku = sku },
            deadline: DateTime.UtcNow.AddSeconds(2),
            cancellationToken: ct);
        return Results.Ok(new StockLevel(reply.Sku, reply.Available, reply.Reserved));
    }
    catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
    {
        return Results.NotFound();
    }
    catch (RpcException ex) when (ex.StatusCode is StatusCode.DeadlineExceeded or StatusCode.Unavailable)
    {
        return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Inventory is unavailable");
    }
});

// Server streaming relayed to browsers as Server-Sent Events.
app.MapGet("/warehouses/{code}/stock-changes", (string code, Inventory.InventoryClient inventory, CancellationToken ct) =>
    TypedResults.ServerSentEvents(WatchAsync(inventory, code, ct), eventType: "stock"));

app.Run();

static async IAsyncEnumerable<StockLevel> WatchAsync(
    Inventory.InventoryClient inventory, string warehouseCode, [EnumeratorCancellation] CancellationToken ct)
{
    // Disposing the call when the browser leaves cancels the upstream stream too.
    using var call = inventory.WatchStock(new WatchStockRequest { WarehouseCode = warehouseCode }, cancellationToken: ct);
    await foreach (var change in call.ResponseStream.ReadAllAsync(ct))
    {
        yield return new StockLevel(change.Sku, change.Available, change.Reserved);
    }
}

public sealed record StockLevel(string Sku, int Available, int Reserved);

When to use it

  • Check stock in the inventory service from the orders API with a 2-second deadline and a retry on Unavailable only
  • Relay live stock changes for one warehouse to a browser dashboard through a single server-streaming call
  • Upload 50,000 nightly stock counts from a worker as one client-streaming call and get one summary back

More examples

The contract: Protos/inventory.proto

The <code>stream</code> keyword on the reply makes <code>WatchStock</code> server streaming: the generated client method returns <code>AsyncServerStreamingCall&lt;StockChanged&gt;</code> and has no <code>Async</code> suffix, because the call starts immediately and you read as messages arrive. <code>stream</code> on the request makes <code>ImportStockCounts</code> client streaming. The client project generates its side with <code>&lt;Protobuf Include="Protos\inventory.proto" GrpcServices="Client" /&gt;</code>.

Example · protobuf
syntax = "proto3";

package shop.inventory.v1;

option csharp_namespace = "Shop.Inventory.V1";

import "google/protobuf/timestamp.proto";

service Inventory {
  rpc GetStock (StockRequest) returns (StockReply);
  rpc WatchStock (WatchStockRequest) returns (stream StockChanged);
  rpc ImportStockCounts (stream StockCount) returns (ImportSummary);
}

message StockRequest {
  string sku = 1;
}

message StockReply {
  string sku = 1;
  int32 available = 2;
  int32 reserved = 3;
}

message WatchStockRequest {
  string warehouse_code = 1;
}

message StockChanged {
  string sku = 1;
  int32 available = 2;
  int32 reserved = 3;
  google.protobuf.Timestamp changed_at = 4;
}

message StockCount {
  string warehouse_code = 1;
  string sku = 2;
  int32 quantity = 3;
}

message ImportSummary {
  int32 accepted = 1;
  int32 rejected = 2;
}

The streaming methods on the server

A server-streaming method returns <code>Task</code> and ends the stream by returning; the status trailers go out then. While it runs, it holds one of the connection's 100 stream slots and one service instance, so a feed that never ends must stop on cancellation. <code>context.CancellationToken</code> covers the client cancelling and the deadline, but on a graceful shutdown Kestrel waits for in-flight calls and aborts them only when <code>ShutdownTimeout</code> runs out, so link <code>ApplicationStopping</code> in or every rolling deploy waits for the full timeout. <code>IServerStreamWriter</code> is not thread-safe: if several producers feed one stream, put a <code>Channel</code> in front of it and let a single loop write. The client-streaming side reads with <code>ReadAllAsync</code> and replies once, after the client calls <code>CompleteAsync</code>.

Example · csharp
using Grpc.Core;
using Shop.Inventory.V1;

public interface IStockChanges
{
    IAsyncEnumerable<StockChanged> SubscribeAsync(string warehouseCode, CancellationToken cancellationToken);
}

public interface IStockCounts
{
    Task SetCountAsync(string warehouseCode, string sku, int quantity, CancellationToken cancellationToken);
}

public sealed class InventoryService(
    IStockChanges changes, IStockCounts counts, IHostApplicationLifetime lifetime, ILogger<InventoryService> logger)
    : Inventory.InventoryBase
{
    public override async Task WatchStock(
        WatchStockRequest request, IServerStreamWriter<StockChanged> responseStream, ServerCallContext context)
    {
        // context.CancellationToken fires when the client cancels or disposes the call or its deadline passes.
        // On shutdown it fires only after ShutdownTimeout, so link ApplicationStopping to end the feed promptly.
        using var stop = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, lifetime.ApplicationStopping);
        try
        {
            await foreach (var change in changes.SubscribeAsync(request.WarehouseCode, stop.Token))
            {
                // One write at a time: the writer throws if a second WriteAsync starts before the first finishes.
                await responseStream.WriteAsync(change, stop.Token);
            }
        }
        catch (OperationCanceledException) when (lifetime.ApplicationStopping.IsCancellationRequested)
        {
            // Returning ends the stream with status OK; the client reconnects to another instance.
        }
    }

    public override async Task<ImportSummary> ImportStockCounts(
        IAsyncStreamReader<StockCount> requestStream, ServerCallContext context)
    {
        var summary = new ImportSummary();
        await foreach (var count in requestStream.ReadAllAsync(context.CancellationToken))
        {
            if (string.IsNullOrWhiteSpace(count.Sku) || count.Quantity < 0)
            {
                summary.Rejected++;
                continue;
            }

            await counts.SetCountAsync(count.WarehouseCode, count.Sku, count.Quantity, context.CancellationToken);
            summary.Accepted++;
        }

        logger.LogInformation("Imported {Accepted} stock counts, rejected {Rejected}", summary.Accepted, summary.Rejected);
        return summary;
    }
}

Client streaming with client-side load balancing

A headless Service (<code>clusterIP: None</code>) returns one DNS record per ready pod. With the <code>dns</code> scheme and <code>RoundRobinConfig</code> the channel opens a connection to each and rotates calls. The default resolver re-resolves only when a connection fails, and a scale-out breaks no connection, so new pods would get no traffic from long-lived clients; registering a <code>DnsResolverFactory</code> with a 30-second <code>refreshInterval</code> through <code>ServiceProvider</code> picks them up. A maximum connection age on the server, which forces reconnects, works too. <code>ChannelCredentials.Insecure</code> is needed because a <code>dns:///</code> address carries no <code>http</code> or <code>https</code> to infer it from. Forgetting <code>CompleteAsync</code> leaves the server waiting for more messages until the deadline.

Example · csharp
using Grpc.Core;
using Grpc.Net.Client;
using Grpc.Net.Client.Balancer;
using Grpc.Net.Client.Configuration;
using Shop.Inventory.V1;

// By default DNS is re-resolved only after a connection fails; refresh it so scaled-out pods get traffic.
var services = new ServiceCollection();
services.AddSingleton<ResolverFactory>(new DnsResolverFactory(refreshInterval: TimeSpan.FromSeconds(30)));

// dns:/// resolves every pod IP behind a headless Service and spreads calls across them.
using var channel = GrpcChannel.ForAddress("dns:///inventory-grpc.shop.svc.cluster.local:8081", new GrpcChannelOptions
{
    Credentials = ChannelCredentials.Insecure,
    ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } },
    ServiceProvider = services.BuildServiceProvider()
});
var client = new Inventory.InventoryClient(channel);

(string Sku, int Quantity)[] counts =
[
    ("KETTLE-BLUE-1L", 140),
    ("MUG-WHITE-350", 820),
    ("TEAPOT-CAST-IRON", 36)
];

using var call = client.ImportStockCounts(deadline: DateTime.UtcNow.AddMinutes(2));
foreach (var (sku, quantity) in counts)
{
    await call.RequestStream.WriteAsync(new StockCount { WarehouseCode = "AMS-1", Sku = sku, Quantity = quantity });
}
await call.RequestStream.CompleteAsync();

var summary = await call;
Console.WriteLine($"Accepted {summary.Accepted}, rejected {summary.Rejected}");

Discussion

  • Be the first to comment on this lesson.