Per-Method Interceptor Routing
By default, interceptors in the interceptors array apply to every request. In practice, you often need different interceptors for different services or methods -- authentication for admin endpoints, aggressive timeouts for fast reads, circuit breakers for external-facing APIs.
Connectum provides three approaches for per-method interceptor routing.
Approach 1: ConnectRPC Native Per-Service/Per-Method
Connectum's defineService() forwards its third argument to ConnectRPC's router.service() options. Use that argument to scope interceptors to a service:
import { defineService } from '@connectum/core';
import { GreeterService } from '#gen/greeter_pb.ts';
export const greeterService = defineService(GreeterService, greeterImpl, {
interceptors: [requireAuth, auditLog],
});Use this approach when interceptors belong to one service. For one method within that service, use the filtering helper below. Raw router callbacks are not ServiceDefinition values accepted by createServer({ services }).
Approach 2: createMethodFilterInterceptor
createMethodFilterInterceptor is a declarative helper for routing interceptors to methods based on wildcard patterns. It produces a single Interceptor you can add to the global chain:
import {
createMethodFilterInterceptor,
createTimeoutInterceptor,
createCircuitBreakerInterceptor,
} from '@connectum/interceptors';
const perMethodInterceptor = createMethodFilterInterceptor({
// Global wildcard: applies to all methods
'*': [logRequest],
// Service wildcard: applies to all methods of the service
'admin.v1.AdminService/*': [requireAdmin],
// Exact match: applies to a specific method only
'user.v1.UserService/DeleteUser': [requireAdmin, auditLog],
});
const server = createServer({
services: [routes],
interceptors: [perMethodInterceptor],
});Supported Patterns
| Pattern | Description | Example |
|---|---|---|
"*" | All methods of all services | "*": [logRequest] |
"package.Service/*" | All methods of a service | "admin.v1.AdminService/*": [auth] |
"package.Service/Method" | Exact method match | "user.v1.UserService/GetUser": [cache] |
TIP
Pattern keys use the protobuf fully-qualified service name (service.typeName) plus the method name: "package.v1.ServiceName/MethodName".
Resolution Order
All matching patterns execute sequentially, from most general to most specific:
If no patterns match, the request passes through to the next interceptor in the chain unchanged.
Invalid Patterns
The following patterns throw an error at creation time:
// Empty service name -- throws Error
createMethodFilterInterceptor({ '/*': [auth] });
// No slash separator -- throws Error
createMethodFilterInterceptor({ 'SomeService': [auth] });Practical Example: Different Resilience Per Method
import {
createDefaultInterceptors,
createMethodFilterInterceptor,
createTimeoutInterceptor,
createCircuitBreakerInterceptor,
} from '@connectum/interceptors';
const resilience = createMethodFilterInterceptor({
// Fast reads: 5 second timeout
'catalog.v1.CatalogService/GetProduct': [
createTimeoutInterceptor({ duration: 5_000 }),
],
// Heavy reports: 60 second timeout
'report.v1.ReportService/*': [
createTimeoutInterceptor({ duration: 60_000 }),
],
// Admin mutations: audit logging
'admin.v1.AdminService/*': [
createAuditLogInterceptor(),
],
});
const server = createServer({
services: [routes],
interceptors: [
// Structural defaults; avoid a global timeout that would cap the 60s rule
...createDefaultInterceptors(),
resilience,
],
});Approach 3: Custom Interceptor with Manual Filtering
For dynamic or complex filtering logic that does not fit wildcard patterns, write a custom interceptor:
import type { Interceptor } from '@connectrpc/connect';
const conditionalAuth: Interceptor = (next) => async (req) => {
// Dynamic condition: check service name at runtime
if (req.service.typeName === 'admin.v1.AdminService') {
await verifyAdminToken(req);
}
// Check method kind
if (req.method.methodKind === 'server_streaming') {
attachStreamMonitoring(req);
}
return next(req);
};Use this approach for filtering based on request content, runtime state, or conditions that cannot be expressed as static patterns.
skip* Options on Built-in Interceptors
The built-in resilience interceptors have skip* options that serve a different purpose from method filtering. These are technical constraints, not routing concerns:
| Option | Used by | Why |
|---|---|---|
skipStreaming | timeout, bulkhead, circuitBreaker, retry, fallback | Resilience patterns wrap the full call. You cannot retry a stream, timeout a long-lived connection, or replace a stream with a fallback value. |
skipGrpcServices | serializer | Skips operational services whose names start with grpc.; it does not select by wire protocol. |
skipHealthCheck | logger | Convenience shortcut to reduce log noise from frequent health checks. |
These options complement createMethodFilterInterceptor. Method filtering handles business routing ("which interceptors for which methods"), while skip* handles technical limitations ("this interceptor cannot operate on this call type").
Choosing the Right Approach
| Scenario | Approach |
|---|---|
| Interceptor tied to a specific service | defineService(..., { interceptors }) |
| Declarative routing by pattern across multiple services | createMethodFilterInterceptor |
| Dynamic logic based on request content or runtime state | Custom interceptor with manual filtering |
| Technical limitation (streaming, binary protocol) | skip* options on built-in interceptors |
WARNING
Do not confuse skip* options with method filtering. Setting skipStreaming: true on the retry interceptor means "retry cannot handle streams" -- it is not a routing decision. Use createMethodFilterInterceptor or router-level interceptors for business routing.
Related
- Interceptors Overview -- quick start and key concepts
- Built-in Interceptors -- default chain reference
- Custom Interceptors -- creating custom interceptors
- Custom Protocols -- creating protocol plugins
- @connectum/interceptors -- Package Guide
- ADR-014: Method Filter Interceptor -- design rationale