Unary interceptors handle single request/response RPCs in gRPC services. They’re ideal for protocol-level concerns like metadata handling, logging, and monitoring. This guide shows you how to implement effective unary interceptors for your Goa services.
A unary interceptor follows this pattern:
func UnaryInterceptor(ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
// 1. Pre-handler operations
// - Extract metadata
// - Validate protocol requirements
// - Start timing
// 2. Call the handler
resp, err := handler(ctx, req)
// 3. Post-handler operations
// - Record metrics
// - Transform errors
// - Add response metadata
return resp, err
}
This structure allows you to:
This interceptor demonstrates proper metadata propagation:
func MetadataInterceptor(ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
// Extract incoming metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
md = metadata.New(nil)
}
// Add or modify metadata
requestID := md.Get("x-request-id")
if len(requestID) == 0 {
requestID = []string{uuid.New().String()}
md = metadata.Join(md, metadata.Pairs("x-request-id", requestID[0]))
}
// Create new context with metadata
ctx = metadata.NewIncomingContext(ctx, md)
// Call handler
resp, err := handler(ctx, req)
// Add metadata to response
header := metadata.Pairs("x-request-id", requestID[0])
grpc.SetHeader(ctx, header)
return resp, err
}
This example demonstrates several key metadata handling capabilities. It shows how to extract and validate metadata from incoming requests, ensuring that required values are present. When values like request IDs are missing, it generates new ones to maintain traceability. The interceptor properly propagates metadata through the context, making it available to downstream handlers. Finally, it adds relevant metadata to responses, enabling end-to-end tracking of requests.
This interceptor captures RPC metrics:
func MonitoringInterceptor(ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
start := time.Now()
// Extract caller information
peer, _ := peer.FromContext(ctx)
method := info.FullMethod
// Call handler
resp, err := handler(ctx, req)
// Record metrics
duration := time.Since(start)
status := status.Code(err)
metrics.RecordRPCMetrics(method, peer.Addr.String(), status, duration)
return resp, err
}
This pattern demonstrates several key monitoring capabilities. It accurately measures RPC execution time by capturing timestamps before and after the handler call. The interceptor extracts important caller information from the context, allowing you to track which clients are making requests. It records standardized metrics that can be used for monitoring and alerting. Finally, it properly handles error status codes, ensuring that failures are accurately captured in your metrics.
Handle protocol-level errors that occur outside of your service methods:
func ProtocolErrorInterceptor(ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
// Handle context errors
if err := ctx.Err(); err != nil {
switch err {
case context.DeadlineExceeded:
return nil, status.Error(codes.DeadlineExceeded, "request timeout")
case context.Canceled:
return nil, status.Error(codes.Canceled, "request canceled")
}
}
// Call handler (Goa handles mapping of design errors to gRPC codes)
resp, err := handler(ctx, req)
if err != nil {
return nil, err
}
// Handle protocol-specific validation
if err := validateProtocolRequirements(resp); err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return resp, nil
}
This example demonstrates several important aspects of protocol error handling in gRPC interceptors. It shows how to properly handle protocol-specific errors like timeouts and cancellations that can occur during RPC calls. The interceptor preserves Goa’s built-in error mapping functionality for design errors while adding additional protocol-level validation. It also ensures that appropriate gRPC status codes are used when protocol-level issues arise, maintaining consistency with gRPC best practices.
Testing gRPC interceptors requires careful consideration of the gRPC context, metadata handling, and error propagation. Here’s how to use Clue’s mock package to test interceptors effectively:
// Mock implementation for testing
type mockUnaryHandler struct {
*mock.Mock
}
func newMockUnaryHandler(t *testing.T) *mockUnaryHandler {
return &mockUnaryHandler{mock.New()}
}
func (m *mockUnaryHandler) Handle(ctx context.Context, req interface{}) (interface{}, error) {
if f := m.Next("Handle"); f != nil {
return f.(func(context.Context, interface{}) (interface{}, error))(ctx, req)
}
return nil, nil
}
func TestMetadataInterceptor(t *testing.T) {
tests := []struct {
name string
setup func(context.Context, *mockUnaryHandler)
incomingMD metadata.MD
wantReqID bool
wantErr bool
}{
{
name: "adds missing request ID",
setup: func(ctx context.Context, h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("no metadata in context")
}
if ids := md.Get("x-request-id"); len(ids) == 0 {
return nil, fmt.Errorf("no request ID added")
}
return "test response", nil
})
},
incomingMD: metadata.New(nil),
wantReqID: true,
wantErr: false,
},
{
name: "preserves existing request ID",
setup: func(ctx context.Context, h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
md, _ := metadata.FromIncomingContext(ctx)
ids := md.Get("x-request-id")
if len(ids) != 1 || ids[0] != "test-id" {
return nil, fmt.Errorf("request ID not preserved")
}
return "test response", nil
})
},
incomingMD: metadata.Pairs("x-request-id", "test-id"),
wantReqID: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test context with metadata
ctx := metadata.NewIncomingContext(context.Background(), tt.incomingMD)
// Create mock handler
handler := newMockUnaryHandler(t)
if tt.setup != nil {
tt.setup(ctx, handler)
}
// Call interceptor
resp, err := MetadataInterceptor(ctx, "test request",
&grpc.UnaryServerInfo{},
handler.Handle)
// Verify error behavior
if (err != nil) != tt.wantErr {
t.Errorf("MetadataInterceptor() error = %v, wantErr %v", err, tt.wantErr)
}
// Verify all expected calls were made
if handler.HasMore() {
t.Error("not all expected handler operations were performed")
}
})
}
}
// Testing monitoring interceptors
func TestMonitoringInterceptor(t *testing.T) {
tests := []struct {
name string
setup func(*mockUnaryHandler)
wantMetric string
wantErr bool
}{
{
name: "records successful call",
setup: func(h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
// Simulate successful processing
time.Sleep(10 * time.Millisecond)
return "success", nil
})
},
wantMetric: "success",
wantErr: false,
},
{
name: "records failed call",
setup: func(h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, status.Error(codes.Internal, "test error")
})
},
wantMetric: "error",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := newMockUnaryHandler(t)
if tt.setup != nil {
tt.setup(handler)
}
resp, err := MonitoringInterceptor(context.Background(), "test",
&grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"},
handler.Handle)
if (err != nil) != tt.wantErr {
t.Errorf("MonitoringInterceptor() error = %v, wantErr %v", err, tt.wantErr)
}
if handler.HasMore() {
t.Error("not all expected handler operations were performed")
}
})
}
}
// Testing protocol error handling
func TestProtocolErrorInterceptor(t *testing.T) {
tests := []struct {
name string
setup func(context.Context, *mockUnaryHandler)
ctx context.Context
wantCode codes.Code
}{
{
name: "handles deadline exceeded",
setup: func(ctx context.Context, h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, ctx.Err()
})
},
ctx: func() context.Context {
ctx, cancel := context.WithTimeout(context.Background(), 0)
cancel()
return ctx
}(),
wantCode: codes.DeadlineExceeded,
},
{
name: "handles context canceled",
setup: func(ctx context.Context, h *mockUnaryHandler) {
h.Set("Handle", func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, ctx.Err()
})
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
wantCode: codes.Canceled,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := newMockUnaryHandler(t)
if tt.setup != nil {
tt.setup(tt.ctx, handler)
}
resp, err := ProtocolErrorInterceptor(tt.ctx, "test",
&grpc.UnaryServerInfo{},
handler.Handle)
if status.Code(err) != tt.wantCode {
t.Errorf("ProtocolErrorInterceptor() status code = %v, want %v",
status.Code(err), tt.wantCode)
}
if handler.HasMore() {
t.Error("not all expected handler operations were performed")
}
})
}
}
These examples demonstrate several key testing techniques for gRPC interceptors.
First, they show how to effectively use Clue’s mock package to create test
doubles that verify interceptor behavior. The Set
method defines default
behaviors for operations, while Next
allows for sequence-specific responses.
The tests cover verification of metadata handling, metrics recording, and error
status codes. Additionally, they demonstrate how to test complex context
scenarios, such as cancellation and timeouts, which are common in real-world
gRPC applications.