gRPC Middleware Chaining — Composing Interceptors for Logging, Auth, and Metrics
In this tutorial, you will learn about grpc middleware chaining. We cover key concepts, practical examples, and best practices to help you master this topic.
gRPC middleware chaining composes multiple interceptors into a pipeline, where each interceptor handles a cross-cutting concern like logging, authentication, rate limiting, or metrics before passing control to the next interceptor and eventually the handler.
What You'll Learn
- Composing multiple interceptors in order
- Building reusable interceptor libraries
- Error handling in interceptor chains
- Passing data between interceptors
- Performance considerations for long chains
- Testing interceptor chains
Why It Matters
A production gRPC service needs logging, auth, rate limiting, metrics, tracing, and error handling — all implemented as interceptors. Proper chaining ensures they compose correctly without interfering. DodaTech's Durga Antivirus Pro uses a chain of 7 interceptors per service: logging, auth, rate limit, tracing, metrics, request validation, and error recovery.
Real-World Use
A request arrives at the threat analysis service. The interceptor chain processes it in order: logging records the incoming request, auth validates the JWT, rate limiting checks quota, tracing creates a span, metrics increments the request counter, validation checks the message, and error recovery catches any panics.
flowchart LR
A["Incoming RPC"] --> B["1. Logging\nInterceptor"]
B --> C["2. Auth\nInterceptor"]
C --> D["3. Rate Limit\nInterceptor"]
D --> E["4. Tracing\nInterceptor"]
E --> F["5. Metrics\nInterceptor"]
F --> G["6. Validation\nInterceptor"]
G --> H["7. Recovery\nInterceptor"]
H --> I["Handler"]
I --> J["Response"]
style A fill:#dbeafe,stroke:#2563eb
style I fill:#bbf7d0,stroke:#16a34a
Code Examples
Example 1: Chaining Interceptors in Go
package main
import (
"google.golang.org/grpc"
)
func main() {
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInterceptor,
rateLimitInterceptor,
tracingInterceptor,
recoveryInterceptor,
),
grpc.ChainStreamInterceptor(
streamLoggingInterceptor,
streamAuthInterceptor,
),
)
pb.RegisterThreatServiceServer(s, &server{})
}
func loggingInterceptor(ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
log.Info("request started",
"method", info.FullMethod,
"request", req,
)
resp, err := handler(ctx, req)
log.Info("request completed",
"method", info.FullMethod,
"error", err,
)
return resp, err
}
func authInterceptor(ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated,
"missing metadata")
}
token := md.Get("authorization")
if len(token) == 0 {
return nil, status.Error(codes.Unauthenticated,
"missing auth token")
}
// Validate and inject user context
claims, err := validateJWT(token[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated,
"invalid token")
}
newCtx := context.WithValue(ctx, "user", claims)
return handler(newCtx, req)
}
Example 2: Python Interceptor Chain
import grpc
from grpc_interceptor import ServerInterceptor
from grpc_interceptor.exceptions import GrpcException
class LoggingInterceptor(ServerInterceptor):
def intercept(self, method, request, context, method_name):
print(f"[{method_name}] request: {request}")
try:
response = method(request, context)
print(f"[{method_name}] success")
return response
except Exception as e:
print(f"[{method_name}] error: {e}")
raise
class AuthInterceptor(ServerInterceptor):
def __init__(self, jwt_validator):
self.jwt_validator = jwt_validator
def intercept(self, method, request, context, method_name):
auth = dict(context.invocation_metadata())
token = auth.get("authorization", "")
if not token:
context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Missing auth token",
)
user = self.jwt_validator.validate(token)
if not user:
context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Invalid auth token",
)
# Set user in context for downstream
context.user = user
return method(request, context)
# Build chain
server = grpc.server(
grpc.insecure_server(),
interceptors=[
LoggingInterceptor(),
AuthInterceptor(jwt_validator),
RateLimitInterceptor(),
MetricsInterceptor(),
],
)
Example 3: Passing Data Between Interceptors in Node.js
const grpc = require('@grpc/grpc-js');
class InterceptorChain {
constructor() {
this.interceptors = [];
}
add(interceptor) {
this.interceptors.push(interceptor);
}
build() {
return (options, nextCall) => {
let currentIndex = 0;
const runInterceptor = (callOptions) => {
if (currentIndex >= this.interceptors.length) {
return nextCall(callOptions);
}
const interceptor = this.interceptors[currentIndex];
currentIndex++;
return interceptor(callOptions, runInterceptor);
};
return runInterceptor(options);
};
}
}
// Shared context between interceptors
class InterceptorContext {
constructor() {
this.data = new Map();
}
set(key, value) {
this.data.set(key, value);
}
get(key) {
return this.data.get(key);
}
}
// Example interceptors
const authInterceptor = (options, next) => {
const ctx = options.context;
const metadata = options.metadata;
const token = metadata.get('authorization')[0];
if (!token) {
throw new Error('Missing token');
}
ctx.set('user', decodeJWT(token));
return next(options);
};
const loggingInterceptor = (options, next) => {
console.log(`Starting ${options.methodDefinition.path}`);
const start = Date.now();
try {
const result = next(options);
console.log(`${options.methodDefinition.path} took ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`${options.methodDefinition.path} failed:`, error);
throw error;
}
};
// Usage
const chain = new InterceptorChain();
chain.add(loggingInterceptor);
chain.add(authInterceptor);
chain.add(rateLimitInterceptor);
const client = new ThreatServiceClient(
'localhost:50051',
grpc.credentials.createInsecure(),
{
interceptors: [chain.build()],
},
);
Common Mistakes
- Putting interceptors in the wrong order — auth should come before rate limiting (no point rate-limiting unauthenticated requests), and logging should be first to capture all requests.
- Modifying request between interceptors — if auth modifies the request context, downstream interceptors must use the modified context, not the original.
- Not handling panics in interceptors — a panic in one interceptor crashes the entire server. Add a recovery interceptor at the start of the chain.
- Making interceptors too slow — interceptors add latency to every request. Keep them fast (under 1ms each). Move heavy processing (e.g., async logging) to background.
- Testing interceptors in isolation only — test the full chain to verify interaction between interceptors, like auth setting context that logging reads.
Practice Questions
- What is the correct order of interceptors in a typical production chain?
- How do you pass data between interceptors in the chain?
- Why should auth come before rate limiting?
- How do you handle errors from downstream interceptors?
- What happens if an interceptor panics in the middle of the chain?
Challenge: Design a chain of 8 interceptors for a production gRPC service: request ID generation, structured logging, auth (JWT validation), rate limiting (per-user), tracing (OpenTelemetry), metrics (Prometheus), request validation, and panic recovery.
Mini Project
Build a reusable gRPC interceptor chain library with: 10 pre-built interceptors (logging, auth, rate limit, tracing, metrics, validation, recovery, request ID, compression, Caching), chain Builder with ordering validation, and per-method interceptor configuration.
FAQ
What's Next
Learn more about gRPC interceptors
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro