gRPC Services

Serving and calling gRPC APIs with nurago: server bootstrap, health service, clients, metrics, tracing, and the gRPC API of the example service

nurago serves and calls gRPC APIs with the same lifecycle, logging, trace ID, and health check model as its HTTP packages. The gRPC code lives in its own packages, so a REST-only service that imports httpserver or httpclient never pulls google.golang.org/grpc into its build.

PackagePurposeHTTP counterpart
grpcserverServer bootstrap: lifecycle, default interceptors, health service, optional reflectionhttpserver
grpcclientPreconfigured *grpc.ClientConn: trace propagation, logging, deadlines, TLS by default, native retrieshttpclient, httpretrier
grpcutilTrace ID metadata, gRPC and HTTP status code mapping, the instrumenter interfaceshttputil
prometheusgrpcPrometheus server and client metricsprometheus
opentelgrpcOpenTelemetry spans and metricsopentel

Two standard-library-only packages are shared with the HTTP side: healthrun runs the health checks, and srvlifecycle runs the start and shutdown state machine of both servers.

Serving gRPC

grpcserver.New binds the listener, registers the services of a Binder, and validates the options. Errors are returned as wrapped sentinels: a duplicate service or a handler that does not implement its interface is an error from New, not a process exit.

type itemBinder struct{ svc itemv1.ItemServiceServer }

func (b *itemBinder) BindGRPC(_ context.Context, s grpc.ServiceRegistrar) error {
    itemv1.RegisterItemServiceServer(s, b.svc)
    return nil
}

srv, err := grpcserver.New(
    ctx,
    &itemBinder{svc: handler},
    grpcserver.WithServerAddr(":8074"),
    grpcserver.WithLogger(logger),
    grpcserver.WithRequestTimeout(30*time.Second),
    grpcserver.WithHealthService(checks),
    grpcserver.WithShutdownWaitGroup(wg),
    grpcserver.WithShutdownSignalChan(sc),
)
if err != nil {
    return err
}

srv.StartServer()

The lifecycle contract is the one of httpserver: a non-blocking, idempotent start, a Shutdown that is safe to call more than once, ServeError for abnormal termination, and Addr for ephemeral ports. Passing the same wait group and signal channel as the HTTP servers drains every listener on one signal. Shutdown stops gracefully and forces the stop when its context expires first, so an open stream cannot hold the process.

Every RPC goes through a default interceptor chain, outermost first:

  1. Request inject and logger. Reads the trace ID from the x-request-id metadata, generates a UUIDv7 when it is missing or invalid, stores it with traceid, and returns it in the response header. Logs one entry per RPC with the method, peer, user agent, grpc_code, and duration. At debug level, unary messages are dumped as JSON, redacted, and capped.
  2. Panic recovery. Logs the stack and answers INTERNAL.
  3. Default deadline. A unary RPC without a deadline gets the one set by WithRequestTimeout. Streams are exempt unless WithMethodTimeout sets one.
  4. Your interceptors, added with WithUnaryInterceptor and WithStreamInterceptor.

The code is logged as grpc_code, a string, rather than the integer response_code of the HTTP logs, so the two do not conflict in a shared log index.

Health service

WithHealthService registers the standard grpc.health.v1.Health service. It takes the same check type as the HTTP health endpoint, so one check set serves both:

checks := []healthcheck.HealthCheck{
    healthcheck.New("db_main", mainDB),
    healthcheck.New("db_read", readDB),
}

statusHandler := healthcheck.NewHandler(checks)           // HTTP /status
grpcOpt := grpcserver.WithHealthService(checks,           // gRPC health
    grpcserver.WithHealthCheckTimeout(5*time.Second))

The service name "" runs every check, and each check ID is also a service name. Concurrent callers of the same service share one run of its checks. Watch streams share one poller per service and receive only status changes. When Shutdown starts, every service reports NOT_SERVING so load balancers drain the server first.

The health service goes through your interceptors. An authentication interceptor should let Check and Watch through for probes and keep List authenticated, since it returns every check ID. The grpcserver package has a JWT example that does this.

Separate listeners

gRPC runs on its own listener, not on the HTTP server. httpserver applies a write timeout and http.TimeoutHandler, which would cut off and buffer streams, and grpc.Server.ServeHTTP is experimental and does not support every grpc-go feature. Operational routes (/metrics, /pprof, /status) stay on the HTTP monitoring server.

Server reflection (WithReflection) lets any client list and describe the registered services. Enable it only on listeners that untrusted networks cannot reach.

Calling gRPC

grpcclient.New returns a plain *grpc.ClientConn, so generated stubs and ecosystem tools work unchanged:

conn, err := grpcclient.New(
    "dns:///items.internal:8074",
    grpcclient.WithLogger(logger),
    grpcclient.WithComponent("items"),
    grpcclient.WithTimeout(5*time.Second),
    grpcclient.WithRetryPolicy(grpcclient.RetryPolicy{
        Methods: []string{
            itemv1.ItemService_GetItem_FullMethodName,
            itemv1.ItemService_ListItems_FullMethodName,
        },
        MaxAttempts:       3,
        InitialBackoff:    100 * time.Millisecond,
        MaxBackoff:        time.Second,
        BackoffMultiplier: 2,
        RetryableCodes:    []codes.Code{codes.Unavailable},
    }),
)

The client copies the trace ID from the context into the outgoing metadata, logs one outbound entry per RPC, and applies WithTimeout to unary calls without a deadline. TLS with the system roots is the default; plaintext needs an explicit WithInsecure.

Retries use the native gRPC retry policy rather than a port of httpretrier: it is transport-aware and replays streams safely before the first response. Scope a policy to the methods that are safe to repeat. In the example above the read RPCs are retried and CreateItem is not, because it is not idempotent.

Metrics and Tracing

metrics.Client is a stable interface and cannot gain gRPC methods, so the gRPC instrumentation is a separate value passed with WithInstrumenter on both sides.

For Prometheus, register a prometheusgrpc.Metrics with the existing registry, next to the HTTP metrics:

grpcMetrics, err := prometheusgrpc.New()
if err != nil {
    return err
}

client, err := prometheus.New(prometheus.WithCollector(grpcMetrics))
if err != nil {
    return err
}

srvOpt := grpcserver.WithInstrumenter(grpcMetrics)
cliOpt := grpcclient.WithInstrumenter(grpcMetrics)

The metric names are the upstream go-grpc-middleware ones (grpc_server_handled_total, grpc_server_handling_seconds, grpc_client_*), so existing dashboards work. Series for every registered method are created at zero when the server starts. Health and reflection RPCs are not recorded by default.

For OpenTelemetry, opentelgrpc.New returns an instrumenter that uses the global providers installed by metrics/opentel, and produces spans and rpc.server.* and rpc.client.* metrics. Health and reflection RPCs are filtered out by default here too.

Errors

Both transports should report the same failure the same way. grpcutil.CodeFromHTTPStatus and grpcutil.HTTPStatusFromCode map between the two code sets following the gRPC specification, and grpcutil.ErrorCode returns the code of any error, including wrapped status errors and context errors.

The Example Service

The service under examples/service, and every project generated from it with make project, serves the item API over gRPC on port 8074 next to the REST routes. Both transports call the same item.Service.

RPCREST route
CreateItemPOST /items
ListItemsGET /items
GetItemGET /items/{id}
DeleteItemDELETE /items/{id}

The item errors map to the same outcome on both transports:

item errorRESTgRPC
ErrNotFound404NOT_FOUND
ErrConflict409ALREADY_EXISTS
ErrValidation422INVALID_ARGUMENT
other500, loggedINTERNAL, logged, with a generic message

The relevant paths are:

PathContents
proto/item/v1/item.proto, buf.yaml, buf.gen.yamlAPI definition and code generation configuration
internal/gen/Generated code, committed so go build needs no protobuf toolchain
internal/grpchandler/The generated server interface implemented on top of item.Service, and the integration tests
internal/cli/bindgrpc.goServer wiring: health checks, Prometheus instrumenter, logger, redactor, TLS, reflection
servers.grpc in the configurationenabled, address, timeout, reflection, tls_cert_file, tls_key_file

Code generation uses buf with local protoc-gen-go and protoc-gen-go-grpc plugins, pinned in the Makefile and installed by make deps, so generation needs no network access. make protogen regenerates the code, make protolint runs the buf STANDARD rules as part of make qa, and make protobreaking checks the API for breaking changes against the main branch. The proto files carry no go_package option: the import path is set in buf.gen.yaml, so the generated code holds no project name and make project can rename the tree without touching it.

make dockertest runs a Go integration test client against the docker-compose environment, after the venom suite. It covers the create, get, list, and delete cycle, the error codes, the message size limit, the health service, trace ID propagation, and items created over one transport being visible over the other.

Locally, make localenvstart enables reflection, so grpcurl can explore the API:

grpcurl -plaintext localhost:8074 list
grpcurl -plaintext -d '{"name":"widget","quantity":3}' localhost:8074 item.v1.ItemService/CreateItem

servers.grpc.enabled = false turns the server off without removing the code or the gRPC dependencies. The example README.md lists the steps to remove both from a REST-only service.

Dependency Footprint

Only grpcserver, grpcclient, grpcutil, prometheusgrpc, and opentelgrpc import google.golang.org/grpc, and metrics/opentel reaches it through the OTLP exporter. make depcheck fails the build if any other package starts reaching a gRPC module, if grpcserver reaches httprouter, or if healthrun or srvlifecycle reach any external module. See /docs/dependency-footprint/.


Previous: /docs/service-scaffolding/

Overview: /docs/

Next: /docs/dependency-footprint/