Claude
Skill
dotnet-grpc
Build or review gRPC services and clients in .NET with correct contract-first design, streaming behavior, transport assumptions, and backend service integration.
Virus-scanned
Reviewed automatically before listing.
Download
postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-grpc-bfa4ebd.zip · 17 KB
Install
skills CLI
npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-grpc
Claude Code
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git
git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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 and connection management
- implementing service-to-service communication in microservices
Documentation
- gRPC on .NET Overview
- Performance Best Practices with gRPC
- gRPC Client Factory
- gRPC Interceptors
- Call gRPC Services with .NET Client
References
- patterns.md - Detailed proto patterns, streaming implementations, interceptors, health checks, and load balancing
- anti-patterns.md - Common gRPC mistakes with explanations and corrections
Workflow
- Use gRPC where low-latency backend communication, strong contracts, or streaming are the real drivers.
- Treat
.protofiles as source of truth and keep generated code ownership clear. - Choose unary, server streaming, client streaming, or bidirectional streaming based on the interaction model, not by default.
- Do not use gRPC for broad browser-facing APIs unless the limitations and gRPC-Web tradeoffs are explicitly acceptable.
- Handle deadlines, cancellation, auth, and retry behavior explicitly on both server and client paths.
- Validate contract changes carefully because gRPC drift breaks callers fast.
Service Patterns
Basic Unary Service
// greeter.proto
syntax = "proto3";
option csharp_namespace = "GrpcService";
package greet;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
// GreeterService.cs
public class GreeterService : Greeter.GreeterBase
{
private readonly ILogger<GreeterService> _logger;
public GreeterService(ILogger<GreeterService> logger)
{
_logger = logger;
}
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
_logger.LogInformation("Greeting {Name}", request.Name);
return Task.FromResult(new HelloReply
{
Message = $"Hello {request.Name}"
});
}
}
Server Streaming
// In .proto file
service DataStream {
rpc StreamData (DataRequest) returns (stream DataChunk);
}
// Service implementation
public override async Task StreamData(
DataRequest request,
IServerStreamWriter<DataChunk> responseStream,
ServerCallContext context)
{
for (int i = 0; i < request.Count; i++)
{
// Check for cancellation to avoid wasted work
if (context.CancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Stream cancelled by client");
break;
}
await responseStream.WriteAsync(new DataChunk
{
Index = i,
Data = await GetDataAsync(i)
});
// Respect backpressure
await Task.Delay(10, context.CancellationToken);
}
}
Bidirectional Streaming
// In .proto file
service Chat {
rpc ChatStream (stream ChatMessage) returns (stream ChatMessage);
}
// Service implementation
public override async Task ChatStream(
IAsyncStreamReader<ChatMessage> requestStream,
IServerStreamWriter<ChatMessage> responseStream,
ServerCallContext context)
{
await foreach (var message in requestStream.ReadAllAsync(context.CancellationToken))
{
_logger.LogInformation("Received: {Message}", message.Text);
// Echo back with transformation
await responseStream.WriteAsync(new ChatMessage
{
Text = $"Echo: {message.Text}",
Timestamp = Timestamp.FromDateTime(DateTime.UtcNow)
});
}
}
Client Patterns
Channel Reuse with Client Factory (Recommended)
// Program.cs - Register gRPC client with factory
builder.Services.AddGrpcClient<Greeter.GreeterClient>(options =>
{
options.Address = new Uri("https://localhost:5001");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
EnableMultipleHttp2Connections = true
};
return handler;
})
.AddInterceptor<LoggingInterceptor>();
// Usage in service
public class MyService
{
private readonly Greeter.GreeterClient _client;
public MyService(Greeter.GreeterClient client)
{
_client = client;
}
public async Task<string> GreetAsync(string name, CancellationToken ct)
{
// Always set deadlines
var deadline = DateTime.UtcNow.AddSeconds(5);
var response = await _client.SayHelloAsync(
new HelloRequest { Name = name },
deadline: deadline,
cancellationToken: ct);
return response.Message;
}
}
Manual Channel Creation with Connection Options
// Reuse channels - expensive to create
var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30)
},
MaxRetryAttempts = 3,
ServiceConfig = new ServiceConfig
{
MethodConfigs =
{
new MethodConfig
{
Names = { MethodName.Default },
RetryPolicy = new RetryPolicy
{
MaxAttempts = 3,
InitialBackoff = TimeSpan.FromSeconds(1),
MaxBackoff = TimeSpan.FromSeconds(5),
BackoffMultiplier = 1.5,
RetryableStatusCodes = { StatusCode.Unavailable }
}
}
}
}
});
// Create multiple clients from same channel
var greeterClient = new Greeter.GreeterClient(channel);
var orderClient = new Orders.OrdersClient(channel);
Consuming Server Streaming
public async Task ProcessStreamAsync(CancellationToken ct)
{
using var call = _client.StreamData(new DataRequest { Count = 100 });
try
{
await foreach (var chunk in call.ResponseStream.ReadAllAsync(ct))
{
await ProcessChunkAsync(chunk);
}
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
_logger.LogInformation("Stream cancelled");
}
}
Bidirectional Streaming Client
public async Task ChatAsync(CancellationToken ct)
{
using var call = _client.ChatStream();
// Read responses in background
var readTask = Task.Run(async () =>
{
await foreach (var response in call.ResponseStream.ReadAllAsync(ct))
{
Console.WriteLine($"Received: {response.Text}");
}
}, ct);
// Send messages
foreach (var message in GetMessages())
{
if (ct.IsCancellationRequested) break;
await call.RequestStream.WriteAsync(new ChatMessage { Text = message });
}
// Signal completion and wait for responses
await call.RequestStream.CompleteAsync();
await readTask;
}
Interceptor Patterns
Logging Interceptor
public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
{
_logger = logger;
}
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var sw = Stopwatch.StartNew();
var call = continuation(request, context);
return new AsyncUnaryCall<TResponse>(
HandleResponse(call.ResponseAsync, context.Method.FullName, sw),
call.ResponseHeadersAsync,
call.GetStatus,
call.GetTrailers,
call.Dispose);
}
private async Task<TResponse> HandleResponse<TResponse>(
Task<TResponse> responseTask, string method, Stopwatch sw)
{
try
{
var response = await responseTask;
_logger.LogInformation("{Method} completed in {Elapsed}ms",
method, sw.ElapsedMilliseconds);
return response;
}
catch (RpcException ex)
{
_logger.LogError(ex, "{Method} failed with {Status} in {Elapsed}ms",
method, ex.StatusCode, sw.ElapsedMilliseconds);
throw;
}
}
}
Server Exception Interceptor
public class ExceptionInterceptor : Interceptor
{
private readonly ILogger<ExceptionInterceptor> _logger;
public ExceptionInterceptor(ILogger<ExceptionInterceptor> logger)
{
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return await continuation(request, context);
}
catch (RpcException)
{
throw; // Let gRPC exceptions pass through
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Internal, "An error occurred"));
}
}
}
Server Configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
options.MaxSendMessageSize = 4 * 1024 * 1024;
options.Interceptors.Add<ExceptionInterceptor>();
});
// Configure Kestrel for HTTP/2
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.Http2.MaxStreamsPerConnection = 100;
options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024; // 1 MB
options.Limits.Http2.InitialStreamWindowSize = 512 * 1024; // 512 KB
});
var app = builder.Build();
app.MapGrpcService<GreeterService>();
app.MapGet("/", () => "gRPC endpoint");
app.Run();
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Creating new channel per call | Connection overhead kills performance | Reuse channels, use client factory |
| Missing deadlines | Calls can hang indefinitely | Always set deadline on client calls |
| Ignoring cancellation in streams | Wastes server resources | Check CancellationToken periodically |
| Using gRPC for browser clients | Limited browser support | Use gRPC-Web with Envoy or REST |
| Large messages (>1MB) | Memory pressure, LOH allocations | Stream in chunks or use HTTP for files |
Sync blocking (Task.Result) |
Thread pool starvation | Use async/await consistently |
| Swallowing exceptions in interceptors | Hides failures from clients | Rethrow or convert to RpcException |
| Not aligning client/server deadlines | Mismatched timeout behavior | Coordinate deadline budgets |
Blocking AsyncUnaryCall with BlockingUnaryCall interceptor |
Interceptors are method-specific | Implement both interceptor methods |
| Missing retry configuration | Single failures cause request failure | Configure retry policy on channel |
Best Practices
Channel and Connection Management
- Reuse channels across the application lifetime
- Enable multiple HTTP/2 connections with
EnableMultipleHttp2Connections = true - Configure keep-alive pings to maintain connections through idle periods
- Use client factory (
AddGrpcClient) for centralized channel management - Set
PooledConnectionIdleTimeoutto prevent premature connection closure
Deadlines and Cancellation
- Always set deadlines on client calls to prevent indefinite hangs
- Propagate cancellation through the call chain
- Check cancellation in long-running streaming handlers
- Coordinate deadline budgets between client and server
Performance
- Avoid large messages (>85KB to stay off Large Object Heap)
- Use streaming for large data transfers instead of single messages
- Enable server GC for high-throughput client applications
- Complete streams gracefully to allow connection reuse
- Dispose streaming calls when done to release resources
Error Handling
- Use appropriate status codes (not just
Internalfor everything) - Let
RpcExceptionpropagate through interceptors - Convert domain exceptions to gRPC status codes at service boundaries
- Include meaningful error details in development mode only
Contract Design
- Use custom objects in proto to enable backward-compatible evolution
- Reserve field numbers you remove instead of reusing
- Version service names for breaking changes (
GreeterV2) - Keep proto files as the single source of truth
Observability
- Add logging interceptors for request/response timing
- Track error rates by status code
- Monitor connection pool health and reuse rates
- Integrate with distributed tracing (OpenTelemetry)
Deliver
- stable protobuf contracts and generated code flow
- service and client code that match the RPC shape
- tests or smoke checks for serialization and call behavior
- proper deadline and cancellation handling
Validate
- gRPC is chosen for the right problem
- streaming semantics and deadlines are explicit
- browser constraints are acknowledged when relevant
- channels are reused appropriately
- error handling converts exceptions to proper status codes
- interceptors are ordered correctly (logging before auth before validation)
Files (dotnet-skills)
-
references
-
anti-patterns.md 24.6 KB
# 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```protobuf // 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:** ```protobuf // 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 ```protobuf // 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:** ```protobuf // VERSION 2 - CORRECT message User { string id = 1; string email = 2; reserved 3; reserved "phone"; string address = 4; // New field number } ``` ### Flat Request Messages ```protobuf // 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:** ```protobuf // 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 ```csharp // WRONG: Using defaults that may not match your workload var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(); // Default limits ``` **Why 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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 ```csharp // 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:** ```csharp // 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); } ``` -
patterns.md 22.2 KB
# 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 ```protobuf 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 ```protobuf // 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 ```protobuf 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 ```csharp 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 ```csharp 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 ```csharp 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 ```csharp 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) ```csharp 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) ```csharp 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 ```csharp 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 ```csharp 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 ```csharp 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 ```csharp 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 ```csharp 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 ```csharp // 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 ```csharp // 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)); ```
-
-
SKILL.md 14.5 KB
--- name: dotnet-grpc version: "1.0.0" category: "Web" description: "Build or review gRPC services and clients in .NET with correct contract-first design, streaming behavior, transport assumptions, and backend service integration." compatibility: "Requires ASP.NET Core gRPC or gRPC client projects." --- # 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 and connection management - implementing service-to-service communication in microservices ## Documentation - [gRPC on .NET Overview](https://learn.microsoft.com/en-us/aspnet/core/grpc/?view=aspnetcore-10.0) - [Performance Best Practices with gRPC](https://learn.microsoft.com/en-us/aspnet/core/grpc/performance?view=aspnetcore-10.0) - [gRPC Client Factory](https://learn.microsoft.com/en-us/aspnet/core/grpc/clientfactory?view=aspnetcore-10.0) - [gRPC Interceptors](https://learn.microsoft.com/en-us/aspnet/core/grpc/interceptors?view=aspnetcore-10.0) - [Call gRPC Services with .NET Client](https://learn.microsoft.com/en-us/aspnet/core/grpc/client?view=aspnetcore-10.0) ### References - [patterns.md](references/patterns.md) - Detailed proto patterns, streaming implementations, interceptors, health checks, and load balancing - [anti-patterns.md](references/anti-patterns.md) - Common gRPC mistakes with explanations and corrections ## Workflow 1. Use gRPC where low-latency backend communication, strong contracts, or streaming are the real drivers. 2. Treat `.proto` files as source of truth and keep generated code ownership clear. 3. Choose unary, server streaming, client streaming, or bidirectional streaming based on the interaction model, not by default. 4. Do not use gRPC for broad browser-facing APIs unless the limitations and gRPC-Web tradeoffs are explicitly acceptable. 5. Handle deadlines, cancellation, auth, and retry behavior explicitly on both server and client paths. 6. Validate contract changes carefully because gRPC drift breaks callers fast. ## Service Patterns ### Basic Unary Service ```csharp // greeter.proto syntax = "proto3"; option csharp_namespace = "GrpcService"; package greet; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; } ``` ```csharp // GreeterService.cs public class GreeterService : Greeter.GreeterBase { private readonly ILogger<GreeterService> _logger; public GreeterService(ILogger<GreeterService> logger) { _logger = logger; } public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) { _logger.LogInformation("Greeting {Name}", request.Name); return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" }); } } ``` ### Server Streaming ```csharp // In .proto file service DataStream { rpc StreamData (DataRequest) returns (stream DataChunk); } // Service implementation public override async Task StreamData( DataRequest request, IServerStreamWriter<DataChunk> responseStream, ServerCallContext context) { for (int i = 0; i < request.Count; i++) { // Check for cancellation to avoid wasted work if (context.CancellationToken.IsCancellationRequested) { _logger.LogInformation("Stream cancelled by client"); break; } await responseStream.WriteAsync(new DataChunk { Index = i, Data = await GetDataAsync(i) }); // Respect backpressure await Task.Delay(10, context.CancellationToken); } } ``` ### Bidirectional Streaming ```csharp // In .proto file service Chat { rpc ChatStream (stream ChatMessage) returns (stream ChatMessage); } // Service implementation public override async Task ChatStream( IAsyncStreamReader<ChatMessage> requestStream, IServerStreamWriter<ChatMessage> responseStream, ServerCallContext context) { await foreach (var message in requestStream.ReadAllAsync(context.CancellationToken)) { _logger.LogInformation("Received: {Message}", message.Text); // Echo back with transformation await responseStream.WriteAsync(new ChatMessage { Text = $"Echo: {message.Text}", Timestamp = Timestamp.FromDateTime(DateTime.UtcNow) }); } } ``` ## Client Patterns ### Channel Reuse with Client Factory (Recommended) ```csharp // Program.cs - Register gRPC client with factory builder.Services.AddGrpcClient<Greeter.GreeterClient>(options => { options.Address = new Uri("https://localhost:5001"); }) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new SocketsHttpHandler { PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, KeepAlivePingDelay = TimeSpan.FromSeconds(60), KeepAlivePingTimeout = TimeSpan.FromSeconds(30), EnableMultipleHttp2Connections = true }; return handler; }) .AddInterceptor<LoggingInterceptor>(); // Usage in service public class MyService { private readonly Greeter.GreeterClient _client; public MyService(Greeter.GreeterClient client) { _client = client; } public async Task<string> GreetAsync(string name, CancellationToken ct) { // Always set deadlines var deadline = DateTime.UtcNow.AddSeconds(5); var response = await _client.SayHelloAsync( new HelloRequest { Name = name }, deadline: deadline, cancellationToken: ct); return response.Message; } } ``` ### Manual Channel Creation with Connection Options ```csharp // Reuse channels - expensive to create var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions { HttpHandler = new SocketsHttpHandler { EnableMultipleHttp2Connections = true, PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, KeepAlivePingDelay = TimeSpan.FromSeconds(60), KeepAlivePingTimeout = TimeSpan.FromSeconds(30) }, MaxRetryAttempts = 3, ServiceConfig = new ServiceConfig { MethodConfigs = { new MethodConfig { Names = { MethodName.Default }, RetryPolicy = new RetryPolicy { MaxAttempts = 3, InitialBackoff = TimeSpan.FromSeconds(1), MaxBackoff = TimeSpan.FromSeconds(5), BackoffMultiplier = 1.5, RetryableStatusCodes = { StatusCode.Unavailable } } } } } }); // Create multiple clients from same channel var greeterClient = new Greeter.GreeterClient(channel); var orderClient = new Orders.OrdersClient(channel); ``` ### Consuming Server Streaming ```csharp public async Task ProcessStreamAsync(CancellationToken ct) { using var call = _client.StreamData(new DataRequest { Count = 100 }); try { await foreach (var chunk in call.ResponseStream.ReadAllAsync(ct)) { await ProcessChunkAsync(chunk); } } catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { _logger.LogInformation("Stream cancelled"); } } ``` ### Bidirectional Streaming Client ```csharp public async Task ChatAsync(CancellationToken ct) { using var call = _client.ChatStream(); // Read responses in background var readTask = Task.Run(async () => { await foreach (var response in call.ResponseStream.ReadAllAsync(ct)) { Console.WriteLine($"Received: {response.Text}"); } }, ct); // Send messages foreach (var message in GetMessages()) { if (ct.IsCancellationRequested) break; await call.RequestStream.WriteAsync(new ChatMessage { Text = message }); } // Signal completion and wait for responses await call.RequestStream.CompleteAsync(); await readTask; } ``` ## Interceptor Patterns ### Logging Interceptor ```csharp public class LoggingInterceptor : Interceptor { private readonly ILogger<LoggingInterceptor> _logger; public LoggingInterceptor(ILogger<LoggingInterceptor> logger) { _logger = logger; } public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation) { var sw = Stopwatch.StartNew(); var call = continuation(request, context); return new AsyncUnaryCall<TResponse>( HandleResponse(call.ResponseAsync, context.Method.FullName, sw), call.ResponseHeadersAsync, call.GetStatus, call.GetTrailers, call.Dispose); } private async Task<TResponse> HandleResponse<TResponse>( Task<TResponse> responseTask, string method, Stopwatch sw) { try { var response = await responseTask; _logger.LogInformation("{Method} completed in {Elapsed}ms", method, sw.ElapsedMilliseconds); return response; } catch (RpcException ex) { _logger.LogError(ex, "{Method} failed with {Status} in {Elapsed}ms", method, ex.StatusCode, sw.ElapsedMilliseconds); throw; } } } ``` ### Server Exception Interceptor ```csharp public class ExceptionInterceptor : Interceptor { private readonly ILogger<ExceptionInterceptor> _logger; public ExceptionInterceptor(ILogger<ExceptionInterceptor> logger) { _logger = logger; } public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>( TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation) { try { return await continuation(request, context); } catch (RpcException) { throw; // Let gRPC exceptions pass through } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in {Method}", context.Method); throw new RpcException(new Status(StatusCode.Internal, "An error occurred")); } } } ``` ## Server Configuration ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB options.MaxSendMessageSize = 4 * 1024 * 1024; options.Interceptors.Add<ExceptionInterceptor>(); }); // Configure Kestrel for HTTP/2 builder.WebHost.ConfigureKestrel(options => { options.Limits.Http2.MaxStreamsPerConnection = 100; options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024; // 1 MB options.Limits.Http2.InitialStreamWindowSize = 512 * 1024; // 512 KB }); var app = builder.Build(); app.MapGrpcService<GreeterService>(); app.MapGet("/", () => "gRPC endpoint"); app.Run(); ``` ## Anti-Patterns to Avoid | Anti-Pattern | Why It's Bad | Better Approach | |--------------|--------------|-----------------| | Creating new channel per call | Connection overhead kills performance | Reuse channels, use client factory | | Missing deadlines | Calls can hang indefinitely | Always set deadline on client calls | | Ignoring cancellation in streams | Wastes server resources | Check `CancellationToken` periodically | | Using gRPC for browser clients | Limited browser support | Use gRPC-Web with Envoy or REST | | Large messages (>1MB) | Memory pressure, LOH allocations | Stream in chunks or use HTTP for files | | Sync blocking (`Task.Result`) | Thread pool starvation | Use async/await consistently | | Swallowing exceptions in interceptors | Hides failures from clients | Rethrow or convert to `RpcException` | | Not aligning client/server deadlines | Mismatched timeout behavior | Coordinate deadline budgets | | Blocking `AsyncUnaryCall` with `BlockingUnaryCall` interceptor | Interceptors are method-specific | Implement both interceptor methods | | Missing retry configuration | Single failures cause request failure | Configure retry policy on channel | ## Best Practices ### Channel and Connection Management 1. **Reuse channels** across the application lifetime 2. **Enable multiple HTTP/2 connections** with `EnableMultipleHttp2Connections = true` 3. **Configure keep-alive pings** to maintain connections through idle periods 4. **Use client factory** (`AddGrpcClient`) for centralized channel management 5. **Set `PooledConnectionIdleTimeout`** to prevent premature connection closure ### Deadlines and Cancellation 1. **Always set deadlines** on client calls to prevent indefinite hangs 2. **Propagate cancellation** through the call chain 3. **Check cancellation** in long-running streaming handlers 4. **Coordinate deadline budgets** between client and server ### Performance 1. **Avoid large messages** (>85KB to stay off Large Object Heap) 2. **Use streaming** for large data transfers instead of single messages 3. **Enable server GC** for high-throughput client applications 4. **Complete streams gracefully** to allow connection reuse 5. **Dispose streaming calls** when done to release resources ### Error Handling 1. **Use appropriate status codes** (not just `Internal` for everything) 2. **Let `RpcException` propagate** through interceptors 3. **Convert domain exceptions** to gRPC status codes at service boundaries 4. **Include meaningful error details** in development mode only ### Contract Design 1. **Use custom objects** in proto to enable backward-compatible evolution 2. **Reserve field numbers** you remove instead of reusing 3. **Version service names** for breaking changes (`GreeterV2`) 4. **Keep proto files** as the single source of truth ### Observability 1. **Add logging interceptors** for request/response timing 2. **Track error rates** by status code 3. **Monitor connection pool** health and reuse rates 4. **Integrate with distributed tracing** (OpenTelemetry) ## Deliver - stable protobuf contracts and generated code flow - service and client code that match the RPC shape - tests or smoke checks for serialization and call behavior - proper deadline and cancellation handling ## Validate - gRPC is chosen for the right problem - streaming semantics and deadlines are explicit - browser constraints are acknowledged when relevant - channels are reused appropriately - error handling converts exceptions to proper status codes - interceptors are ordered correctly (logging before auth before validation)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.