惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
小众软件
小众软件
博客园 - 叶小钗
宝玉的分享
宝玉的分享
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
B
Blog RSS Feed
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
Google DeepMind News
Google DeepMind News
U
Unit 42
F
Fortinet All Blogs
IT之家
IT之家
Y
Y Combinator Blog
Martin Fowler
Martin Fowler
T
The Blog of Author Tim Ferriss
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The GitHub Blog
The GitHub Blog
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
酷 壳 – CoolShell
酷 壳 – CoolShell

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
gRPC Interceptors in Node.js: Auth, Logging, Error Handli...
The Practica · 2026-06-08 · via The Practical Developer

Open any realistic gRPC service in Node.js and you will find the same five things copy-pasted into every handler: authenticate the caller, log the request, start a timer, catch errors and map them to gRPC status codes, increment a metric on the way out. The handlers themselves are 30 lines, but the wrapping ceremony around each one is 60 lines of boilerplate that rots differently every time someone adds a new RPC.

gRPC has a solution for this: interceptors. They work like Express middleware but for gRPC — a chain of functions that wrap every unary, server-streaming, client-streaming, and bidirectional call. They are part of the Node.js gRPC library (both @grpc/grpc-js and the grpc package support them), yet most production codebases I audit still paste the boilerplate.

This post gives you the interceptor pattern that separates cross-cutting concerns from business logic: a chain that handles auth, structured logging, error mapping, and metrics in under 200 lines of TypeScript. Every piece is testable in isolation, every interceptor can be removed without touching handlers, and the pattern works for unary calls, streams, and client-side calls equally.

The problem in code

A typical unary handler without interceptors looks like this:

import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import { authenticate } from '../auth';
import { logger } from '../logging';
import { metrics } from '../metrics';

async function getOrder(
  call: ServerUnaryCall<GetOrderRequest, GetOrderResponse>,
  callback: sendUnaryData<GetOrderResponse>
) {
  // boilerplate
  const start = Date.now();
  logger.info({ method: 'getOrder', requestId: call.metadata.get('x-request-id')[0] });

  try {
    const user = authenticate(call.metadata);
    if (!user) {
      callback({ code: grpc.status.UNAUTHENTICATED, message: 'invalid token' });
      metrics.incr('grpc.getOrder.auth_error');
      return;
    }

    // actual business logic
    const order = await db.query('SELECT * FROM orders WHERE id = $1', [call.request.orderId]);
    if (!order) {
      callback({ code: grpc.status.NOT_FOUND, message: 'order not found' });
      metrics.incr('grpc.getOrder.not_found');
      return;
    }

    metrics.timing('grpc.getOrder.duration', Date.now() - start);
    metrics.incr('grpc.getOrder.success');
    callback(null, { order });
  } catch (err) {
    logger.error({ err, method: 'getOrder' });
    callback({ code: grpc.status.INTERNAL, message: 'internal error' });
    metrics.incr('grpc.getOrder.error');
  }
}

Now multiply that by 15 RPCs. The business logic is the three lines in the middle. Everything else is copy-paste overhead that every engineer on the team modifies slightly differently, producing a slow leak of inconsistency that makes it impossible to change auth or add a new metric without touching every file.

What an interceptor actually is

A gRPC interceptor is a function that receives the call, the metadata, and a reference to the next interceptor or handler in the chain. It can inspect or mutate any of them, and it must call the next function or produce the final response.

For @grpc/grpc-js, the server interceptor signature is:

type ServerInterceptor = (
  call: ServerUnaryCall<any, any> | ServerWritableStream<any, any> |
        ServerReadableStream<any, any> | ServerDuplexStream<any, any>,
  metadata: Metadata,
  next: (call: any, metadata: Metadata) => void
) => void;

The interceptor calls next() to continue the chain (or not, if it wants to reject early). After next(), it can wrap the response handling by listening to events on the call object.

This is the key insight: interceptors surround the entire lifecycle of a call, not just the request path. You can run code before the handler, after the response is sent, and on errors, all from a single function.

The interceptor chain

Here is the interceptor chain that replaces the 60 lines of boilerplate per handler with five composable interceptors:

1. Auth interceptor

// interceptors/auth.ts
import { Metadata, ServerUnaryCall, status } from '@grpc/grpc-js';

export function authInterceptor(userExtractor: (meta: Metadata) => string | null) {
  return (
    call: ServerUnaryCall<any, any>,
    metadata: Metadata,
    next: (call: any, metadata: Metadata) => void
  ) => {
    const userId = userExtractor(metadata);
    if (!userId) {
      call.sendError({ code: status.UNAUTHENTICATED, message: 'missing or invalid auth token' });
      return;
    }
    // Attach the userId to metadata so downstream interceptors and
    // the handler can read it without re-parsing the token.
    metadata.add('x-authenticated-user', userId);
    next(call, metadata);
  };
}

The auth interceptor exits early with call.sendError if the token is missing or invalid. It never calls next, so the handler never runs. If the token is valid, it attaches the user ID to the metadata object and passes control to the next interceptor.

This is critical because of how gRPC metadata works: it is available in the handler via call.metadata, so any interceptor upstream can enrich it.

2. Structured logging interceptor

// interceptors/logging.ts
import { Metadata, ServerUnaryCall, ServerWritableStream,
         ServerReadableStream, ServerDuplexStream, status } from '@grpc/grpc-js';
import { logger } from '../logger';

type ServerCall = ServerUnaryCall<any, any> | ServerWritableStream<any, any> |
                  ServerReadableStream<any, any> | ServerDuplexStream<any, any>;

export function loggingInterceptor(
  call: ServerCall,
  metadata: Metadata,
  next: (call: ServerCall, metadata: Metadata) => void
) {
  const requestId = metadata.get('x-request-id')[0] || 'unknown';
  const method = call.getPath(); // e.g., "/orders.OrderService/GetOrder"
  const userId = metadata.get('x-authenticated-user')[0] || 'anonymous';

  logger.info({
    msg: 'incoming request',
    method,
    requestId,
    userId,
    type: call.constructor.name,
  });

  const start = Date.now();

  // Intercept the response by wrapping the sendError and listen for finish.
  const originalSendError = call.sendError.bind(call);
  call.sendError = ((error: any) => {
    const duration = Date.now() - start;
    logger.info({
      msg: 'request failed',
      method,
      requestId,
      duration,
      errorCode: error?.code,
      errorMessage: error?.details,
    });
    return originalSendError(error);
  }) as typeof call.sendError;

  call.on('finish', () => {
    const duration = Date.now() - start;
    logger.info({
      msg: 'request completed',
      method,
      requestId,
      duration,
    });
  });

  next(call, metadata);
}

The logging interceptor wraps the lifecycle: log on entry, log on error (via sendError override), and log on success (via the finish event). Because it uses call.getPath() (not a hardcoded string), it works for every RPC in the service with zero per-handler configuration.

The sendError override is safe here because it runs before the handler gets control. Each interceptor can wrap sendError as long as they are stacked in the right order (outermost interceptor wraps first, and calls the original when done).

3. Error mapping interceptor

gRPC has a well-defined status code model. Your handlers should throw domain errors with meaningful codes, not INTERNAL for everything:

// interceptors/errors.ts
import { Metadata, ServerUnaryCall, status } from '@grpc/grpc-js';

export class GrpcDomainError extends Error {
  constructor(
    message: string,
    public code: status
  ) {
    super(message);
    this.name = 'GrpcDomainError';
  }
}

export function errorMappingInterceptor(
  call: ServerUnaryCall<any, any>,
  metadata: Metadata,
  next: (call: ServerUnaryCall<any, any>, metadata: Metadata) => void
) {
  // Override sendError to catch domain errors
  const originalNext = next;
  const wrappedNext = (call: any, metadata: Metadata) => {
    try {
      originalNext(call, metadata);
    } catch (err) {
      if (err instanceof GrpcDomainError) {
        call.sendError({ code: err.code, message: err.message, details: err.message });
      } else if (err && typeof err === 'object' && 'code' in err && typeof err.code === 'number') {
        call.sendError({ code: err.code, message: (err as Error).message });
      } else if (err instanceof Error) {
        call.sendError({
          code: status.INTERNAL,
          message: 'internal error',
          details: process.env.NODE_ENV === 'development' ? err.message : undefined,
        });
      } else {
        call.sendError({ code: status.UNKNOWN, message: 'unknown error' });
      }
    }
  };

  next(call, metadata);
  // Note: gRPC interceptors handle errors differently; for a cleaner
  // approach, see the final composition below.
}

A cleaner approach wraps the call’s sendError and the handler execution together. The full composition in the next section shows the proper pattern.

4. Metrics interceptor

// interceptors/metrics.ts
import { Metadata, ServerUnaryCall } from '@grpc/grpc-js';
import { metrics } from '../metrics';

export function metricsInterceptor(
  call: ServerUnaryCall<any, any>,
  metadata: Metadata,
  next: (call: ServerUnaryCall<any, any>, metadata: Metadata) => void
) {
  const method = call.getPath();
  const start = Date.now();

  call.on('finish', () => {
    const duration = Date.now() - start;
    metrics.timing('grpc.handler.duration', duration, { method });
  });

  const originalSendError = call.sendError.bind(call);
  call.sendError = ((error: any) => {
    metrics.incr('grpc.handler.error', { method, code: String(error?.code ?? 'unknown') });
    return originalSendError(error);
  }) as typeof call.sendError;

  metrics.incr('grpc.handler.started', { method });
  next(call, metadata);
}

Each interceptor focusses on exactly one concern. None of them know about the other interceptors. None of them touch the handler. And every one of them works without a single change to the handler code.

Wiring interceptors together

Interceptors are registered at server creation time via the interceptors option:

// server.ts
import * as grpc from '@grpc/grpc-js';
import { authInterceptor } from './interceptors/auth';
import { loggingInterceptor } from './interceptors/logging';
import { metricsInterceptor } from './interceptors/metrics';
import { errorMappingInterceptor } from './interceptors/errors';

const server = new grpc.Server({
  interceptors: [
    // Order matters: the first interceptor listed wraps the outermost layer.
    // Auth runs first, then logging, then metrics, then error mapping.
    authInterceptor((meta) => {
      const token = meta.get('authorization')[0] as string | undefined;
      if (!token) return null;
      return verifyToken(token.replace('Bearer ', ''));
    }),
    loggingInterceptor,
    metricsInterceptor,
    errorMappingInterceptor,
  ],
});

The interceptor chain for a single request now looks like this:

client -> auth -> logging -> metrics -> error-mapping -> handler -> error-mapping -> metrics -> logging -> client

Every interceptor runs on the way in and (via event listeners and sendError overrides) on the way out. The handler becomes a simple function with no cross-cutting concerns:

// handlers.ts
async function getOrder(
  call: ServerUnaryCall<GetOrderRequest, GetOrderResponse>,
  callback: sendUnaryData<GetOrderResponse>
) {
  const userId = call.metadata.get('x-authenticated-user')[0];
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [call.request.orderId]);
  if (!order) {
    throw new GrpcDomainError('order not found', grpc.status.NOT_FOUND);
  }
  callback(null, { order });
}

The handler is nine lines. No auth parsing, no log statements, no metric increments, no try/catch for status code mapping. Every cross-cutting concern lives in exactly one file.

The three traps that break interceptor chains

Trap 1: Order inversion

If you put errorMappingInterceptor before loggingInterceptor, the error mapping runs before the logging interceptor sees the error. The logging interceptor logs the success (because next() returned without throwing), but the handler actually failed. The interceptor chain is a stack: the first interceptor listed wraps everything below it.

Fix: put error mapping, logging, and metrics AFTER auth in the stack. Auth rejects early (it controls the gate). Everything else wraps the output path.

Trap 2: Async error swallowing

gRPC interceptors in @grpc/grpc-js handle synchronous throws from next() but do not automatically catch rejected promises. If your handler is async and throws, the interceptor chain will not see the error. You must ensure that next() is called inside a try/catch (for sync errors) and that async handler errors are caught at the point the handler is invoked.

The safest pattern is to wrap next() with a promise-aware error boundary:

function safeNext(call: any, metadata: Metadata, next: Function) {
  try {
    const result = next(call, metadata);
    if (result && typeof result.then === 'function') {
      result.catch((err: any) => {
        if (!call.writableEnded) {
          call.sendError({ code: status.INTERNAL, message: err?.message ?? 'unexpected error' });
        }
      });
    }
  } catch (err) {
    if (!call.writableEnded) {
      call.sendError({ code: status.INTERNAL, message: (err as Error)?.message ?? 'unexpected error' });
    }
  }
}

This is not a hypothetical trap. I have seen interceptor chains that silently drop errors because an async handler rejects, nobody catches it, and the client hangs until the gRPC timeout fires. The client gets a DEADLINE_EXCEEDED instead of INTERNAL, and debugging takes hours.

Trap 3: Metadata mutation order

Multiple interceptors reading and writing to the same metadata object works as long as they are order-aware. The auth interceptor writes x-authenticated-user. The logging interceptor reads it. If you reorder them, the logging interceptor reads an empty metadata field.

The fix is a convention: document which interceptors write to metadata and which read from it. Or use a separate context system that is not metadata-based, like AsyncLocalStorage:

import { AsyncLocalStorage } from 'node:async_hooks';

export const requestContext = new AsyncLocalStorage<{ userId: string; requestId: string }>();

// In the auth interceptor:
requestContext.enterWith({ userId, requestId: metadata.get('x-request-id')[0] as string });

// In the handler:
const { userId } = requestContext.getStore()!;

This eliminates the metadata ordering dependency and gives you a single source of truth for per-request context that works across the entire async chain.

Client-side interceptors

The same pattern works for client calls. Client interceptors wrap every outgoing request:

import * as grpc from '@grpc/grpc-js';

function clientLoggingInterceptor(
  options: any,
  nextCall: (options: any) => any
) {
  return (method: any, request: any, metadata: any) => {
    const start = Date.now();
    const call = nextCall(options)(method, request, metadata);
    call.on('finish', () => {
      logger.info({ method: method.path, duration: Date.now() - start });
    });
    return call;
  };
}

const client = new MyServiceClient(
  'server:50051',
  grpc.credentials.createInsecure(),
  {
    interceptors: [clientLoggingInterceptor],
  }
);

Client-side interceptors are invaluable for adding tracing headers, logging outgoing call latency, and applying client-side retry logic without wrapping every client.getOrder() call in a retry function.

Testing interceptors in isolation

Because each interceptor is a pure function that takes (call, metadata, next) and returns nothing, you can test it without a running gRPC server:

import { EventEmitter } from 'node:events';
import { Metadata } from '@grpc/grpc-js';

test('auth interceptor rejects missing tokens', () => {
  const call = new EventEmitter() as any;
  call.sendError = vi.fn();
  call.getPath = () => '/orders.OrderService/GetOrder';

  const metadata = new Metadata();
  const next = vi.fn();

  authInterceptor(() => null)(call, metadata, next);

  expect(call.sendError).toHaveBeenCalledWith(
    expect.objectContaining({ code: grpc.status.UNAUTHENTICATED })
  );
  expect(next).not.toHaveBeenCalled();
});

No gRPC server, no protobuf loading, no new Server(). The interceptor either calls next() or calls sendError. Assert on those two things. This makes interceptor testing faster and more reliable than testing handlers, which means you can build a test suite that validates auth failures, error mapping, metrics emission, and logging output in under 10ms per test.

When not to use interceptors

Interceptors add indirection. If your service has three RPCs, the four-interceptor chain above is overengineering. Write the boilerplate and move on. The indirection cost (reading the interceptor files, understanding the chain order, debugging the event wrapping) is higher than the copy-paste cost.

If your service has 15+ RPCs, interceptors pay for themselves. The 200 lines of interceptor code replace 900+ lines of handler boilerplate (60 lines per handler x 15 handlers). Every time you add a new RPC, you write the business logic and nothing else. Every time you change the auth scheme, you edit one file.

The takeaway

gRPC interceptors are the middleware pattern for gRPC, and they solve the same problem Express middleware solves: separating cross-cutting concerns from business logic. The pattern is four interceptors (auth, logging, error mapping, metrics), a carefully ordered chain, and a safeNext wrapper that catches async errors. Every interceptor is testable in isolation, every interceptor works for all RPCs, and every interceptor can be removed or replaced without touching handlers.

The three traps (order inversion, async swallowing, metadata ordering) are well-documented once you know to look for them. Use AsyncLocalStorage for context, wrap next() with a promise-aware boundary, and document the interceptor stack order in your server setup file.

Your handlers should be business logic and nothing else. That is the whole point.


A note from Yojji

Building gRPC services that are observable, secure, and maintainable under real production traffic is the kind of engineering discipline that separates a prototype from a platform. The patterns in this post (composable interceptors, structured error codes, testable middleware boundaries) are the same patterns that Yojji has shipped across dozens of Node.js microservice projects since 2016.

Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architectures, and they run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.

If you would rather hire a team that already knows how to wire interceptor chains and test async error boundaries than learn it through a production incident, Yojji is worth a conversation.