Interceptors, Errors and Deadlines
Wrap every method with one function, return real status codes, and let deadlines travel with the call.
grpc.NewServer(grpc.ChainUnaryInterceptor(recoverPanics, authenticate, logCalls))An interceptor is gRPC's middleware: one function that wraps every method on the server, so logging, authentication and metrics are written once instead of in each method. There are two kinds because there are two shapes of call. A unary interceptor receives the request and a handler, which stands for the real method; it calls the handler and may inspect or replace what comes back. A stream interceptor receives the stream instead and wraps the whole call.
Codes, not sentences
gRPC answers every call with a status: a numeric code plus a message. Return status.Error(codes.NotFound, "no such user") and the caller can branch on status.Code(err) == codes.NotFound without reading English. status.Errorf(code, format, args...) is shorthand for status.Error(code, fmt.Sprintf(format, args...)), and the two produce the same error.
Deadlines
Give the call a context with a timeout and gRPC sends the time remaining along with the request. The server's ctx then carries that same deadline. Pass it into your database queries and your own outgoing calls and the whole chain gives up together; when it runs out both sides see codes.DeadlineExceeded. A deadline is a point in time, not a fresh budget per hop, so it keeps shrinking as a request travels. A handler that starts from context.Background() breaks the chain and keeps working on an answer nobody is waiting for.
Chaining
grpc.ChainUnaryInterceptor(a, b) is the readable form of nesting interceptors inside each other by hand. a runs outermost, then b, then your method; on the way out the order reverses.
Example
package main
import (
"context"
"fmt"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/status"
)
type server struct {
healthpb.UnimplementedHealthServer
}
func (server) Check(ctx context.Context, req *healthpb.HealthCheckRequest) (*healthpb.HealthCheckResponse, error) {
if req.GetService() != "" {
// status.Errorf attaches a code the caller can branch on.
return nil, status.Errorf(codes.NotFound, "unknown service %q", req.GetService())
}
return &healthpb.HealthCheckResponse{Status: healthpb.HealthCheckResponse_SERVING}, nil
}
// logCalls wraps every unary method. handler is the real method.
func logCalls(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
res, err := handler(ctx, req)
fmt.Printf("%s -> %s\n", info.FullMethod, status.Code(err))
return res, err
}
func main() {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer(grpc.ChainUnaryInterceptor(logCalls))
healthpb.RegisterHealthServer(srv, server{})
go func() { _ = srv.Serve(lis) }()
defer srv.Stop()
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// The deadline travels with the call; the server sees it on its ctx.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{Service: "billing"})
fmt.Println("client saw:", status.Code(err), status.Convert(err).Message())
}
// Output:
// /grpc.health.v1.Health/Check -> NotFound
// client saw: NotFound unknown service "billing"When to use it
- Log every call with its method name, duration and status code, in one place.
- Reject unauthenticated calls before any method body runs.
- Make sure every outgoing call has a deadline, even when a caller forgot one.
- Recover from a panic in one method so it does not take the whole server down.
More examples
Covering streams as well as unary calls
A unary interceptor never sees a streaming method, which is the usual reason a team finds half its traffic unlogged. Register both. A stream interceptor runs once per call, not once per message, so it can time and log the call but cannot see individual messages unless it wraps ss in its own type. Put the panic recovery outermost so it also catches a panic inside another interceptor.
func logStreams(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
err := handler(srv, ss) // runs the whole streaming method
slog.Info("stream finished",
"method", info.FullMethod,
"client_stream", info.IsClientStream,
"code", status.Code(err).String())
return err
}
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(recoverPanics, authenticate, logCalls),
grpc.ChainStreamInterceptor(recoverStreamPanics, authenticateStream, logStreams),
)A client that always sets a deadline
Interceptors exist on the client side too, and this is the one worth adding first. A gRPC call with no deadline can hang for as long as the connection survives, and one such call in a request path is enough to exhaust a server's goroutines. The deadline check means an existing deadline, usually inherited from an incoming request, is never extended by this code.
func withTimeout(d time.Duration) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any,
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if _, ok := ctx.Deadline(); !ok { // only when the caller set none
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, d)
defer cancel()
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithChainUnaryInterceptor(withTimeout(2*time.Second)),
)Turning internal errors into codes
One place that decides what each failure looks like from outside. The default branch is the important one: it logs everything it knows and tells the caller only that something broke, which keeps table names and file paths out of the response. Call it from each method, or once from a unary interceptor that rewrites the error on the way out. Codes.Canceled is normal, not an incident, so count it separately in metrics.
var ErrNotFound = errors.New("not found")
// toStatus maps the errors our packages return onto gRPC codes.
func toStatus(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrNotFound):
return status.Error(codes.NotFound, "no such record")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "took too long")
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "caller went away")
default:
slog.Error("unhandled error", "err", err) // detail stays in the log
return status.Error(codes.Internal, "internal error")
}
}
Discussion