11 - Interceptors

📋 Jump to Takeaways

Interceptors are gRPC's version of middleware. They sit between the client/server and the RPC handler, letting you add logging, authentication, metrics, or any cross-cutting concern without touching your business logic.

Unary Server Interceptor

A unary server interceptor wraps every unary RPC call. Add "time" to your imports in main.go:

func loggingInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    start := time.Now()

    // Call the actual handler
    resp, err := handler(ctx, req)

    log.Printf("method=%s duration=%s err=%v",
        info.FullMethod, time.Since(start), err)

    return resp, err
}

The signature looks intimidating but it's simple: you get the context, the request, info about which method was called, and the next handler in the chain. Call handler(ctx, req) to proceed, or return early to short-circuit.

Register it when creating the server:

srv := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
)

Stream Server Interceptor

Streaming RPCs have their own interceptor type:

func streamLoggingInterceptor(
    srv interface{},
    ss grpc.ServerStream,
    info *grpc.StreamServerInfo,
    handler grpc.StreamHandler,
) error {
    start := time.Now()

    err := handler(srv, ss)

    log.Printf("stream method=%s duration=%s err=%v",
        info.FullMethod, time.Since(start), err)

    return err
}

Register it separately:

srv := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
    grpc.StreamInterceptor(streamLoggingInterceptor),
)

Chaining Multiple Interceptors

You often need more than one interceptor. gRPC supports chaining:

srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        loggingInterceptor,
        authInterceptor,
        recoveryInterceptor,
    ),
    grpc.ChainStreamInterceptor(
        streamLoggingInterceptor,
        streamAuthInterceptor,
    ),
)

Interceptors run in order. The first one in the list runs first, calls the next, and so on. Like HTTP middleware, the order matters.

Authentication Interceptor

Here's a practical example. Check for an API key in the metadata (gRPC's equivalent of HTTP headers). Metadata is a set of key-value string pairs sent alongside every RPC call. We'll cover it in depth in lesson 13; for now, just know it's how you pass auth tokens, request IDs, and other context.

func authInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    // Skip auth for health checks
    if info.FullMethod == "/grpc.health.v1.Health/Check" {
        return handler(ctx, req)
    }

    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }

    keys := md.Get("authorization")
    if len(keys) == 0 || keys[0] != "Bearer my-secret-key" {
        return nil, status.Error(codes.Unauthenticated, "invalid api key")
    }

    return handler(ctx, req)
}

The interceptor extracts metadata from the context, checks the authorization value, and either proceeds or returns an error. The client never reaches your handler if auth fails.

Add these imports to main.go:

"google.golang.org/grpc/metadata"

You should already have codes and status from earlier lessons.

Recovery Interceptor

Catch panics so one bad request doesn't crash the whole server:

func recoveryInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (resp interface{}, err error) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("panic recovered: %v", r)
            err = status.Errorf(codes.Internal, "internal error")
        }
    }()

    return handler(ctx, req)
}

Client Interceptors

Clients have interceptors too. Useful for adding auth headers, logging outgoing calls, or retrying failed requests:

func clientLoggingInterceptor(
    ctx context.Context,
    method string,
    req, reply interface{},
    cc *grpc.ClientConn,
    invoker grpc.UnaryInvoker,
    opts ...grpc.CallOption,
) error {
    start := time.Now()

    err := invoker(ctx, method, req, reply, cc, opts...)

    log.Printf("client call=%s duration=%s err=%v",
        method, time.Since(start), err)

    return err
}

// Register on the client connection
conn, err := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithUnaryInterceptor(clientLoggingInterceptor),
)

Client Auth Interceptor

Automatically attach credentials to every outgoing call:

func clientAuthInterceptor(
    ctx context.Context,
    method string,
    req, reply interface{},
    cc *grpc.ClientConn,
    invoker grpc.UnaryInvoker,
    opts ...grpc.CallOption,
) error {
    ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer my-secret-key")
    return invoker(ctx, method, req, reply, cc, opts...)
}

This pairs with the server auth interceptor from earlier. The client adds the key, the server checks it.

Key Takeaways

  • Interceptors are gRPC middleware for cross-cutting concerns
  • Unary and stream interceptors have different signatures
  • Use ChainUnaryInterceptor and ChainStreamInterceptor for multiple interceptors
  • Order matters: first interceptor runs first
  • Common interceptors: logging, auth, recovery, metrics
  • Client interceptors work the same way, useful for adding auth headers automatically

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy