gRPC Interceptors

An interceptor is a function that runs before or after every gRPC call, on either the server or the client side. Interceptors let you add behaviour — logging, authentication, metrics, retry — in one place so that every method in every service gets it automatically.

The Interceptor Concept

Think of an interceptor as a security checkpoint at an airport. Every passenger (request) passes through the checkpoint. The checkpoint can inspect, stamp, redirect, or block — without the passengers or the airline counters (service methods) doing anything extra.

Without interceptors:
  GetUser() {
    log.Info("start")         ← repeated in every method
    validateAuth(ctx)         ← repeated in every method
    recordMetrics()           ← repeated in every method
    // actual logic
  }
  CreateOrder() {
    log.Info("start")         ← repeated again
    validateAuth(ctx)         ← repeated again
    recordMetrics()           ← repeated again
    // actual logic
  }

With interceptors:
  interceptorChain = [logger, authValidator, metricsRecorder]

  GetUser()    ← just business logic; interceptors run automatically
  CreateOrder() ← just business logic; interceptors run automatically

Four Interceptor Types

┌────────────────────────────────┬──────────────────────────────────────┐
│ Type                           │ Applies To                           │
├────────────────────────────────┼──────────────────────────────────────┤
│ Unary Server Interceptor       │ Non-streaming server methods         │
│ Stream Server Interceptor      │ Streaming server methods             │
│ Unary Client Interceptor       │ Non-streaming client calls           │
│ Stream Client Interceptor      │ Streaming client calls               │
└────────────────────────────────┴──────────────────────────────────────┘

Unary Server Interceptor — Anatomy

A unary server interceptor has this exact signature in Go:

  func myInterceptor(
    ctx     context.Context,        // request context (deadline, metadata)
    req     interface{},            // the incoming request message
    info    *grpc.UnaryServerInfo,  // method name and service name
    handler grpc.UnaryHandler,      // the next thing in the chain
  ) (interface{}, error) {

    // === BEFORE the handler runs ===
    log.Printf("Incoming call: %s", info.FullMethod)

    // === CALL the next interceptor or the actual handler ===
    resp, err := handler(ctx, req)

    // === AFTER the handler returns ===
    log.Printf("Call complete: %s, err=%v", info.FullMethod, err)

    return resp, err
  }

Interceptor 1: Logging

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

  start := time.Now()

  // Extract request ID from metadata if present
  md, _ := metadata.FromIncomingContext(ctx)
  reqID := ""
  if vals := md.Get("x-request-id"); len(vals) > 0 {
    reqID = vals[0]
  }

  log.Printf("[%s] START  method=%s", reqID, info.FullMethod)

  resp, err := handler(ctx, req)

  duration := time.Since(start)
  if err != nil {
    st, _ := status.FromError(err)
    log.Printf("[%s] ERROR  method=%s code=%s duration=%v msg=%s",
      reqID, info.FullMethod, st.Code(), duration, st.Message())
  } else {
    log.Printf("[%s] OK     method=%s duration=%v", reqID, info.FullMethod, duration)
  }

  return resp, err
}

Interceptor 2: Panic Recovery

A nil pointer or index-out-of-range panic inside a handler crashes the entire server process if not caught. A recovery interceptor catches panics and converts them into an INTERNAL error so only that one call fails.

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 the stack trace for debugging
      buf := make([]byte, 4096)
      n := runtime.Stack(buf, false)
      log.Printf("PANIC in %s: %v\n%s", info.FullMethod, r, buf[:n])

      // Return a safe error to the client instead of crashing
      err = status.Errorf(codes.Internal, "internal server error")
    }
  }()

  return handler(ctx, req)
}

Interceptor 3: Rate Limiting

import "golang.org/x/time/rate"

type rateLimiter struct {
  limiter *rate.Limiter
}

func (rl *rateLimiter) unaryInterceptor(ctx context.Context, req interface{},
    info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {

  if !rl.limiter.Allow() {
    return nil, status.Errorf(codes.ResourceExhausted,
      "rate limit exceeded, try again later")
  }
  return handler(ctx, req)
}

// Usage: allow 100 calls per second with a burst of 20
rl := &rateLimiter{limiter: rate.NewLimiter(100, 20)}
grpc.NewServer(grpc.ChainUnaryInterceptor(rl.unaryInterceptor))

Chaining Multiple Interceptors

Interceptors form a chain. Each one calls handler to pass control to the next. The order matters: the first interceptor in the list runs outermost (first before, last after).

Interceptor chain execution order:

  grpc.ChainUnaryInterceptor(
    recoveryInterceptor,   // 1st
    loggingInterceptor,    // 2nd
    authInterceptor,       // 3rd
    rateLimitInterceptor,  // 4th
  )

Call arrives:
  recovery.before
    logging.before
      auth.before
        rateLimit.before
          ─── actual handler ───
        rateLimit.after
      auth.after
    logging.after
  recovery.after

Diagram:
  ┌─ recovery ────────────────────────────────────────┐
  │  ┌─ logging ───────────────────────────────────┐  │
  │  │  ┌─ auth ──────────────────────────────┐    │  │
  │  │  │  ┌─ rateLimit ─────────────────┐    │    │  │
  │  │  │  │   HANDLER (your code)       │    │    │  │
  │  │  │  └─────────────────────────────┘    │    │  │
  │  │  └─────────────────────────────────────┘    │  │
  │  └─────────────────────────────────────────────┘  │
  └───────────────────────────────────────────────────┘

Streaming Server Interceptor

Streaming interceptors wrap the entire stream lifetime, not just one message. They receive a grpc.ServerStream object that you can wrap to intercept individual messages.

func streamLoggingInterceptor(srv interface{}, ss grpc.ServerStream,
    info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {

  log.Printf("Stream START: %s (client→server:%v, server→client:%v)",
    info.FullMethod, info.IsClientStream, info.IsServerStream)

  start := time.Now()
  err := handler(srv, ss)   // runs the entire stream to completion

  log.Printf("Stream END:   %s duration=%v err=%v",
    info.FullMethod, time.Since(start), err)
  return err
}

// Wrap the stream to intercept individual messages
type loggingStream struct {
  grpc.ServerStream
}

func (s *loggingStream) RecvMsg(m interface{}) error {
  err := s.ServerStream.RecvMsg(m)
  log.Printf("RecvMsg: %T err=%v", m, err)
  return err
}

func (s *loggingStream) SendMsg(m interface{}) error {
  log.Printf("SendMsg: %T", m)
  return s.ServerStream.SendMsg(m)
}

Client-Side Interceptor

Client interceptors run on the caller's side before the request leaves the process. They are ideal for injecting tokens, adding tracing headers, or retrying on transient failures.

func retryInterceptor(ctx context.Context, method string, req, reply interface{},
    cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
    opts ...grpc.CallOption) error {

  maxRetries := 3

  for attempt := 0; attempt <= maxRetries; attempt++ {
    err := invoker(ctx, method, req, reply, cc, opts...)
    if err == nil {
      return nil
    }

    st, _ := status.FromError(err)
    // Only retry on transient errors
    if st.Code() != codes.Unavailable && st.Code() != codes.DeadlineExceeded {
      return err
    }

    if attempt == maxRetries {
      return err
    }

    // Exponential backoff: 100ms, 200ms, 400ms
    backoff := time.Duration(100*(1<<attempt)) * time.Millisecond
    log.Printf("Retry %d/%d for %s in %v", attempt+1, maxRetries, method, backoff)
    time.Sleep(backoff)
  }
  return nil
}

// Register on the client channel
conn, _ := grpc.NewClient("server:443",
  grpc.WithTransportCredentials(creds),
  grpc.WithChainUnaryInterceptor(retryInterceptor),
)

Built-in gRPC Middleware Libraries

go-grpc-middleware (github.com/grpc-ecosystem/go-grpc-middleware):
  • grpc_zap       — structured logging with Zap
  • grpc_recovery  — panic recovery
  • grpc_validator — proto message validation
  • grpc_auth      — authentication helper
  • grpc_ratelimit — rate limiting

grpc-go built-in:
  • keepalive   — connection health
  • retry       — automatic retry policy via service config

Summary

Interceptors are middleware for gRPC. Server interceptors run on every incoming call. Client interceptors run on every outgoing call. Chain them with grpc.ChainUnaryInterceptor and grpc.ChainStreamInterceptor. Place recovery first in the chain so it catches panics from all other interceptors. Place authentication early so rate limiting and handlers never run for unauthenticated calls.

Leave a Comment

Your email address will not be published. Required fields are marked *