mcpField
G
go-grpc

go-grpc

github.com/eduardo-slDevelopment8391 installsUpdated 2w ago
Repository

About go-grpc

A Go skill reference for building gRPC services and clients, intended for developers designing contract-first APIs. It emphasizes versioned protobuf contracts, meaningful status codes, deadline propagation, interceptors, streaming, and in-memory testing.

Key features

  1. 01Versioned protobuf contract design
  2. 02Semantic gRPC status codes
  3. 03Deadlines and context propagation
  4. 04Unary and stream interceptors
  5. 05Streaming and client configuration
  6. 06bufconn testing guidance

Use cases

  1. 01Designing versioned RPC interfaces
  2. 02Mapping domain errors to status codes
  3. 03Propagating deadlines across service calls
  4. 04Testing gRPC clients and servers without network

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/eduardo-sl/go-agent-skills --skill go-grpc

After installing, type/go-grpcin Claude Code to confirm it loaded.

Bundled filesreferences/clients.mdreferences/testing.md

Details

From the project README

Go gRPC Services

gRPC's contract-first model only pays off if the contract is treated as an API: versioned packages, deliberate error codes, deadlines everywhere, and interceptors for everything cross-cutting.

Detailed reference material, loaded on demand:

  • references/clients.md — NewClient, service-config retries and load balancing, keepalive on both sides, reading status codes.
  • references/testing.md — in-memory bufconn server and client, asserting status codes.

Read a reference file only when the summary below is not enough.

1. Proto Design Rules

syntax = "proto3";

package payment.v1;                            // version IN the package
option go_package = "github.com/acme/payment-service/gen/payment/v1;paymentv1";

service PaymentService {
  rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse);
}

message CreatePaymentRequest {                 // one request/response pair
  string order_id = 1;                         // per RPC, always — even if
  int64 amount_cents = 2;                      // empty today
}

message CreatePaymentResponse {
  Payment payment = 1;
}
  • Version in the package (payment.v1); breaking change = payment.v2.
  • Never reuse or renumber field tags; reserved 3, 7; deleted ones.
  • Dedicated Request/Response messages per RPC — adding a field later is free; changing a shared message breaks every RPC using it.
  • Generate with buf or a pinned protoc in make generate; commit generated code so builds don't depend on toolchain drift.
  • In CI, buf lint and buf breaking --against '.git#branch=main' stop renumbered fields and removed RPCs before they reach a client. Field rules (lengths, ranges, formats) belong in protovalidate annotations, enforced by an interceptor.

2. Errors: Status Codes, Not Strings

Return status.Error, mapping domain errors in ONE place:

func (s *Server) CreatePayment(ctx context.Context, req *pb.CreatePaymentRequest) (*pb.CreatePaymentResponse, error) {
    p, err := s.svc.Create(ctx, toDomain(req))
    if err != nil {
        return nil, toStatus(err)
    }
    return &pb.CreatePaymentResponse{Payment: fromDomain(p)}, nil
}

func toStatus(err error) error {
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return status.Error(codes.NotFound, "payment not found")
    case errors.Is(err, domain.ErrDuplicate):
        return status.Error(codes.AlreadyExists, "payment already exists")
    case errors.Is(err, context.DeadlineExceeded):
        return status.Error(codes.DeadlineExceeded, "timed out")
    default:
        return status.Error(codes.Internal, "internal error") // no details leak
    }
}

Code semantics that matter: InvalidArgument (bad request regardless of state), FailedPrecondition (bad state), NotFound, AlreadyExists, Unauthenticated vs PermissionDenied, Unavailable (retryable), Internal (bug). Clients read codes with status.FromError(err) — never parse messages.

3. Deadlines Are Mandatory

// Client — every call gets a deadline
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := client.CreatePayment(ctx, req)

// Server — check before expensive work
if err := ctx.Err(); err != nil {
    return nil, status.FromContextError(err).Err()
}

The server inherits the client's deadline through the context. Pass ctx into every downstream call (DB, other RPCs) so cancellation propagates end to end.

4. Interceptors for Cross-Cutting Concerns

Handlers stay business-only; recovery, auth, logging, metrics live in interceptors:

srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        recoveryInterceptor,   // outermost: panic → codes.Internal
        loggingInterceptor,
        authInterceptor,
    ),
)

func loggingInterceptor(ctx context.Context, req any,
    info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    start := time.Now()
    resp, err := handler(ctx, req)
    slog.InfoContext(ctx, "rpc",
        slog.String("method", info.FullMethod),
        slog.Duration("duration", time.Since(start)),
        slog.String("code", status.Code(err).String()),
    )
    return resp, err
}

Order matters: recovery first (outermost), then observability, then auth. Streaming RPCs need the parallel StreamInterceptor versions.

5. Streaming

  • Server streaming for large result sets: stream.Send in a loop, return non-nil error to abort with a status.
  • Client/bidi streaming only when the protocol truly needs it — each open stream holds a goroutine and flow-control state.
  • Always terminate on ctx.Done():
func (s *Server) WatchPayments(req *pb.WatchRequest, stream pb.PaymentService_WatchPaymentsServer) error {
    for {
        select {
        case <-stream.Context().Done():
            return status.FromContextError(stream.Context().Err()).Err()
        case ev := <-s.events:
            if err := stream.Send(toProto(ev)); err != nil {
                return err
            }
        }
    }
}

6. Clients

  • grpc.NewClient (it replaces the deprecated Dial) once per target at startup; share the *grpc.ClientConn — it multiplexes and reconnects.
  • Retries, timeouts and load balancing in the service config: retry UNAVAILABLE on idempotent methods only, and use round_robin (the default pick_first sends everything to one backend).
  • Client keepalive pings must be allowed by the server's EnforcementPolicy (default MinTime 5 minutes), or the server closes the connection with too_many_pings.
  • Branch on status.Code(err), never on the message.

Examples: references/clients.md.

7. Testing

Test through the generated client over bufconn: a real server with the production interceptors, no network. Dial passthrough:///bufnet with WithContextDialer — NewClient's default dns resolver would try to resolve the name. Assert with status.Code(err). Example: references/testing.md.

8. Production Server Setup

lis, err := net.Listen("tcp", cfg.Addr)
if err != nil {
    return fmt.Errorf("listen: %w", err)
}

srv := grpc.NewServer(
    grpc.Creds(creds),                // TLS; mTLS for service-to-service
    grpc.MaxRecvMsgSize(4<<20),       // explicit; size it to the largest valid request
    grpc.ChainUnaryInterceptor(...),
)
pb.RegisterPaymentServiceServer(srv, server)

healthSrv := health.NewServer() // grpc.health.v1 — load balancers need it
healthpb.RegisterHealthServer(srv, healthSrv)
reflection.Register(srv)        // grpcurl/debugging; gate on non-prod if policy requires

go func() {
    <-ctx.Done()
    stopped := make(chan struct{})
    go func() { srv.GracefulStop(); close(stopped) }()
    select {
    case <-stopped:                 // in-flight RPCs finished
    case <-time.After(10 * time.Second):
        srv.Stop()                  // force after grace period
    }
}()

return srv.Serve(lis)

For mTLS, the server's tls.Config sets ClientCAs and ClientAuth: tls.RequireAndVerifyClientCert; handlers read the caller's verified identity from peer.FromContext(ctx) rather than trusting metadata.

Verification Checklist

  1. Proto packages versioned (*.v1); no tag reuse; reserved for removals
  2. Dedicated Request/Response message per RPC
  3. Generated code produced by a pinned tool (buf/protoc) and committed; buf breaking runs in CI
  4. All handler errors are status.Error with semantically correct codes
  5. codes.Internal responses never leak internal error text
  6. Every client call has a deadline; ctx propagated through all layers
  7. Recovery, logging, auth implemented as chained interceptors (unary + stream)
  8. Streams select on stream.Context().Done()
  9. Health service registered; graceful stop with forced fallback; TLS (mTLS internally) and an explicit receive limit
  10. grpcurl smoke test (or generated client test) passes against the running server
  11. One shared ClientConn per target, built with grpc.NewClient; retries limited to UNAVAILABLE

Comments

Markdown supported