32 Writing Your Own Unary Interceptor
When building gRPC services in the Go ecosystem, we very often run into the need to plug in middleware functions: whether it’s logging requests, authentication validation, auditing, monitoring, or handling errors in a consistent pattern. In the REST world, this kind of thing is commonly done through middleware like in Gin or Echo. In gRPC, the equivalent concept is called an interceptor.
In this article, I’ll take a practical look at Unary Interceptors in gRPC: from understanding the concept and the architecture, to implementation and production tips. We’ll also build a custom unary interceptor from scratch, complete with code examples, a simulation, and flow diagrams.
What Is a Unary Interceptor?
An interceptor in gRPC is a function that “intercepts” the request flow from client to server before the method handler is executed. There are two main types of interceptors in gRPC:
- Unary interceptor – For unary methods (one request, one response).
- Stream interceptor – For streaming methods (client, server, or bidirectional).
In this article we’ll focus on the unary interceptor.
Why Use a Unary Interceptor?
Imagine you want to make sure all of your API endpoints are audited, or you want every request to be logged, or you want JWT validation to run before reaching your core logic. If you write that code over and over in every handler, the code becomes non-reusable and bug-prone. This is exactly where a unary interceptor becomes the solution.
Simply put, with a unary interceptor we have a single place where additional logic can “intercept” every request, either before or after the main handler runs.
Unary Interceptor Flow Diagram
Let’s visualize the process with a flowchart using Mermaid :
flowchart TD
A(Client gRPC Request) --> B[Unary Interceptor]
B --> C{Pre-Processing}
C -->|Pass| D["Handler (Your Actual gRPC Service)"]
D --> E{Post-Processing}
E --> F[gRPC Response]
C -->|Blocked/Error| G[Return Error to Client]
Anatomy of a Unary Interceptor in Go
Let’s look at the signature of a unary interceptor in Go:
1func(
2 ctx context.Context,
3 req interface{},
4 info *grpc.UnaryServerInfo,
5 handler grpc.UnaryHandler,
6) (resp interface{}, err error)Parameter explanation:
ctx: The request context, which can be used to propagate custom values (e.g., a user claim).req: The request payload.info: Contains meta-information (the fully qualified method name, etc.).handler: The function that executes the actual handler.
Typically, we run some pre-processing, then call handler(ctx, req), and add post-processing if needed.
Example Unary Interceptor Implementation: Logging
To get started, let’s build a simple interceptor that logs every incoming request, along with its execution time.
1import (
2 "context"
3 "log"
4 "time"
5 "google.golang.org/grpc"
6)
7
8func LoggingUnaryInterceptor(
9 ctx context.Context,
10 req interface{},
11 info *grpc.UnaryServerInfo,
12 handler grpc.UnaryHandler,
13) (interface{}, error) {
14 start := time.Now()
15 log.Printf("gRPC method: %s - req: %+v", info.FullMethod, req)
16
17 resp, err := handler(ctx, req)
18
19 log.Printf(
20 "gRPC method: %s - resp: %+v - err: %v - duration: %s",
21 info.FullMethod, resp, err, time.Since(start),
22 )
23 return resp, err
24}How to Register the Interceptor on the Server
Once the interceptor is ready, we register it when creating the gRPC server:
1server := grpc.NewServer(
2 grpc.UnaryInterceptor(LoggingUnaryInterceptor),
3)You can also chain several unary interceptors at once using grpc_middleware :
1import "github.com/grpc-ecosystem/go-grpc-middleware"
2
3server := grpc.NewServer(
4 grpc_middleware.WithUnaryServerChain(
5 AuthUnaryInterceptor,
6 LoggingUnaryInterceptor,
7 MetricsUnaryInterceptor,
8 ),
9)Simulation: Comparing Before & After the Interceptor
Here is a simulation of the request flow with and without a unary interceptor:
| Without Interceptor | With Interceptor | |
|---|---|---|
| 1 | gRPC Request Arrives | gRPC Request Arrives |
| 2 | Handler runs | Pre-processing by the Interceptor |
| 3 | Handler runs | Handler runs |
| 4 | Response sent to client | Post-processing by the Interceptor |
| 5 | Response sent to client |
Case Study: Auth Interceptor with JWT
Let’s build an interceptor that validates a JWT token from the metadata before the handler is executed. If the token is invalid, return an error immediately; if it’s valid, propagate the user claim into the context.
Implementation:
1import (
2 "context"
3 "errors"
4 "strings"
5 "google.golang.org/grpc"
6 "google.golang.org/grpc/metadata"
7)
8
9func AuthUnaryInterceptor(
10 ctx context.Context,
11 req interface{},
12 info *grpc.UnaryServerInfo,
13 handler grpc.UnaryHandler,
14) (interface{}, error) {
15 md, ok := metadata.FromIncomingContext(ctx)
16 if !ok {
17 return nil, errors.New("missing metadata")
18 }
19
20 // Get the Authorization header
21 tokens := md["authorization"]
22 if len(tokens) == 0 {
23 return nil, errors.New("missing authorization token")
24 }
25
26 token := strings.TrimPrefix(tokens[0], "Bearer ")
27
28 // Validate the token here (pseudocode)
29 claims, err := ValidateJWT(token)
30 if err != nil {
31 return nil, errors.New("invalid token")
32 }
33
34 // Propagate the claim into the context
35 newCtx := context.WithValue(ctx, "user", claims)
36
37 // Forward to the main handler with the new context
38 return handler(newCtx, req)
39}The Handler Can Access the User Claim
1user := ctx.Value("user").(UserClaims)
2// the user can now be accessed inside the handlerBest Practices for Writing Unary Interceptors
- Keep the logic simple: Make sure the work inside the interceptor isn’t too heavy, so latency doesn’t balloon.
- Handle errors explicitly: When an error occurs, make sure to return an informative error.
- Propagate context properly: Insert anything into the context clearly so the handler can access it.
- Separate pre and post processing concerns: Keep the logic before and after the handler runs distinct.
- Chain interceptors in the right order: For example: Auth → Logging → Metrics.
Understanding Interceptor Chaining and Composition
One of the advantages of interceptors is that they’re easy to compose. With chaining, the order of execution can be illustrated as follows:
sequenceDiagram
participant Client
participant Interceptor1
participant Interceptor2
participant Handler
Client->>Interceptor1: Request
Interceptor1->>Interceptor2: Pre-processing
Interceptor2->>Handler: Pre-processing
Handler-->>Interceptor2: Response
Interceptor2-->>Interceptor1: Post-processing
Interceptor1-->>Client: Return Response
Conclusion
A unary interceptor is an essential feature for building gRPC services that are robust, concise, and maintainable. By understanding its architecture and implementation patterns, we can easily add functionality such as logging, authentication, rate-limiting, and observability without duplicating code in every handler.
With the examples and case study above, you are now ready to write your own unary interceptor to fit your application’s needs! And remember, design your interceptors with good efficiency and clarity to produce services and products that are reliable and scalable.
References:
Happy refactoring!