
Grpc
- 17 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with ai & agent building tasks.
About
grpc is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- grpc
- AI & Agent Building
- AI-coding skill
Grpc by the numbers
- 17 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #10,861 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill grpcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
gRPC for .NET
Trigger On
- building backend-to-backend RPC services or clients
- adding protobuf contracts, streaming calls, or interceptors
- deciding between gRPC, HTTP APIs, and SignalR
- optimizing gRPC performance, deadlines, cancellation, or connection reuse
- integrating service-to-service communication in microservices
Do Not Use For
- public browser-first APIs unless gRPC-Web limitations are explicitly acceptable
- SignalR hub design, realtime UI fan-out, or websocket-style client collaboration
- generic ASP.NET Core minimal APIs or REST controllers with no protobuf/RPC requirement
- non-.NET gRPC work unless the user asks for cross-stack contract guidance
Load References
- references/patterns.md for proto design, streaming implementations, interceptors, health checks, load balancing, and client factory setup.
- references/anti-patterns.md for common channel, deadline, streaming, message-size, and exception-handling mistakes.
Workflow
1. Validate the architecture fit before touching code.
- prefer gRPC for backend RPC, strong contracts, low-latency calls, or streaming
- prefer REST or minimal APIs for broad browser compatibility and loosely coupled public APIs
- prefer SignalR for browser/client realtime fan-out and UI collaboration
2. Treat .proto files as the source of truth.
- keep package names,
csharp_namespace, service names, and versioning deliberate - reserve removed field numbers and avoid reusing tags
- use wrapper types or explicit messages when optionality matters
3. Choose the RPC shape from the interaction model.
- unary for request/response
- server streaming for large or progressive result sets
- client streaming for uploads or batches
- bidirectional streaming for coordinated two-way flows
4. Wire server and client behavior together.
- register services with
AddGrpc - use
AddGrpcClientor long-livedGrpcChannelreuse - set deadlines and propagate cancellation
- convert domain failures to appropriate
RpcExceptionstatus codes
5. Add observability and resilience where the boundary justifies it.
- logging or exception interceptors
- OpenTelemetry traces and status-code metrics
- retry policy only for safe idempotent calls
6. Validate with the repo's normal build and tests, plus a focused smoke call when runnable.
Current Upstream Notes
dotnet/aspnetcorev9.0.17is servicing. Keep gRPC guidance focused on proto compatibility, streaming shape, deadlines, cancellation, channel reuse, and smoke calls.- After package servicing updates, regenerate protobuf outputs only when inputs or generator packages actually changed; do not churn generated files as a proxy for validation.
flowchart LR
A["RPC requirement"] --> B["proto contract"]
B --> C["server implementation"]
B --> D["client factory or channel"]
C --> E["deadlines / cancellation / status codes"]
D --> E
E --> F["build, tests, smoke call"]Examples
Use client factory for normal app integration:
builder.Services.AddGrpcClient<Greeter.GreeterClient>(options =>
{
options.Address = new Uri("https://localhost:5001");
});Always set a deadline and pass cancellation:
var response = await client.SayHelloAsync(
new HelloRequest { Name = name },
deadline: DateTime.UtcNow.AddSeconds(5),
cancellationToken: cancellationToken);For streaming, check cancellation inside the read/write loop and keep message sizes bounded. Load references/patterns.md before writing detailed streaming code.
Anti-Patterns
- creating a new
GrpcChannelper call - omitting deadlines and relying only on client-side cancellation
- ignoring
ServerCallContext.CancellationTokenin streaming handlers - sending large single messages instead of chunking or streaming
- using gRPC as the default public browser API
- swallowing exceptions inside interceptors
- retrying non-idempotent calls without explicit policy
Deliver
- stable protobuf contracts and generated-code ownership
- service and client code that match the RPC shape
- explicit deadline, cancellation, retry, and status-code behavior
- tests or smoke checks for serialization and call behavior
- documentation of browser, transport, or deployment constraints when relevant
Validate
dotnet buildsucceeds after contract or generated-code changes- tests or smoke checks exercise at least one server/client call
- streaming methods respect cancellation and bounded message sizes
- channels are reused through client factory or a long-lived channel
- status-code handling is intentional and observable
- browser constraints are documented if gRPC-Web is involved
{
"version": "1.0.1",
"category": "Web",
"package_prefix": "Grpc"
}
gRPC Anti-Patterns Reference
This document catalogs common mistakes when building gRPC services and clients in .NET, with explanations and corrections.
Connection and Channel Anti-Patterns
Creating a New Channel Per Call
// WRONG: Creating channel per call
public async Task<string> GetGreetingAsync(string name)
{
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(new HelloRequest { Name = name });
return reply.Message;
}Why it's bad:
- HTTP/2 connection establishment is expensive (TLS handshake, SETTINGS exchange)
- Prevents connection pooling and multiplexing
- Kills performance under load
- Causes connection churn on the server
Correct approach:
// CORRECT: Use client factory for channel reuse
builder.Services.AddGrpcClient<Greeter.GreeterClient>(options =>
{
options.Address = new Uri("https://localhost:5001");
});
// Or manage singleton channel manually
public class GrpcClientService
{
private static readonly GrpcChannel _channel =
GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30)
}
});
private readonly Greeter.GreeterClient _client = new(_channel);
public Task<HelloReply> SayHelloAsync(HelloRequest request) =>
_client.SayHelloAsync(request).ResponseAsync;
}Disposing Channel After Every Call
// WRONG: Disposing channel immediately
public async Task DoWorkAsync()
{
var channel = GrpcChannel.ForAddress("https://localhost:5001");
try
{
var client = new Greeter.GreeterClient(channel);
await client.SayHelloAsync(new HelloRequest { Name = "World" });
}
finally
{
await channel.ShutdownAsync(); // Kills connection reuse
}
}Why it's bad:
- ShutdownAsync closes all HTTP/2 connections
- Subsequent calls must re-establish connections
- Negates HTTP/2 multiplexing benefits
Correct approach:
// CORRECT: Channel lives for application lifetime
public class ChannelManager : IAsyncDisposable
{
private readonly GrpcChannel _channel;
public ChannelManager(string address)
{
_channel = GrpcChannel.ForAddress(address);
}
public GrpcChannel Channel => _channel;
public async ValueTask DisposeAsync()
{
await _channel.ShutdownAsync();
}
}
// Register as singleton
builder.Services.AddSingleton(sp =>
new ChannelManager("https://localhost:5001"));Deadline and Timeout Anti-Patterns
Missing Deadlines on Client Calls
// WRONG: No deadline set
var response = await client.ProcessOrderAsync(new OrderRequest { OrderId = orderId });Why it's bad:
- Calls can hang indefinitely if server is slow or network fails
- No way to enforce timeout at the gRPC level
- Can cause thread/connection exhaustion under load
Correct approach:
// CORRECT: Always set a deadline
var deadline = DateTime.UtcNow.AddSeconds(10);
var response = await client.ProcessOrderAsync(
new OrderRequest { OrderId = orderId },
deadline: deadline);
// Or use CallOptions for more control
var options = new CallOptions(deadline: DateTime.UtcNow.AddSeconds(10));
var response = await client.ProcessOrderAsync(new OrderRequest { OrderId = orderId }, options);Using CancellationToken Instead of Deadline
// WRONG: CancellationToken alone doesn't signal the server
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await client.ProcessOrderAsync(request, cancellationToken: cts.Token);Why it's bad:
- CancellationToken only cancels client-side
- Server continues processing, wasting resources
- Deadline is transmitted to server, CancellationToken is not
Correct approach:
// CORRECT: Use deadline AND cancellation token
var deadline = DateTime.UtcNow.AddSeconds(10);
var response = await client.ProcessOrderAsync(
request,
deadline: deadline,
cancellationToken: cancellationToken);Mismatched Client/Server Deadlines
// WRONG: Server deadline longer than client
// Client sets 5 second deadline
var deadline = DateTime.UtcNow.AddSeconds(5);
var response = await client.ProcessAsync(request, deadline: deadline);
// Server handler takes up to 30 seconds
public override async Task<Response> Process(Request request, ServerCallContext context)
{
await LongRunningOperation(TimeSpan.FromSeconds(30)); // Client already timed out
return new Response();
}Why it's bad:
- Server wastes resources after client has given up
- Results are computed but never delivered
- No coordinated timeout behavior
Correct approach:
// CORRECT: Server respects incoming deadline
public override async Task<Response> Process(Request request, ServerCallContext context)
{
// Check remaining time before expensive operations
var remaining = context.Deadline - DateTime.UtcNow;
if (remaining < TimeSpan.FromSeconds(1))
{
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "Insufficient time"));
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
cts.CancelAfter(remaining - TimeSpan.FromMilliseconds(100)); // Buffer for response
await ProcessWithTimeoutAsync(request, cts.Token);
return new Response();
}Streaming Anti-Patterns
Ignoring Cancellation in Streaming Handlers
// WRONG: Not checking cancellation
public override async Task StreamData(
DataRequest request,
IServerStreamWriter<DataChunk> responseStream,
ServerCallContext context)
{
var items = await GetAllItemsAsync(); // Could be millions
foreach (var item in items)
{
await responseStream.WriteAsync(item); // Continues even if client disconnects
}
}Why it's bad:
- Server continues processing after client disconnects
- Wastes CPU, memory, and network resources
- Can cause resource exhaustion under load
Correct approach:
// CORRECT: Check cancellation regularly
public override async Task StreamData(
DataRequest request,
IServerStreamWriter<DataChunk> responseStream,
ServerCallContext context)
{
await foreach (var item in GetItemsAsync(context.CancellationToken))
{
context.CancellationToken.ThrowIfCancellationRequested();
await responseStream.WriteAsync(item, context.CancellationToken);
}
}Not Completing Client Streams
// WRONG: Forgetting to complete the request stream
public async Task UploadDataAsync(IEnumerable<DataChunk> chunks)
{
using var call = _client.UploadData();
foreach (var chunk in chunks)
{
await call.RequestStream.WriteAsync(chunk);
}
// Missing: await call.RequestStream.CompleteAsync();
var response = await call.ResponseAsync; // Hangs forever
}Why it's bad:
- Server waits indefinitely for more messages
- Call never completes
- Resources are held until timeout
Correct approach:
// CORRECT: Always complete client streams
public async Task UploadDataAsync(IEnumerable<DataChunk> chunks)
{
using var call = _client.UploadData();
foreach (var chunk in chunks)
{
await call.RequestStream.WriteAsync(chunk);
}
await call.RequestStream.CompleteAsync(); // Signal end of stream
var response = await call.ResponseAsync;
}Blocking on Bidirectional Streams
// WRONG: Sequential read/write in bidirectional stream
public async Task ChatAsync()
{
using var call = _client.Chat();
while (true)
{
var message = await GetNextMessageAsync();
await call.RequestStream.WriteAsync(message);
// Blocks until response arrives - can't send another message until then
var response = await call.ResponseStream.MoveNext();
}
}Why it's bad:
- Serializes what should be concurrent operations
- Loses bidirectional streaming benefits
- Can deadlock if server expects multiple requests before responding
Correct approach:
// CORRECT: Concurrent read and write
public async Task ChatAsync(CancellationToken ct)
{
using var call = _client.Chat();
var readTask = Task.Run(async () =>
{
await foreach (var response in call.ResponseStream.ReadAllAsync(ct))
{
ProcessResponse(response);
}
}, ct);
var writeTask = Task.Run(async () =>
{
await foreach (var message in GetMessagesAsync(ct))
{
await call.RequestStream.WriteAsync(message);
}
await call.RequestStream.CompleteAsync();
}, ct);
await Task.WhenAll(readTask, writeTask);
}Message Size Anti-Patterns
Sending Large Messages
// WRONG: Sending multi-MB payloads
message FileUploadRequest {
bytes content = 1; // Could be hundreds of MB
string filename = 2;
}
public async Task UploadFileAsync(byte[] fileContent)
{
await _client.UploadFileAsync(new FileUploadRequest
{
Content = ByteString.CopyFrom(fileContent), // LOH allocation
Filename = "large-file.zip"
});
}Why it's bad:
- Large messages cause Large Object Heap allocations
- Memory fragmentation and GC pressure
- Can exceed default message size limits (4MB)
- Single-request timeout affects entire transfer
Correct approach:
// CORRECT: Use streaming for large data
service FileService {
rpc UploadFile (stream FileChunk) returns (UploadResponse);
}
message FileChunk {
oneof data {
FileMetadata metadata = 1;
bytes chunk = 2;
}
}
public async Task UploadFileAsync(string filePath, CancellationToken ct)
{
using var call = _client.UploadFile();
// Send metadata first
await call.RequestStream.WriteAsync(new FileChunk
{
Metadata = new FileMetadata { Filename = Path.GetFileName(filePath) }
});
// Stream file in chunks
await using var stream = File.OpenRead(filePath);
var buffer = new byte[64 * 1024]; // 64KB chunks
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0)
{
await call.RequestStream.WriteAsync(new FileChunk
{
Chunk = ByteString.CopyFrom(buffer, 0, bytesRead)
});
}
await call.RequestStream.CompleteAsync();
var response = await call.ResponseAsync;
}Error Handling Anti-Patterns
Swallowing Exceptions
// WRONG: Swallowing exceptions returns OK status
public override async Task<OrderResponse> CreateOrder(OrderRequest request, ServerCallContext context)
{
try
{
await _repository.CreateOrderAsync(request);
return new OrderResponse { Success = true };
}
catch (Exception ex)
{
_logger.LogError(ex, "Order creation failed");
return new OrderResponse { Success = false }; // Client sees OK status
}
}Why it's bad:
- Client receives OK status despite failure
- Error details lost in response message
- Client retry logic doesn't trigger
- Breaks gRPC error handling conventions
Correct approach:
// CORRECT: Convert exceptions to appropriate status codes
public override async Task<OrderResponse> CreateOrder(OrderRequest request, ServerCallContext context)
{
try
{
await _repository.CreateOrderAsync(request);
return new OrderResponse { Success = true };
}
catch (ValidationException ex)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message));
}
catch (DuplicateOrderException ex)
{
throw new RpcException(new Status(StatusCode.AlreadyExists, ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "Order creation failed");
throw new RpcException(new Status(StatusCode.Internal, "An error occurred"));
}
}Using Wrong Status Codes
// WRONG: Using Internal for everything
catch (KeyNotFoundException)
{
throw new RpcException(new Status(StatusCode.Internal, "Not found")); // Should be NotFound
}
catch (UnauthorizedAccessException)
{
throw new RpcException(new Status(StatusCode.Internal, "Access denied")); // Should be PermissionDenied
}
catch (ArgumentException)
{
throw new RpcException(new Status(StatusCode.Internal, "Bad argument")); // Should be InvalidArgument
}Why it's bad:
- Clients can't distinguish error types
- Retry policies may retry non-retryable errors
- Metrics and monitoring lose granularity
Correct approach:
// CORRECT: Map to appropriate gRPC status codes
public static class ExceptionMapping
{
public static RpcException ToRpcException(Exception ex) => ex switch
{
ArgumentException e => new RpcException(new Status(StatusCode.InvalidArgument, e.Message)),
KeyNotFoundException e => new RpcException(new Status(StatusCode.NotFound, e.Message)),
UnauthorizedAccessException e => new RpcException(new Status(StatusCode.PermissionDenied, e.Message)),
InvalidOperationException e => new RpcException(new Status(StatusCode.FailedPrecondition, e.Message)),
NotImplementedException e => new RpcException(new Status(StatusCode.Unimplemented, e.Message)),
OperationCanceledException e => new RpcException(new Status(StatusCode.Cancelled, e.Message)),
TimeoutException e => new RpcException(new Status(StatusCode.DeadlineExceeded, e.Message)),
_ => new RpcException(new Status(StatusCode.Internal, "An internal error occurred"))
};
}Leaking Sensitive Information in Error Details
// WRONG: Including stack trace and internal details
catch (Exception ex)
{
throw new RpcException(new Status(
StatusCode.Internal,
$"Failed: {ex.Message}\n{ex.StackTrace}\nConnection: {_connectionString}"));
}Why it's bad:
- Exposes internal implementation details
- May leak sensitive data (connection strings, paths)
- Security vulnerability
Correct approach:
// CORRECT: Log details server-side, return safe message
catch (Exception ex)
{
var errorId = Guid.NewGuid();
_logger.LogError(ex, "Error {ErrorId}: {Message}", errorId, ex.Message);
var message = _environment.IsDevelopment()
? ex.Message
: $"An error occurred. Reference: {errorId}";
throw new RpcException(new Status(StatusCode.Internal, message));
}Interceptor Anti-Patterns
Blocking in Interceptors
// WRONG: Synchronous blocking in async interceptor
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
// Blocking call - ties up thread pool
var token = _tokenService.GetTokenAsync().Result; // NEVER do this
var headers = context.Options.Headers ?? new Metadata();
headers.Add("authorization", $"Bearer {token}");
return continuation(request, context);
}Why it's bad:
- Blocks thread pool threads
- Can cause thread pool starvation
- Degrades application throughput
Correct approach:
// CORRECT: Handle async properly in interceptor
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
// Get token synchronously if possible, or wrap the response
var call = continuation(request, context);
return new AsyncUnaryCall<TResponse>(
AddAuthAndCallAsync(request, context, call.ResponseAsync),
call.ResponseHeadersAsync,
call.GetStatus,
call.GetTrailers,
call.Dispose);
}
private async Task<TResponse> AddAuthAndCallAsync<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
Task<TResponse> responseTask)
{
// Can await safely here
var token = await _tokenService.GetTokenAsync();
// Note: Headers are already sent at this point, so this pattern
// requires restructuring to modify context before continuation
return await responseTask;
}Incomplete Interceptor Implementation
// WRONG: Only implementing UnaryServerHandler
public class AuthInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthAsync(context);
return await continuation(request, context);
}
// Missing: ServerStreamingServerHandler, ClientStreamingServerHandler,
// DuplexStreamingServerHandler - streaming calls bypass auth!
}Why it's bad:
- Streaming calls bypass the interceptor logic
- Security holes in streaming endpoints
- Inconsistent behavior
Correct approach:
// CORRECT: Implement all relevant interceptor methods
public class AuthInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthAsync(context);
return await continuation(request, context);
}
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthAsync(context);
await continuation(request, responseStream, context);
}
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthAsync(context);
return await continuation(requestStream, context);
}
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthAsync(context);
await continuation(requestStream, responseStream, context);
}
}Proto Design Anti-Patterns
Using Primitives for Optional Fields
// WRONG: Primitives have default values, not null
message SearchRequest {
int32 page_size = 1; // 0 means unset OR 0?
bool include_deleted = 2; // false means unset OR false?
}Why it's bad:
- Can't distinguish "not set" from "set to default value"
- Forces awkward conventions (e.g., -1 means unset)
- Proto3 removed "required" and made all fields optional with defaults
Correct approach:
// CORRECT: Use wrapper types for optional primitives
import "google/protobuf/wrappers.proto";
message SearchRequest {
google.protobuf.Int32Value page_size = 1; // null means unset
google.protobuf.BoolValue include_deleted = 2; // null means unset
}
// Or use explicit presence with optional keyword (proto3 optional)
message SearchRequest {
optional int32 page_size = 1;
optional bool include_deleted = 2;
}Reusing Field Numbers
// VERSION 1
message User {
string id = 1;
string email = 2;
string phone = 3; // Later removed
}
// VERSION 2 - WRONG
message User {
string id = 1;
string email = 2;
string address = 3; // Reused field 3 - breaks wire compatibility
}Why it's bad:
- Old clients deserialize address bytes as phone string
- Silent data corruption
- Breaks backward compatibility
Correct approach:
// VERSION 2 - CORRECT
message User {
string id = 1;
string email = 2;
reserved 3;
reserved "phone";
string address = 4; // New field number
}Flat Request Messages
// WRONG: Flat request makes evolution difficult
service OrderService {
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
}
message CreateOrderRequest {
string customer_id = 1;
string product_id = 2;
int32 quantity = 3;
string shipping_street = 4;
string shipping_city = 5;
string shipping_country = 6;
// Adding billing address requires many new fields
}Why it's bad:
- Hard to add related groups of fields
- No reuse across messages
- Field explosion over time
Correct approach:
// CORRECT: Nested messages for structured data
message CreateOrderRequest {
string customer_id = 1;
OrderDetails order = 2;
Address shipping_address = 3;
Address billing_address = 4; // Easy to add
}
message OrderDetails {
repeated OrderItem items = 1;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
}
message Address {
string street = 1;
string city = 2;
string country = 3;
string postal_code = 4;
}Configuration Anti-Patterns
Not Configuring HTTP/2 Limits
// WRONG: Using defaults that may not match your workload
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(); // Default limitsWhy it's bad:
- Default stream limits may be too low for your traffic
- Window sizes may cause unnecessary backpressure
- Keep-alive not configured for long-lived connections
Correct approach:
// CORRECT: Configure HTTP/2 settings appropriately
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.Http2.MaxStreamsPerConnection = 250;
options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024; // 1MB
options.Limits.Http2.InitialStreamWindowSize = 768 * 1024; // 768KB
options.Limits.Http2.KeepAlivePingDelay = TimeSpan.FromSeconds(30);
options.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
});Ignoring Keep-Alive Configuration
// WRONG: No keep-alive configuration
var channel = GrpcChannel.ForAddress("https://server:5001");Why it's bad:
- Connections may be silently closed by proxies/load balancers
- First call after idle period fails
- No detection of dead connections
Correct approach:
// CORRECT: Configure keep-alive
var channel = GrpcChannel.ForAddress("https://server:5001", new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
EnableMultipleHttp2Connections = true
}
});Testing Anti-Patterns
Testing with Real Network Calls
// WRONG: Integration test depends on real server
[Fact]
public async Task CreateOrder_WithValidData_ReturnsSuccess()
{
var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new Orders.OrdersClient(channel);
var response = await client.CreateOrderAsync(new CreateOrderRequest
{
CustomerId = "test-customer"
});
Assert.True(response.Success);
}Why it's bad:
- Tests depend on external server
- Flaky due to network issues
- Slow test execution
- Can't test edge cases easily
Correct approach:
// CORRECT: Use TestServer or mocks
[Fact]
public async Task CreateOrder_WithValidData_ReturnsSuccess()
{
await using var factory = new WebApplicationFactory<Program>();
using var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
{
HttpHandler = factory.Server.CreateHandler()
});
var client = new Orders.OrdersClient(channel);
var response = await client.CreateOrderAsync(new CreateOrderRequest
{
CustomerId = "test-customer"
});
Assert.True(response.Success);
}
// Or use Moq with generated client interface
[Fact]
public async Task ProcessOrder_CallsService()
{
var mockClient = new Mock<IOrdersClient>();
mockClient
.Setup(x => x.CreateOrderAsync(It.IsAny<CreateOrderRequest>(), null, null, default))
.Returns(new AsyncUnaryCall<CreateOrderResponse>(
Task.FromResult(new CreateOrderResponse { Success = true }),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => new Metadata(),
() => { }));
var service = new OrderProcessor(mockClient.Object);
await service.ProcessAsync();
mockClient.Verify(x => x.CreateOrderAsync(
It.Is<CreateOrderRequest>(r => r.CustomerId == "expected"),
null, null, default), Times.Once);
}gRPC Patterns Reference
This document provides detailed patterns for gRPC services in .NET, covering proto design, streaming implementations, and interceptor patterns.
Proto File Patterns
Service Definition Best Practices
syntax = "proto3";
package mycompany.orders.v1;
option csharp_namespace = "MyCompany.Orders.V1";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/empty.proto";
// Version services explicitly for breaking changes
service OrderService {
// Unary RPC for simple request-response
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
// Server streaming for large result sets
rpc ListOrders (ListOrdersRequest) returns (stream OrderItem);
// Client streaming for batch uploads
rpc UploadOrders (stream CreateOrderRequest) returns (UploadOrdersResponse);
// Bidirectional streaming for real-time sync
rpc SyncOrders (stream OrderSyncMessage) returns (stream OrderSyncMessage);
}Message Design Patterns
// Use wrapper types for optional primitives
message OrderFilter {
google.protobuf.StringValue customer_id = 1;
google.protobuf.Int32Value min_quantity = 2;
google.protobuf.BoolValue is_active = 3;
}
// Wrap request fields in objects for extensibility
message CreateOrderRequest {
OrderDetails order = 1;
RequestMetadata metadata = 2;
}
message OrderDetails {
string customer_id = 1;
repeated OrderLineItem items = 2;
ShippingAddress address = 3;
}
// Use oneof for mutually exclusive fields
message PaymentMethod {
oneof method {
CreditCard credit_card = 1;
BankTransfer bank_transfer = 2;
DigitalWallet digital_wallet = 3;
}
}
// Reserve removed field numbers to prevent reuse
message Order {
string id = 1;
string customer_id = 2;
// Field 3 was deprecated_field
reserved 3;
reserved "deprecated_field";
OrderStatus status = 4;
}
// Use enums with explicit zero value as unknown/default
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELLED = 5;
}Pagination Pattern
message ListOrdersRequest {
int32 page_size = 1;
string page_token = 2;
OrderFilter filter = 3;
}
message ListOrdersResponse {
repeated Order orders = 1;
string next_page_token = 2;
int32 total_count = 3;
}Streaming Patterns
Server Streaming with Backpressure
public override async Task StreamOrders(
ListOrdersRequest request,
IServerStreamWriter<Order> responseStream,
ServerCallContext context)
{
var batchSize = 100;
var lastId = string.Empty;
while (!context.CancellationToken.IsCancellationRequested)
{
var orders = await _repository.GetOrdersBatchAsync(
lastId,
batchSize,
context.CancellationToken);
if (orders.Count == 0)
break;
foreach (var order in orders)
{
// WriteAsync handles backpressure automatically
await responseStream.WriteAsync(order, context.CancellationToken);
lastId = order.Id;
}
// Optional: yield control to prevent starvation
await Task.Yield();
}
}Client Streaming with Batching
public override async Task<UploadOrdersResponse> UploadOrders(
IAsyncStreamReader<CreateOrderRequest> requestStream,
ServerCallContext context)
{
var batch = new List<CreateOrderRequest>();
var batchSize = 100;
var totalProcessed = 0;
var errors = new List<string>();
await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
{
batch.Add(request);
if (batch.Count >= batchSize)
{
var result = await ProcessBatchAsync(batch, context.CancellationToken);
totalProcessed += result.SuccessCount;
errors.AddRange(result.Errors);
batch.Clear();
}
}
// Process remaining items
if (batch.Count > 0)
{
var result = await ProcessBatchAsync(batch, context.CancellationToken);
totalProcessed += result.SuccessCount;
errors.AddRange(result.Errors);
}
return new UploadOrdersResponse
{
ProcessedCount = totalProcessed,
ErrorCount = errors.Count,
Errors = { errors.Take(10) }
};
}Bidirectional Streaming with Concurrent Processing
public override async Task SyncOrders(
IAsyncStreamReader<OrderSyncMessage> requestStream,
IServerStreamWriter<OrderSyncMessage> responseStream,
ServerCallContext context)
{
var pendingResponses = Channel.CreateUnbounded<OrderSyncMessage>();
// Writer task - sends responses as they become available
var writerTask = Task.Run(async () =>
{
await foreach (var response in pendingResponses.Reader.ReadAllAsync(context.CancellationToken))
{
await responseStream.WriteAsync(response, context.CancellationToken);
}
}, context.CancellationToken);
// Reader task - processes incoming messages concurrently
try
{
await foreach (var message in requestStream.ReadAllAsync(context.CancellationToken))
{
// Process each message asynchronously
_ = ProcessAndQueueResponseAsync(message, pendingResponses.Writer, context.CancellationToken);
}
}
finally
{
pendingResponses.Writer.Complete();
await writerTask;
}
}
private async Task ProcessAndQueueResponseAsync(
OrderSyncMessage message,
ChannelWriter<OrderSyncMessage> writer,
CancellationToken ct)
{
try
{
var response = await ProcessSyncMessageAsync(message, ct);
await writer.WriteAsync(response, ct);
}
catch (Exception ex)
{
await writer.WriteAsync(new OrderSyncMessage
{
CorrelationId = message.CorrelationId,
Error = ex.Message
}, ct);
}
}Streaming with Heartbeats
public override async Task Subscribe(
SubscribeRequest request,
IServerStreamWriter<Event> responseStream,
ServerCallContext context)
{
var subscription = await _eventBus.SubscribeAsync(request.Topic, context.CancellationToken);
var heartbeatInterval = TimeSpan.FromSeconds(30);
var lastActivity = DateTime.UtcNow;
using var heartbeatTimer = new PeriodicTimer(heartbeatInterval);
// Start heartbeat task
var heartbeatTask = Task.Run(async () =>
{
while (await heartbeatTimer.WaitForNextTickAsync(context.CancellationToken))
{
if (DateTime.UtcNow - lastActivity > heartbeatInterval)
{
await responseStream.WriteAsync(new Event { IsHeartbeat = true }, context.CancellationToken);
}
}
}, context.CancellationToken);
try
{
await foreach (var evt in subscription.ReadAllAsync(context.CancellationToken))
{
await responseStream.WriteAsync(evt, context.CancellationToken);
lastActivity = DateTime.UtcNow;
}
}
finally
{
await heartbeatTask;
}
}Interceptor Patterns
Authentication Interceptor (Server)
public class AuthenticationInterceptor : Interceptor
{
private readonly ITokenValidator _tokenValidator;
private readonly ILogger<AuthenticationInterceptor> _logger;
// Methods that don't require authentication
private static readonly HashSet<string> AnonymousMethods = new(StringComparer.OrdinalIgnoreCase)
{
"/grpc.health.v1.Health/Check",
"/mycompany.auth.v1.AuthService/Login"
};
public AuthenticationInterceptor(
ITokenValidator tokenValidator,
ILogger<AuthenticationInterceptor> logger)
{
_tokenValidator = tokenValidator;
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthenticationAsync(context);
return await continuation(request, context);
}
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
await ValidateAuthenticationAsync(context);
await continuation(request, responseStream, context);
}
private async Task ValidateAuthenticationAsync(ServerCallContext context)
{
if (AnonymousMethods.Contains(context.Method))
return;
var authHeader = context.RequestHeaders.GetValue("authorization");
if (string.IsNullOrEmpty(authHeader))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing authorization header"));
}
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid authorization scheme"));
}
var token = authHeader.Substring(7);
var principal = await _tokenValidator.ValidateTokenAsync(token, context.CancellationToken);
if (principal == null)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired token"));
}
// Store principal for downstream use
context.UserState["ClaimsPrincipal"] = principal;
}
}Authentication Interceptor (Client)
public class ClientAuthInterceptor : Interceptor
{
private readonly ITokenProvider _tokenProvider;
public ClientAuthInterceptor(ITokenProvider tokenProvider)
{
_tokenProvider = tokenProvider;
}
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var newContext = AddAuthHeader(context);
return continuation(request, newContext);
}
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation)
{
var newContext = AddAuthHeader(context);
return continuation(request, newContext);
}
private ClientInterceptorContext<TRequest, TResponse> AddAuthHeader<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context)
where TRequest : class
where TResponse : class
{
var token = _tokenProvider.GetToken();
if (string.IsNullOrEmpty(token))
return context;
var headers = context.Options.Headers ?? new Metadata();
headers.Add("authorization", $"Bearer {token}");
return new ClientInterceptorContext<TRequest, TResponse>(
context.Method,
context.Host,
context.Options.WithHeaders(headers));
}
}Retry Interceptor with Circuit Breaker
public class RetryInterceptor : Interceptor
{
private readonly ILogger<RetryInterceptor> _logger;
private readonly RetryPolicy _policy;
private static readonly HashSet<StatusCode> RetryableStatusCodes = new()
{
StatusCode.Unavailable,
StatusCode.Aborted,
StatusCode.DeadlineExceeded,
StatusCode.ResourceExhausted
};
public RetryInterceptor(ILogger<RetryInterceptor> logger, RetryPolicy policy)
{
_logger = logger;
_policy = policy;
}
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var call = continuation(request, context);
return new AsyncUnaryCall<TResponse>(
RetryAsync(call.ResponseAsync, request, context, continuation),
call.ResponseHeadersAsync,
call.GetStatus,
call.GetTrailers,
call.Dispose);
}
private async Task<TResponse> RetryAsync<TRequest, TResponse>(
Task<TResponse> responseTask,
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
where TRequest : class
where TResponse : class
{
var attempt = 0;
var delay = _policy.InitialBackoff;
while (true)
{
try
{
return await responseTask;
}
catch (RpcException ex) when (ShouldRetry(ex, attempt))
{
attempt++;
_logger.LogWarning(
"Retry attempt {Attempt} for {Method} after {Status}",
attempt, context.Method.FullName, ex.StatusCode);
await Task.Delay(delay);
delay = TimeSpan.FromTicks(Math.Min(
(long)(delay.Ticks * _policy.BackoffMultiplier),
_policy.MaxBackoff.Ticks));
var call = continuation(request, context);
responseTask = call.ResponseAsync;
}
}
}
private bool ShouldRetry(RpcException ex, int attempt)
{
return attempt < _policy.MaxAttempts
&& RetryableStatusCodes.Contains(ex.StatusCode);
}
}
public record RetryPolicy(
int MaxAttempts = 3,
TimeSpan InitialBackoff = default,
TimeSpan MaxBackoff = default,
double BackoffMultiplier = 2.0)
{
public TimeSpan InitialBackoff { get; init; } = InitialBackoff == default
? TimeSpan.FromMilliseconds(100)
: InitialBackoff;
public TimeSpan MaxBackoff { get; init; } = MaxBackoff == default
? TimeSpan.FromSeconds(5)
: MaxBackoff;
}Validation Interceptor
public class ValidationInterceptor : Interceptor
{
private readonly IServiceProvider _serviceProvider;
public ValidationInterceptor(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await ValidateRequestAsync(request, context.CancellationToken);
return await continuation(request, context);
}
private async Task ValidateRequestAsync<TRequest>(TRequest request, CancellationToken ct)
{
var validator = _serviceProvider.GetService<IValidator<TRequest>>();
if (validator == null)
return;
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
var errors = string.Join("; ", result.Errors.Select(e => e.ErrorMessage));
throw new RpcException(new Status(StatusCode.InvalidArgument, errors));
}
}
}Metrics Interceptor
public class MetricsInterceptor : Interceptor
{
private readonly IMeterFactory _meterFactory;
private readonly Histogram<double> _requestDuration;
private readonly Counter<long> _requestCount;
public MetricsInterceptor(IMeterFactory meterFactory)
{
_meterFactory = meterFactory;
var meter = _meterFactory.Create("GrpcServer");
_requestDuration = meter.CreateHistogram<double>(
"grpc.server.request.duration",
unit: "ms",
description: "Duration of gRPC requests");
_requestCount = meter.CreateCounter<long>(
"grpc.server.request.count",
description: "Number of gRPC requests");
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var sw = Stopwatch.StartNew();
var status = StatusCode.OK;
try
{
return await continuation(request, context);
}
catch (RpcException ex)
{
status = ex.StatusCode;
throw;
}
finally
{
sw.Stop();
RecordMetrics(context.Method, status, sw.Elapsed.TotalMilliseconds);
}
}
private void RecordMetrics(string method, StatusCode status, double duration)
{
var tags = new TagList
{
{ "grpc.method", method },
{ "grpc.status_code", status.ToString() }
};
_requestDuration.Record(duration, tags);
_requestCount.Add(1, tags);
}
}Deadline Propagation
public class DeadlinePropagationInterceptor : Interceptor
{
private readonly TimeSpan _bufferTime = TimeSpan.FromMilliseconds(100);
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var newContext = PropagateDeadline(context);
return continuation(request, newContext);
}
private ClientInterceptorContext<TRequest, TResponse> PropagateDeadline<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context)
where TRequest : class
where TResponse : class
{
// Check if there's an incoming deadline from the current call context
if (ServerCallContext.Current?.Deadline is { } incomingDeadline)
{
var remaining = incomingDeadline - DateTime.UtcNow;
// Apply buffer to leave time for response processing
var adjustedRemaining = remaining - _bufferTime;
if (adjustedRemaining > TimeSpan.Zero)
{
var newOptions = context.Options.WithDeadline(DateTime.UtcNow + adjustedRemaining);
return new ClientInterceptorContext<TRequest, TResponse>(
context.Method,
context.Host,
newOptions);
}
}
return context;
}
}Health Check Implementation
public class HealthServiceImpl : Health.HealthBase
{
private readonly IEnumerable<IHealthCheck> _healthChecks;
private readonly ConcurrentDictionary<string, HealthCheckResponse.Types.ServingStatus> _statusMap = new();
public HealthServiceImpl(IEnumerable<IHealthCheck> healthChecks)
{
_healthChecks = healthChecks;
}
public override async Task<HealthCheckResponse> Check(
HealthCheckRequest request,
ServerCallContext context)
{
var service = request.Service;
if (string.IsNullOrEmpty(service))
{
// Overall health check
var allHealthy = await CheckAllServicesAsync(context.CancellationToken);
return new HealthCheckResponse
{
Status = allHealthy
? HealthCheckResponse.Types.ServingStatus.Serving
: HealthCheckResponse.Types.ServingStatus.NotServing
};
}
if (_statusMap.TryGetValue(service, out var status))
{
return new HealthCheckResponse { Status = status };
}
throw new RpcException(new Status(StatusCode.NotFound, $"Service '{service}' not found"));
}
public override async Task Watch(
HealthCheckRequest request,
IServerStreamWriter<HealthCheckResponse> responseStream,
ServerCallContext context)
{
var lastStatus = HealthCheckResponse.Types.ServingStatus.Unknown;
while (!context.CancellationToken.IsCancellationRequested)
{
var currentStatus = await GetServiceStatusAsync(request.Service, context.CancellationToken);
if (currentStatus != lastStatus)
{
await responseStream.WriteAsync(new HealthCheckResponse { Status = currentStatus });
lastStatus = currentStatus;
}
await Task.Delay(TimeSpan.FromSeconds(5), context.CancellationToken);
}
}
private async Task<bool> CheckAllServicesAsync(CancellationToken ct)
{
foreach (var check in _healthChecks)
{
var result = await check.CheckHealthAsync(new HealthCheckContext(), ct);
if (result.Status != HealthStatus.Healthy)
return false;
}
return true;
}
private async Task<HealthCheckResponse.Types.ServingStatus> GetServiceStatusAsync(
string service,
CancellationToken ct)
{
// Implementation depends on service-specific health checks
return HealthCheckResponse.Types.ServingStatus.Serving;
}
}Load Balancing Client Configuration
// Configure DNS-based load balancing
var channel = GrpcChannel.ForAddress("dns:///my-service.example.com", new GrpcChannelOptions
{
ServiceConfig = new ServiceConfig
{
LoadBalancingConfigs = { new RoundRobinConfig() },
MethodConfigs =
{
new MethodConfig
{
Names = { MethodName.Default },
RetryPolicy = new RetryPolicy
{
MaxAttempts = 3,
InitialBackoff = TimeSpan.FromMilliseconds(100),
MaxBackoff = TimeSpan.FromSeconds(2),
BackoffMultiplier = 1.5,
RetryableStatusCodes = { StatusCode.Unavailable }
}
}
}
},
Credentials = ChannelCredentials.SecureSsl
});Compression Configuration
// Server-side compression
builder.Services.AddGrpc(options =>
{
options.ResponseCompressionAlgorithm = "gzip";
options.ResponseCompressionLevel = CompressionLevel.Optimal;
options.CompressionProviders = new List<ICompressionProvider>
{
new GzipCompressionProvider(CompressionLevel.Optimal)
};
});
// Client-side compression
var callOptions = new CallOptions(
headers: new Metadata
{
{ "grpc-accept-encoding", "gzip" }
},
writeOptions: new WriteOptions(WriteFlags.NoCompress));