gRPC Reflection, Testing, and Debugging
Building a gRPC service is one thing. Knowing it actually works in production is another. gRPC's binary protocol, code-generated stubs, and streaming semantics make it fundamentally different from REST APIs when it comes to testing and debugging. You cannot just open a browser or fire off a curl command. You need specialized tools, deliberate strategies, and a clear understanding of what can go wrong at every layer of the stack. This guide covers the full spectrum: from ad-hoc exploration with server reflection and grpcurl, through structured unit and integration testing, to production debugging with distributed tracing and channelz.
Server Reflection: Exploring Services at Runtime
With REST, you can discover an API by reading OpenAPI specs or just hitting endpoints and observing responses. gRPC's binary encoding and schema-first design mean you cannot do that without the .proto files — unless the server supports reflection.
gRPC server reflection is a standard service (defined in grpc.reflection.v1alpha.ServerReflection) that exposes the server's protobuf schema at runtime. When enabled, any client can query the server to discover which services are available, what methods they expose, and the full structure of their request and response messages — all without needing local proto files.
Enabling reflection is typically a one-liner in your server setup. In Go with the standard grpc package:
import "google.golang.org/grpc/reflection"
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &userServer{})
reflection.Register(s) // one line to enable
s.Serve(lis)
In Python with grpcio-reflection:
from grpc_reflection.v1alpha import reflection
SERVICE_NAMES = (
user_pb2.DESCRIPTOR.services_by_name['UserService'].full_name,
reflection.SERVICE_NAME,
)
reflection.enable_server_reflection(SERVICE_NAMES, server)
With reflection enabled, you can list all registered services, describe any method's input and output types, and even inspect nested message fields — entirely at runtime. This makes reflection indispensable during development and staging. In production, you may want to gate it behind authentication or disable it entirely to avoid leaking your API surface.
grpcurl: The curl for gRPC
grpcurl is a command-line tool that lets you interact with gRPC servers the way curl lets you interact with HTTP APIs. It supports server reflection natively, so if the server has reflection enabled, you need nothing more than the server address to start exploring.
Listing Services and Methods
# List all services
$ grpcurl -plaintext localhost:50051 list
grpc.reflection.v1alpha.ServerReflection
myapp.UserService
myapp.OrderService
# Describe a specific service
$ grpcurl -plaintext localhost:50051 describe myapp.UserService
myapp.UserService is a service:
service UserService {
rpc GetUser ( .myapp.GetUserRequest ) returns ( .myapp.User );
rpc ListUsers ( .myapp.ListUsersRequest ) returns ( stream .myapp.User );
rpc CreateUser ( .myapp.CreateUserRequest ) returns ( .myapp.User );
}
# Describe a message type
$ grpcurl -plaintext localhost:50051 describe myapp.User
myapp.User is a message:
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
Making Calls
# Unary call
$ grpcurl -plaintext -d '{"id": "user-123"}' \
localhost:50051 myapp.UserService/GetUser
# Server streaming call
$ grpcurl -plaintext -d '{"page_size": 10}' \
localhost:50051 myapp.UserService/ListUsers
# With metadata (headers)
$ grpcurl -plaintext \
-H 'authorization: Bearer tok_abc123' \
-d '{"name": "Alice", "email": "[email protected]"}' \
localhost:50051 myapp.UserService/CreateUser
If the server does not have reflection enabled, you can supply the proto files directly:
$ grpcurl -proto user.proto -import-path ./protos \
-d '{"id": "user-123"}' \
localhost:50051 myapp.UserService/GetUser
grpcurl also supports TLS, client certificates, unix domain sockets, and deadline configuration. It is the single most important tool for ad-hoc gRPC debugging.
GUI Tools: BloomRPC and Postman
For developers who prefer a visual interface, several GUI tools provide point-and-click gRPC interaction.
BloomRPC
BloomRPC (now largely succeeded by its spiritual successor, kreya and similar tools) was one of the first desktop GUI clients for gRPC. You import your .proto files, and it presents a form-based interface for each RPC method. You fill in request fields, click send, and see the response with syntax highlighting. It supports all four gRPC call types including bidirectional streaming, TLS configuration, and metadata headers.
Postman
Postman added gRPC support starting in 2022. You can create a gRPC request by importing proto files or using server reflection, then build requests with Postman's familiar interface. Postman's gRPC support includes method invocation for all streaming types, saved request collections, environment variables for switching between staging and production, and the ability to write test scripts against responses. This makes it particularly useful for teams that already use Postman for REST APIs and want a unified tool.
Other notable GUI tools include Evans (a REPL-style CLI with interactive mode), grpcui (a web-based UI that auto-generates forms from reflection), and Kreya (a cross-platform desktop client with environment management).
Unit Testing with In-Process Servers
The most effective strategy for unit-testing gRPC services is to run the server in-process — start the gRPC server within the test process itself, bind it to a local port or an in-memory transport, and connect a real gRPC client to it. This tests the full serialization/deserialization path and interceptor chain without any network overhead or port management complexity.
In Go, the google.golang.org/grpc/test/bufconn package provides an in-memory connection that bypasses the network entirely:
func TestGetUser(t *testing.T) {
// Create an in-memory listener
lis := bufconn.Listen(1024 * 1024)
// Start the gRPC server
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &userServer{
db: newMockDB(), // inject mock dependencies
})
go s.Serve(lis)
defer s.Stop()
// Connect the client via bufconn
conn, err := grpc.DialContext(ctx, "bufnet",
grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "user-123"})
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if resp.Name != "Alice" {
t.Errorf("got name %q, want Alice", resp.Name)
}
}
This approach has significant advantages over testing the service implementation struct directly. It exercises the full protobuf serialization round-trip, any interceptors or middleware you have registered (authentication, logging, validation), and the correct propagation of metadata and status codes. If your interceptor rejects unauthenticated requests, that behavior is tested naturally.
Integration Testing Strategies
Unit tests with in-process servers cover individual services well. Integration tests need to verify that multiple services work together correctly, including real database access, inter-service communication, and end-to-end request flows.
Docker Compose Test Environments
The most reliable integration testing strategy uses Docker Compose to spin up all services plus their dependencies (databases, caches, message queues) in an isolated environment. Each test run gets a fresh environment, eliminating flaky state-dependent failures.
# docker-compose.test.yml
services:
user-service:
build: ./user-service
environment:
- DB_HOST=postgres
- ORDER_SERVICE_ADDR=order-service:50051
order-service:
build: ./order-service
environment:
- DB_HOST=postgres
postgres:
image: postgres:16
environment:
- POSTGRES_DB=testdb
test-runner:
build: ./integration-tests
depends_on:
- user-service
- order-service
environment:
- USER_SERVICE_ADDR=user-service:50051
- ORDER_SERVICE_ADDR=order-service:50051
Contract Testing
In microservice architectures, contract testing verifies that service interfaces remain compatible across independent deployments. For gRPC, protobuf's schema evolution rules provide some safety — adding fields is backward-compatible, removing required fields is not. But contract tests go further by verifying that the semantics of the interface are preserved: that a particular request produces a response with specific field values, that error conditions return the correct status codes, and that streaming behavior meets expectations.
Tools like Pact support gRPC via plugins. The consumer (client) records its expectations as a "pact," and the provider (server) verifies it can satisfy them. This catches breaking changes before deployment without requiring both services to be running simultaneously.
Testcontainers
For language-specific integration tests, Testcontainers lets you programmatically start Docker containers from within your test code. You spin up a Postgres container, run migrations, start your gRPC server against the real database, and test against it — all within a standard test function. When the test finishes, the containers are destroyed automatically.
Mocking gRPC Services
When testing a service that depends on other gRPC services, you need to mock those dependencies. There are several approaches, each with different tradeoffs.
Interface-Based Mocking
gRPC code generators produce both client and server interfaces. In Go, the generated client interface can be mocked directly:
type mockOrderClient struct {
pb.UnimplementedOrderServiceClient
orders map[string]*pb.Order
}
func (m *mockOrderClient) GetOrder(ctx context.Context,
req *pb.GetOrderRequest, opts ...grpc.CallOption) (*pb.Order, error) {
order, ok := m.orders[req.Id]
if !ok {
return nil, status.Errorf(codes.NotFound, "order %s not found", req.Id)
}
return order, nil
}
// Inject the mock when constructing the service under test
svc := &userServer{
orderClient: &mockOrderClient{
orders: map[string]*pb.Order{
"order-1": {Id: "order-1", UserId: "user-123", Total: 4999},
},
},
}
Mock Servers
Sometimes you want to test the full network path including serialization and interceptors on the client side. In that case, run a mock gRPC server that returns canned responses. This is essentially the in-process server pattern, but the service implementation returns hardcoded data rather than hitting real backends.
Traffic Recording and Replay
For complex integration scenarios, some teams record real gRPC traffic and replay it in tests. Tools like grpc-wireshark can decode gRPC traffic, and custom interceptors can log request/response pairs for later replay. This is particularly useful for reproducing production bugs in a test environment.
Load Testing gRPC Services
gRPC's HTTP/2 transport, multiplexed streams, and binary encoding make its performance characteristics very different from REST. Load testing tools designed for HTTP/1.1 do not work well with gRPC. You need tools that speak native HTTP/2 and understand the gRPC framing protocol.
ghz
ghz is a purpose-built gRPC benchmarking tool, similar in spirit to Apache Bench or wrk but for gRPC. It supports reflection-based service discovery, configurable concurrency and request rates, and detailed latency histograms.
# Basic load test with ghz
$ ghz --insecure \
--proto user.proto \
--call myapp.UserService.GetUser \
-d '{"id": "user-123"}' \
-c 50 -n 10000 \
localhost:50051
Summary:
Count: 10000
Total: 2.03 s
Slowest: 24.31 ms
Fastest: 0.38 ms
Average: 4.21 ms
Requests/sec: 4926.11
Latency distribution:
10 % in 1.82 ms
25 % in 2.64 ms
50 % in 3.89 ms
75 % in 5.41 ms
90 % in 7.23 ms
95 % in 9.87 ms
99 % in 18.72 ms
Status code distribution:
[OK] 10000 responses
ghz also supports outputting results as JSON or CSV for integration with dashboards, and it can read request data from files for varied payloads across requests.
Locust
Locust is a Python-based load testing framework with a web UI for real-time monitoring. While it is primarily designed for HTTP, the grpc-locust plugin (or custom User classes using the grpcio Python client) lets you write gRPC load tests with Locust's flexible scripting model:
from locust import User, task, between
import grpc
import user_pb2
import user_pb2_grpc
class GrpcUser(User):
wait_time = between(0.5, 2)
def on_start(self):
self.channel = grpc.insecure_channel("localhost:50051")
self.stub = user_pb2_grpc.UserServiceStub(self.channel)
@task(3)
def get_user(self):
self.stub.GetUser(user_pb2.GetUserRequest(id="user-123"))
@task(1)
def list_users(self):
for user in self.stub.ListUsers(user_pb2.ListUsersRequest(page_size=10)):
pass # consume the stream
k6 with gRPC
k6 by Grafana Labs has built-in gRPC support via its k6/net/grpc module. It can load proto files, make gRPC calls, and apply the same sophisticated load patterns (ramp-up, constant arrival rate, staged load) that make k6 popular for HTTP load testing. k6 also integrates natively with Grafana for real-time dashboards.
Distributed Tracing with OpenTelemetry
In a microservice architecture where a single user request may traverse five or ten gRPC services, you need distributed tracing to understand the full request lifecycle. OpenTelemetry (OTel) is the standard framework for this, and it has first-class gRPC support via interceptors.
OpenTelemetry gRPC interceptors automatically create spans for every RPC call, record metadata like the method name, status code, and latency, and propagate the trace context through gRPC metadata headers (traceparent / grpc-trace-bin). Setting it up in Go:
import (
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
// Server side: add OTel interceptor
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
// Client side: add OTel interceptor
conn, err := grpc.Dial(addr,
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
With these interceptors in place, every gRPC call between services is automatically traced. The resulting trace shows the full call tree: how long each service took, which downstream calls it made, and where time was spent. Backends like Jaeger, Grafana Tempo, and Zipkin visualize these traces as flame graphs or waterfall diagrams, making it trivial to identify the slow service in a chain of ten.
Beyond automatic instrumentation, you can add custom spans for database queries, cache lookups, and business logic within your service handlers. The key insight is that the trace context flows through context.Context, so any span created within a gRPC handler automatically becomes a child of the RPC span.
Channelz: Runtime Channel Diagnostics
Channelz is a gRPC-native diagnostic service that exposes detailed runtime information about all channels (client connections), subchannels, and sockets managed by the gRPC runtime. It is the equivalent of looking at netstat or ss, but with gRPC-level semantics: you see not just TCP connections but the logical gRPC channels, their connectivity states, the number of calls started/succeeded/failed, and the last time each channel changed state.
Channelz is enabled by registering the channelz service on your gRPC server. You can then query it via grpcurl or programmatically through the channelz gRPC API:
# List all top-level channels
$ grpcurl -plaintext localhost:50051 \
grpc.channelz.v1.Channelz/GetTopChannels
# Get details for a specific channel
$ grpcurl -plaintext -d '{"channel_id": 1}' \
localhost:50051 grpc.channelz.v1.Channelz/GetChannel
# List all servers
$ grpcurl -plaintext localhost:50051 \
grpc.channelz.v1.Channelz/GetServers
Channelz is particularly valuable for diagnosing connectivity issues in production. If a service is experiencing intermittent failures, channelz can reveal that one of five subchannels is in TRANSIENT_FAILURE state, pointing to a specific backend instance. Without channelz, you would only see the aggregate error rate without understanding the underlying cause.
Admin Services and Health Checking
gRPC defines a standard health checking protocol (grpc.health.v1.Health) that load balancers and orchestrators like Kubernetes use to determine if a service is ready to handle traffic. The health service supports per-service health status, so a server can report itself healthy for one service but unhealthy for another (for example, if a specific database connection pool is exhausted).
// Go: register the health service
import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"
healthServer := health.NewServer()
healthpb.RegisterHealthServer(s, healthServer)
// Set per-service health
healthServer.SetServingStatus("myapp.UserService", healthpb.HealthCheckResponse_SERVING)
healthServer.SetServingStatus("myapp.OrderService", healthpb.HealthCheckResponse_NOT_SERVING)
In Kubernetes, you configure gRPC health checks in your pod spec:
livenessProbe:
grpc:
port: 50051
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
grpc:
port: 50051
service: "myapp.UserService"
periodSeconds: 3
The admin service pattern goes further by exposing operational endpoints on a separate port — typically bound only to localhost or an internal network. Admin services commonly include channelz, health, reflection, and custom endpoints for cache invalidation, log-level adjustment, or feature flag toggling. Running admin services on a separate port means they can be protected by network-level controls without affecting the main serving path.
Common Debugging Patterns
gRPC has a well-defined set of status codes that, unlike HTTP's somewhat ambiguous codes, map precisely to specific failure modes. Learning to diagnose these efficiently is a core gRPC debugging skill.
DEADLINE_EXCEEDED (Code 4)
This is the most common production issue. The client set a deadline (timeout) for the RPC, and the server did not respond in time. Debugging this requires understanding where time was spent:
- Check the trace. OpenTelemetry traces reveal which downstream call or database query consumed the time. Often, a DEADLINE_EXCEEDED at the top level is caused by a slow dependency three services deep.
- Check deadline propagation. gRPC propagates deadlines through the call chain. If the originating client sets a 5-second deadline, and the first service spends 4 seconds before calling a second service, the second service only has 1 second. This cascading effect often causes the deepest service to exceed its (inherited) deadline.
- Check for deadline too short. Sometimes the client deadline is simply too aggressive. A p99 latency of 500ms with a 200ms deadline means 1%+ of requests will always fail.
// Go: set a deadline on the client side
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.DeadlineExceeded {
log.Printf("GetUser timed out after 5s")
}
}
RESOURCE_EXHAUSTED (Code 8)
This indicates the server or client has run out of some resource. The most common causes:
- Message size limit. gRPC defaults to a 4 MB maximum message size. If a response exceeds this, the client receives RESOURCE_EXHAUSTED. Fix by increasing the limit with
grpc.MaxRecvMsgSize()or, better, by paginating the response. - Connection-level flow control. HTTP/2 flow control can throttle a sender if the receiver is not consuming data fast enough. This is common with server streaming RPCs where the client processes messages slowly.
- Server-side limits. Custom rate limiters or concurrency limiters in interceptors may return RESOURCE_EXHAUSTED when overloaded.
- Memory pressure. If the server is running out of memory (for example, buffering too many concurrent streaming responses), it may return this code.
UNAVAILABLE (Code 14)
UNAVAILABLE means the server is not reachable or not ready to handle requests. It is the gRPC equivalent of "connection refused" or "503 Service Unavailable." Key debugging steps:
- Check if the server is running. Use channelz or
grpcurlto verify the server is accepting connections. - Check DNS resolution. The client may be resolving the service name to the wrong address, or DNS may be returning stale records.
- Check TLS certificate issues. A TLS handshake failure manifests as UNAVAILABLE. Verify certificates are valid, not expired, and that the server name matches the certificate's SAN.
- Check load balancer health. If using a load balancer (like Envoy or an L7 proxy), the backend may be failing health checks while the server itself is running fine.
- Check for goaway frames. The server may be gracefully shutting down and sending HTTP/2 GOAWAY frames. This is normal during deploys, but clients should retry on a different connection.
UNIMPLEMENTED (Code 12)
The client is calling a method that the server does not recognize. This usually means a version mismatch — the client was generated from a newer proto file that includes a method the server has not implemented yet. Verify with reflection:
# Check if the method exists on the server
$ grpcurl -plaintext localhost:50051 describe myapp.UserService
INTERNAL (Code 13)
This is the catch-all for server bugs. Common causes include nil pointer dereferences, serialization failures (malformed protobuf messages), and unhandled panics. Server-side logs are essential here. Consider adding a recovery interceptor that catches panics and returns INTERNAL with a sanitized error message rather than crashing the server process:
// Go: panic recovery interceptor
func RecoveryInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in %s: %v\n%s", info.FullMethod, r, debug.Stack())
}
}()
return handler(ctx, req)
}
Structured Logging for gRPC
Effective gRPC debugging requires structured logging that captures the right fields for every RPC. A logging interceptor should record:
- Method name — the full gRPC method (e.g.,
/myapp.UserService/GetUser) - Status code — OK, NOT_FOUND, INTERNAL, etc.
- Duration — how long the call took
- Request/response size — useful for identifying oversized messages
- Trace ID — links the log entry to the distributed trace
- Peer address — which client or server instance was involved
- Metadata — selected request headers (auth tokens should be redacted)
// Example structured log output (JSON)
{
"level": "info",
"method": "/myapp.UserService/GetUser",
"code": "OK",
"duration_ms": 4.2,
"req_size": 28,
"resp_size": 142,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"peer": "10.0.1.42:38291"
}
Several open-source interceptor libraries provide this out of the box. In Go, go-grpc-middleware includes logging interceptors for zap and logrus. In Java, grpc-spring-boot-starter includes auto-configured logging. The key is to ensure every RPC leaves a structured log entry, so you can query your log aggregation system (Loki, Elasticsearch, Datadog) by method name, status code, or trace ID.
Debugging gRPC in Production: A Workflow
When a gRPC service starts misbehaving in production, a systematic approach saves time. Here is a proven workflow that moves from broad to narrow:
- Check metrics. Look at the gRPC method-level dashboards for error rate, latency percentiles, and throughput. Identify which method or methods are affected and when the problem started.
- Check recent deploys. Correlate the problem timeline with deployments. Most production issues are caused by the most recent change.
- Pull traces. Find traces that show the failure. OpenTelemetry traces reveal the exact call chain and where the delay or error originated.
- Check channelz. If the issue looks like a connectivity problem (UNAVAILABLE errors, elevated latency), use channelz to inspect the channel and subchannel states. Look for subchannels in TRANSIENT_FAILURE.
- Check logs. Search structured logs for the failing method and time window. Look for patterns: is it one client, one backend instance, or all traffic?
- Reproduce with grpcurl. Once you have narrowed down the failing method and conditions, use grpcurl to manually call the method and observe the response, headers, and trailers.
- Check resource pressure. Look at CPU, memory, file descriptors, and goroutine/thread counts on the affected instances. RESOURCE_EXHAUSTED often correlates with one of these hitting a limit.
This workflow is structured as a funnel: each step narrows the problem space until you have a specific cause and can deploy a fix.
Network-Level Debugging
When application-level tools do not provide enough information, you may need to drop to the network level. Because gRPC runs over HTTP/2, standard HTTP debugging techniques apply with some caveats.
Wireshark can decode gRPC traffic if you provide the proto files. Filter on http2 and look for frames with the content-type: application/grpc header. For TLS-encrypted traffic, you will need to configure Wireshark with the server's private key or use the SSLKEYLOGFILE environment variable to capture the session keys.
The gRPC libraries themselves support extensive environment variable logging. In Go, setting GRPC_GO_LOG_SEVERITY_LEVEL=info and GRPC_GO_LOG_VERBOSITY_LEVEL=2 produces detailed logs about connection state changes, name resolution, and load balancing decisions. In C-core based implementations (Python, Ruby, C++), GRPC_VERBOSITY=DEBUG and GRPC_TRACE=all provide similar detail. These are extremely verbose and should only be used for targeted debugging, not in production.
Putting It All Together
A well-instrumented gRPC service stack looks like this:
- Development — Server reflection enabled. grpcurl and grpcui for exploration. Unit tests with in-process servers and bufconn.
- CI/CD — Integration tests in Docker Compose. Contract tests for cross-team APIs. Load tests with ghz on every release candidate.
- Staging — Full OpenTelemetry instrumentation. Channelz enabled. Structured logging with trace ID correlation.
- Production — Health checking for Kubernetes probes. Admin services on a separate port. Dashboards for method-level error rates, latency, and throughput. Alerting on p99 latency and error rate thresholds.
The investment in testing and observability infrastructure pays for itself the first time you get paged at 2 AM for a DEADLINE_EXCEEDED spike and can identify the root cause in five minutes instead of fifty. gRPC's structured protocol, typed status codes, and standardized auxiliary services (reflection, channelz, health) provide better debugging primitives than REST — but only if you set them up.
To understand the protocol fundamentals that underpin all of this, see How gRPC Works. For a deep dive into the status codes referenced throughout this guide, see gRPC Error Handling.