IndexAdvanced Go PatternsPart 10

gRPC Patterns

Interceptors, streaming, deadlines, and idiomatic errors.

Jun 6, 20266 min readRPCPart 10 of 11

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.

Code
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.

Interceptor chain — each wraps the next, the handler is innermost RECOVERY LOGGING AUTH handler request → → response
Recovery outermost, then logging, then auth — the handler runs in the middle

A unary server interceptor:

Go
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:

Go
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:

Code
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:

Go
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:

Go
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:

Go
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:

Go
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.

Four gRPC call types · client ↔ server UNARY client server SERVER ⤵ CLIENT ⤴ BIDI ⇄
Solid = client→server · dashed = server→client · multiple lines = a stream

A server-streaming handler, sending results as they're produced:

Go
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. Send blocks 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.v1 service, so load balancers and orchestrators know when an instance is ready.
  • Reflection (reflection.Register) in non-prod, so tools like grpcurl can 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.

The takeaway

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.