Introduction

When a gRPC handler in Go panics -- due to nil pointer dereference, index out of bounds, or any other runtime panic -- the entire gRPC server process crashes unless a recovery interceptor is installed. Unlike HTTP servers which often have built-in panic recovery middleware, gRPC for Go requires explicit interceptor configuration. Without it, a single buggy request can take down the entire server, dropping all active streams and forcing a full restart. In microservice architectures, this can cascade to dependent services.

Symptoms

The gRPC server crashes and logs a panic:

``` panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x1234567]

goroutine 42 [running]: myapp/server.(*UserService).GetUser(0xc0000a2000, 0x1a3b7c0, 0xc000123450, 0xc0000a2000) /app/user_service.go:45 +0x1a8 ```

All active gRPC streams are dropped. Clients see:

bash
rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing dial tcp 10.0.1.50:9090: connect: connection refused"

Common Causes

  • Nil pointer in handler logic: Accessing a nil database connection, config, or dependency
  • Missing recovery interceptor: No grpc_recovery interceptor installed on the gRPC server
  • Panic in middleware: Custom interceptor panics before the handler is called
  • Type assertion panic: value.(Type) without the comma-ok idiom
  • Slice index out of bounds: Accessing a slice element without checking length
  • Uninitialized protobuf message: Calling methods on a nil protobuf field

Step-by-Step Fix

Step 1: Install the panic recovery interceptor

```go import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status"

grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" grpc_logging "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap" )

func NewGRPCServer(logger *zap.Logger) *grpc.Server { // Recovery interceptor - catches panics and returns Internal error recoveryOpt := grpc_recovery.WithRecoveryHandler( func(p interface{}) error { logger.Error("gRPC handler panic recovered", zap.Any("panic", p), zap.Stack("stack"), ) return status.Errorf(codes.Internal, "internal server error") }, )

return grpc.NewServer( grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer( grpc_recovery.UnaryServerInterceptor(recoveryOpt), grpc_logging.UnaryServerInterceptor(logger), )), grpc.StreamInterceptor(grpc_middleware.ChainStreamServer( grpc_recovery.StreamServerInterceptor(recoveryOpt), grpc_logging.StreamServerInterceptor(logger), )), ) } ```

With this interceptor, a panicking handler returns codes.Internal to the client instead of crashing the server.

Step 2: Prevent common panic sources in handlers

```go func (s *UserService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) { // Safe type assertion md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Error(codes.InvalidArgument, "missing metadata") }

// Safe slice access if len(req.Ids) == 0 { return nil, status.Error(codes.InvalidArgument, "at least one ID required") }

// Safe map access token := md["authorization"] if len(token) == 0 { return nil, status.Error(codes.Unauthenticated, "missing auth token") }

// Nil check on dependencies if s.db == nil { return nil, status.Error(codes.Internal, "database not initialized") }

user, err := s.db.FindUserByID(ctx, req.Ids[0]) if err != nil { return nil, status.Error(codes.NotFound, "user not found") }

return &pb.GetUserResponse{User: user.ToProto()}, nil } ```

Step 3: Add panic recovery to stream handlers

```go func (s *ChatService) StreamMessages(req *pb.StreamMessagesRequest, stream pb.ChatService_StreamMessagesServer) error { // Use defer to recover from panics in stream handling defer func() { if r := recover(); r != nil { s.logger.Error("panic in stream handler", zap.Any("panic", r), zap.Stack("stack"), ) // Cannot send gRPC error after panic - stream is broken // The interceptor handles the error response panic(r) // Re-panic so the interceptor can log it properly } }()

for { select { case <-stream.Context().Done(): return nil case msg := <-s.messageCh: if err := stream.Send(msg); err != nil { return err } } } } ```

Prevention

  • Always install grpc_recovery interceptor as the outermost interceptor
  • Use grpc_recovery.WithRecoveryHandler to customize panic logging
  • Add nil checks for all injected dependencies at handler entry
  • Use the comma-ok idiom for all type assertions: v, ok := x.(Type)
  • Check slice bounds before indexing: if i < len(slice)
  • Add panic() tests: write tests that deliberately trigger panics and verify recovery
  • Use go vet and staticcheck to catch common nil pointer and bounds issues before runtime