20 Adding a Unary Interceptor on the Client
When building distributed systems with gRPC, we often need logging, monitoring, validation, or data transformation on the requests/responses that travel between the client and the server. Of course, it would be unwise to sprinkle every bit of extra logic into each individual RPC call. Fortunately, gRPC provides a very powerful and modular concept called the interceptor. One of the most commonly used is the Unary Interceptor on the client side.
This article takes a deep dive into what, why, and how to add a Unary Interceptor to a gRPC client, using examples written in Go. We will dissect common use cases, walk through the code, and run a simulation while visualizing the data flow with a diagram.
What Is a Unary Interceptor on the Client?
Put simply, an interceptor is middleware (or a hook) that we can plug into the life cycle of an RPC call. For a unary RPC (an RPC with one request and one response), a unary interceptor works by intercepting every outgoing request to the server before it actually happens, and by handling the response before it is finally returned to the application.
Using interceptors allows us to:
- Log requests and responses
- Configure retries, timeouts, and deadlines
- Validate data before it is sent
- Perform tracing (OpenTelemetry, Jaeger, etc.)
- Handle authentication/security (adding headers, and so on)
- Implement custom error handling
Because they are reusable, the code becomes cleaner, more maintainable, and DRY (Don’t Repeat Yourself).
The Unary Interceptor Flow
Let’s first look at how data flows when we use a unary interceptor on the client.
graph LR
A[Application Code] --> B[Unary Interceptor]
B --> C[gRPC Client Stub]
C --> D[gRPC Server]
D --> C
C --> B
B --> A
- A: The application code that calls the RPC.
- B: The interceptor, which intercepts the request (and the response).
- C: The gRPC library/stub.
- D: The gRPC server.
The interceptor is invoked every time there is a call to any gRPC method. Inside the interceptor, we can run additional logic before and/or after invoker() (the actual handler).
How to Add a Unary Interceptor on the Client (Go)
1. Preparation
Make sure you have a gRPC project and the following dependency:
1go get google.golang.org/grpcWe’ll assume you already have a stub generated from the proto (for example: pb.MyServiceClient).
2. Implementing the Unary Interceptor
The general signature of a unary interceptor in Go looks like this:
1func MyUnaryClientInterceptor(
2 ctx context.Context,
3 method string,
4 req, reply interface{},
5 cc *grpc.ClientConn,
6 invoker grpc.UnaryInvoker,
7 opts ...grpc.CallOption,
8) error {
9 // Logic before the request
10 err := invoker(ctx, method, req, reply, cc, opts...)
11 // Logic after the request
12 return err
13}invoker()is the actual call to the service. You must call it, unless you intentionally want to reject sending the request to the server.- You can place logic before (pre), after (post), or even block/pass through the request.
Example: Logging Interceptor
For instance, suppose we want to record the request and response time every time the client calls an RPC method.
1import (
2 "context"
3 "log"
4 "time"
5 "google.golang.org/grpc"
6)
7
8func LoggingUnaryClientInterceptor(
9 ctx context.Context,
10 method string,
11 req, reply interface{},
12 cc *grpc.ClientConn,
13 invoker grpc.UnaryInvoker,
14 opts ...grpc.CallOption,
15) error {
16 start := time.Now()
17 log.Printf("[Client Interceptor] >> Outgoing request: %s", method)
18 err := invoker(ctx, method, req, reply, cc, opts...) // send the request
19 elapsed := time.Since(start)
20 log.Printf("[Client Interceptor] << Response from %s in %s Err:%v", method, elapsed, err)
21 return err
22}3. Attaching the Interceptor to the Client
In gRPC Go, a unary interceptor is attached as a dial option when creating the client connection.
1conn, err := grpc.Dial(
2 "localhost:50051",
3 grpc.WithInsecure(),
4 grpc.WithUnaryInterceptor(LoggingUnaryClientInterceptor),
5)
6if err != nil {
7 log.Fatalf("did not connect: %v", err)
8}
9defer conn.Close()
10client := pb.NewMyServiceClient(conn)After this, every unary RPC called by the client will automatically pass through the LoggingUnaryClientInterceptor middleware we defined.
Case Study: Adding an Authentication Token
Suppose we want to inject an auth token header into every request so that the server can recognize the client’s identity.
1import (
2 "context"
3 "google.golang.org/grpc/metadata"
4)
5
6func AuthTokenUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {
7 return func(
8 ctx context.Context,
9 method string,
10 req, reply interface{},
11 cc *grpc.ClientConn,
12 invoker grpc.UnaryInvoker,
13 opts ...grpc.CallOption,
14 ) error {
15 // Inject the auth header into the metadata
16 md := metadata.Pairs("authorization", "Bearer "+token)
17 ctx = metadata.NewOutgoingContext(ctx, md)
18 return invoker(ctx, method, req, reply, cc, opts...)
19 }
20}Attach it when dialing:
1conn, err := grpc.Dial(
2 "localhost:50051",
3 grpc.WithInsecure(),
4 grpc.WithUnaryInterceptor(AuthTokenUnaryClientInterceptor("token123")),
5)Chaining Multiple Interceptors
On Go 1.36+, use a third-party package for chaining (for example: grpc_middleware). Example:
1import (
2 "github.com/grpc-ecosystem/go-grpc-middleware"
3)
4
5conn, err := grpc.Dial(
6 "localhost:50051",
7 grpc.WithUnaryInterceptor(
8 grpc_middleware.ChainUnaryClient(
9 LoggingUnaryClientInterceptor,
10 AuthTokenUnaryClientInterceptor("token123"),
11 ),
12 ),
13)Each interceptor runs in the order it appears in the chain.
Output Simulation
Imagine the client calls a single method, GetUser(ctx, req), on the server, using two interceptors: Logging and Auth.
Process Simulation Table:
| Step | Module | Action | Detail |
|---|---|---|---|
| 1 | App Code | Call GetUser() | |
| 2 | Logging Int. | Record start time, log outgoing method | GetUser |
| 3 | Auth Int. | Inject Auth header into outgoing context | Header: Bearer token123 |
| 4 | gRPC stub | Send the request to the server | |
| 5 | Logging Int. | Measure time, log response | Show latency |
Best Practices
- Always call
invoker()inside the interceptor so the request still goes through. - Split the logic across several interceptors by concern (logging, auth, tracing).
- Make sure the interceptor order matches your needs (for example: inject the header before logging/after).
- Use a chain/stack when needed so it stays maintainable and scalable.
- Test your interceptors at both the unit and integration level to make sure they don’t accidentally change the application’s behavior.
Conclusion
Adding a unary interceptor on the gRPC client is a very powerful technique for keeping your code clean, DRY, and scalable even as the application grows more complex. Whether for logging, monitoring, auth, or anything else, interceptors offer a single central place to extend the application’s behavior in a modular way.
If you are just getting started with gRPC, try the implementation above and see how easy it is to enrich the client’s behavior without having to modify each call one by one.
Don’t hesitate to experiment with other use cases built on this interceptor foundation, and happy coding!