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:
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_recoveryinterceptor 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_recoveryinterceptor as the outermost interceptor - Use
grpc_recovery.WithRecoveryHandlerto 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 vetand staticcheck to catch common nil pointer and bounds issues before runtime