Go

Explain gRPC in Go: unary vs streaming RPCs, interceptors, deadlines, status codes and error details.

Question 568HardGo 1.22 to 1.25

RPC kinds. gRPC has four:

  • Unary: one request, one response.
  • Server streaming: one request, then stream.Send many times.
  • Client streaming: Recv many times, then SendAndClose.
  • Bidirectional: both sides send and receive independently.

A stream is one HTTP/2 stream. Send is not safe to call from several goroutines at once, and neither is Recv. One sending goroutine and one receiving goroutine is fine.

Interceptors are middleware: grpc.ChainUnaryInterceptor and grpc.ChainStreamInterceptor on the server, and WithChainUnaryInterceptor on the client. Use them for auth, logging, metrics and panic recovery.

Deadlines come from the client's ctx. They travel over the wire as grpc-timeout, and the server sees them as ctx.Done(). Always set one, because the default is no deadline at all.

func recoverUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo,
	h grpc.UnaryHandler) (resp any, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = status.Errorf(codes.Internal, "panic in %s", info.FullMethod)
		}
	}()
	return h(ctx, req)
}

func (s *server) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.User, error) {
	if r.GetId() == "" {
		st := status.New(codes.InvalidArgument, "id is required")
		st, _ = st.WithDetails(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{
			{Field: "id", Description: "must not be empty"}}})
		return nil, st.Err()
	}
	u, err := s.db.Find(ctx, r.GetId())
	if errors.Is(err, sql.ErrNoRows) {
		return nil, status.Error(codes.NotFound, "user not found")
	}
	return u, err
}

// client
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
_, err := client.GetUser(ctx, &pb.GetUserRequest{})
switch status.Code(err) {
case codes.DeadlineExceeded, codes.Unavailable: // candidates for retry
case codes.InvalidArgument: // do not retry
}

What does this print? fmt.Println(status.Code(nil), status.Code(errors.New("x"))) prints OK Unknown. If a handler returns a plain Go error, the client sees Unknown. Map errors to proper codes at the service boundary.

Other gotchas:

  • Use grpc.NewClient (Dial is deprecated). It connects lazily.
  • Reuse one ClientConn per target.
  • Return ctx.Err() promptly when the client cancels.
  • Never wrap a status error with fmt.Errorf("%w") and expect status.Code to find the code on grpc-go versions older than 1.55. Older versions do not unwrap.

More on Observability, Debugging & Production Operations

All 14 Observability, Debugging & Production Operations questions