gRPC Patterns
Interceptors, streaming, deadlines, and idiomatic errors.
gRPC is the default choice for service-to-service communication in Go: strongly typed contracts from Protocol Buffers, efficient binary framing over HTTP/2, and first-class streaming. The basics are well documented, so this post focuses on the patterns that make a gRPC service production-grade — interceptors, streaming done right, proper error handling, and deadline propagation that ties straight back to context and resiliency.
The contract comes first
Everything in gRPC starts from a .proto file. The discipline that pays off: treat it as a real API contract, evolved carefully.
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User); // server streaming
}
message GetUserRequest {
string id = 1;
}Two rules that prevent breaking changes:
- Never reuse or renumber a field tag. The numbers (
= 1) are the wire identity. Reserve removed ones (reserved 3;) so they can't be recycled. - Add, don't mutate. New fields are backward compatible; changing a field's type or meaning is not. Plan for forward/backward compatibility from day one.
The generated Go gives you a typed client and a server interface to implement. Everything below is about what you wrap around that.
Interceptors — gRPC's middleware
Interceptors are the single most important pattern for a real service. They're middleware: cross-cutting logic that runs around every RPC, so logging, metrics, auth, recovery, and tracing live in one place instead of being copy-pasted into every handler. There are unary and stream variants on both client and server.
A unary server interceptor:
func LoggingInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req) // call the actual RPC handler
slog.Info("rpc",
"method", info.FullMethod,
"duration", time.Since(start),
"code", status.Code(err),
)
return resp, err
}A recovery interceptor that turns a panic into a clean error instead of killing the connection — every production server should have one:
func RecoveryInterceptor(
ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
slog.Error("panic in handler", "method", info.FullMethod, "panic", r)
err = status.Errorf(codes.Internal, "internal error")
}
}()
return handler(ctx, req)
}Chain them on the server in the order you want them to run:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
RecoveryInterceptor, // outermost: catches panics from everything inside
LoggingInterceptor,
AuthInterceptor,
),
)The client side mirrors this — client interceptors are exactly where the resiliency patterns belong. Retries, circuit breaking, and per-call timeouts wrap every outbound RPC cleanly when implemented as a grpc.UnaryClientInterceptor, instead of being scattered through call sites.
Errors — use status codes, not raw errors
A returned plain error becomes an opaque codes.Unknown on the wire, losing all semantics. Return proper gRPC statuses so clients can react correctly:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
user, err := s.store.Find(ctx, req.Id)
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %q not found", req.Id)
}
if err != nil {
// Don't leak internals to the caller; log the detail, return a clean code.
slog.Error("store.Find failed", "err", err)
return nil, status.Error(codes.Internal, "internal error")
}
return user, nil
}On the client, recover the code to decide how to react — which is exactly how you decide whether something is retryable:
resp, err := client.GetUser(ctx, req)
switch status.Code(err) {
case codes.OK:
// success
case codes.NotFound:
// handle missing
case codes.Unavailable, codes.DeadlineExceeded:
// transient — safe to retry with backoff
default:
// permanent — surface it
}Map your domain errors to the closest standard code: InvalidArgument, NotFound, AlreadyExists, PermissionDenied, Unauthenticated, ResourceExhausted (great for rate limiting), Unavailable, DeadlineExceeded. For richer errors, the status package can attach typed details to the message.
Deadlines propagate across the wire — use them
This is gRPC's quiet superpower and ties the whole series together. A deadline set on the client's context travels with the request to the server, which sees the remaining time on its own incoming context. Set deadlines on the client:
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req) // the 2s budget crosses the network…and respect them on the server, especially before expensive work:
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
if ctx.Err() != nil {
return nil, status.FromContextError(ctx.Err()).Err() // already expired? bail
}
// Pass ctx down to the DB so its budget shrinks across hops.
return s.store.Find(ctx, req.Id)
}Because the deadline shrinks as it flows through each hop in a call chain, a top-level "this request gets 2 seconds" is honored end-to-end — no service in the chain keeps working on a request the caller already abandoned. This is context cancellation, made distributed. Always set a client deadline; an RPC with no deadline is the distributed version of the hung call from post 9.
Streaming — and its backpressure
gRPC offers server-streaming, client-streaming, and bidirectional streaming. Use streaming when there's a natural sequence — many results, a live feed, an upload — rather than forcing it into unary calls or paging by hand.
A server-streaming handler, sending results as they're produced:
func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
ctx := stream.Context() // carries the client's deadline & cancellation
for _, user := range s.store.All() {
if err := ctx.Err(); err != nil {
return err // client hung up or deadline passed — stop streaming
}
if err := stream.Send(user); err != nil {
return err // send failure (broken connection)
}
}
return nil // returning nil closes the stream cleanly
}Things that bite people with streams:
- Check
stream.Context()in the loop, so a client that disconnects (or times out) stops your server from streaming into the void. - Streams aren't free. Each open stream holds a goroutine and buffers. For huge result sets, prefer streaming over loading everything into memory — but cap how many concurrent streams you'll serve (a bulkhead).
- Backpressure is real.
Sendblocks when the client isn't reading fast enough (HTTP/2 flow control). That's a feature — it stops a slow consumer from exhausting your memory — but it means your producer must respect the context so it doesn't block forever.
Operational must-haves
A production gRPC server should also wire up:
- Health checking via the standard
grpc.health.v1service, so load balancers and orchestrators know when an instance is ready. - Reflection (
reflection.Register) in non-prod, so tools likegrpcurlcan introspect the service without the.proto. - Keepalives configured to detect dead connections, and sane message-size limits so a malformed or hostile request can't exhaust memory.
- Graceful shutdown with
srv.GracefulStop()so in-flight RPCs finish before the process exits.
The takeaway
Production gRPC in Go is about the patterns around the generated code: put cross-cutting concerns (logging, recovery, auth, metrics, and your resiliency logic) in interceptors; return proper status codes instead of opaque errors so clients can branch and retry correctly; set client deadlines and respect them server-side so cancellation propagates end-to-end across the wire; and use streaming with context checks and an eye on backpressure. Treat the .proto as a versioned contract, and don't ship without health checks and graceful shutdown.
That's the series. Across ten posts the same thread runs through everything: a request carries a budget, and good Go code respects it. Interceptors put cross-cutting logic in one place, status codes let callers branch and retry, and deadlines set on a client context flow across the wire and shrink at every hop — turning context cancellation into something distributed. Treat the .proto as a versioned contract, lean on streaming and its backpressure where the data is naturally a sequence, and never ship without health checks and graceful shutdown. Now go build something resilient.