Learnixo
Back to blog
AI Systemsintermediate

gRPC Interceptors — Cross-Cutting Concerns in gRPC

Build gRPC interceptors in ASP.NET Core: logging interceptors, authentication validation, error handling, retry policies on the client, and applying interceptors globally vs per-service.

Asma Hafeez KhanMay 16, 20265 min read
gRPCInterceptorsASP.NET Core.NETCross-Cutting
Share:𝕏

What gRPC Interceptors Are

Interceptors are gRPC's equivalent of middleware. They wrap gRPC calls on both server and client sides, enabling cross-cutting concerns (logging, auth, error handling, metrics) without modifying service methods.

Server interceptor pipeline:
  Request → ServerInterceptorA → ServerInterceptorB → ServiceMethod

Client interceptor pipeline:
  ClientCode → ClientInterceptorA → ClientInterceptorB → Network → Server

Server-Side Logging Interceptor

C#
// Infrastructure/Grpc/Interceptors/LoggingInterceptor.cs
public sealed class LoggingInterceptor : Interceptor
{
    private readonly ILogger<LoggingInterceptor> _logger;

    public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
        => _logger = logger;

    // Intercept unary calls
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest                        request,
        ServerCallContext               context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        var method    = context.Method;
        var user      = context.GetHttpContext().User
            .FindFirstValue(JwtRegisteredClaimNames.Sub);
        var requestId = context.RequestHeaders
            .FirstOrDefault(h => h.Key == "x-request-id")?.Value;

        _logger.LogInformation(
            "gRPC {Method} called by {UserId} [{RequestId}]",
            method, user ?? "anonymous", requestId);

        var sw = Stopwatch.StartNew();
        try
        {
            var response = await continuation(request, context);
            sw.Stop();
            _logger.LogInformation(
                "gRPC {Method} completed in {ElapsedMs}ms", method, sw.ElapsedMilliseconds);
            return response;
        }
        catch (RpcException ex)
        {
            sw.Stop();
            _logger.LogWarning(
                "gRPC {Method} failed with {StatusCode} in {ElapsedMs}ms: {Detail}",
                method, ex.StatusCode, sw.ElapsedMilliseconds, ex.Status.Detail);
            throw;
        }
        catch (Exception ex)
        {
            sw.Stop();
            _logger.LogError(ex,
                "gRPC {Method} threw unhandled exception in {ElapsedMs}ms",
                method, sw.ElapsedMilliseconds);
            throw new RpcException(new Status(StatusCode.Internal, "An unexpected error occurred."));
        }
    }

    // Intercept server streaming calls
    public override Task ServerStreamingServerHandler<TRequest, TResponse>(
        TRequest request,
        IServerStreamWriter<TResponse> responseStream,
        ServerCallContext context,
        ServerStreamingServerMethod<TRequest, TResponse> continuation)
    {
        _logger.LogInformation("gRPC streaming {Method} started", context.Method);
        return continuation(request, responseStream, context);
    }
}

Server-Side Error Handling Interceptor

C#
public sealed class ErrorHandlingInterceptor : Interceptor
{
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        try
        {
            return await continuation(request, context);
        }
        catch (RpcException)
        {
            throw;  // let RpcExceptions through unchanged
        }
        catch (ValidationException ex)
        {
            throw new RpcException(new Status(StatusCode.InvalidArgument,
                string.Join("; ", ex.Errors.Select(e => e.ErrorMessage))));
        }
        catch (NotFoundException ex)
        {
            throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
        }
        catch (UnauthorizedAccessException)
        {
            throw new RpcException(new Status(StatusCode.PermissionDenied,
                "Access denied."));
        }
        catch (Exception ex)
        {
            // Log internally — do not expose to client
            GetLogger(context).LogError(ex, "Unhandled exception in gRPC service");
            throw new RpcException(new Status(StatusCode.Internal,
                "An unexpected error occurred. Ref: " + context.RequestHeaders
                    .FirstOrDefault(h => h.Key == "x-request-id")?.Value));
        }
    }
}

Audit Interceptor for Clinical Systems

C#
public sealed class ClinicalAuditInterceptor : Interceptor
{
    private readonly IAuditLogger _audit;

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        var user   = context.GetHttpContext().User;
        var userId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
        var method = context.Method;

        await _audit.RecordAsync(new AuditEntry(
            UserId:    userId ?? "unknown",
            Action:    method,
            Timestamp: DateTime.UtcNow,
            Source:    "gRPC"));

        return await continuation(request, context);
    }
}

Registering Server Interceptors

C#
// Program.cs — apply to all gRPC services
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<LoggingInterceptor>();
    options.Interceptors.Add<ErrorHandlingInterceptor>();
    options.Interceptors.Add<ClinicalAuditInterceptor>();
});

// Or per-service
builder.Services.AddGrpc();
builder.Services.AddGrpcServiceOptions<ClinicalPatientGrpcService>(options =>
{
    options.Interceptors.Add<LoggingInterceptor>();
});

app.MapGrpcService<ClinicalPatientGrpcService>();

Client-Side Retry Interceptor

C#
public sealed class RetryInterceptor : Interceptor
{
    private const int MaxRetries  = 3;
    private const int DelayMs     = 1000;

    public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
        TRequest request,
        ClientInterceptorContext<TRequest, TResponse> context,
        AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
    {
        return new AsyncUnaryCall<TResponse>(
            RetryAsync(request, context, continuation),
            Task.FromResult(Metadata.Empty),
            () => new Status(),
            () => Metadata.Empty,
            () => { });
    }

    private static async Task<TResponse> RetryAsync<TRequest, TResponse>(
        TRequest request,
        ClientInterceptorContext<TRequest, TResponse> context,
        AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
        where TRequest : class
        where TResponse : class
    {
        for (int attempt = 0; attempt < MaxRetries; attempt++)
        {
            try
            {
                return await continuation(request, context).ResponseAsync;
            }
            catch (RpcException ex) when (
                attempt < MaxRetries - 1 &&
                (ex.StatusCode == StatusCode.Unavailable ||
                 ex.StatusCode == StatusCode.DeadlineExceeded))
            {
                await Task.Delay(DelayMs * (attempt + 1));  // exponential backoff
            }
        }
        throw new RpcException(new Status(StatusCode.Unavailable, "Max retries exceeded."));
    }
}

// Register on the client channel
var channel = GrpcChannel.ForAddress("https://api.systemforge.internal");
var invoker = channel.Intercept(new RetryInterceptor());
var client  = new ClinicalPatientService.ClinicalPatientServiceClient(invoker);

AddGrpcClient with Interceptors

C#
// DI-registered gRPC client with interceptors
builder.Services.AddGrpcClient<ClinicalPatientService.ClinicalPatientServiceClient>(options =>
{
    options.Address = new Uri("https://clinical-api.internal");
})
.AddInterceptor<RetryInterceptor>()
.AddInterceptor<AuthHeaderInterceptor>();  // attach JWT to outgoing calls

public sealed class AuthHeaderInterceptor : Interceptor
{
    private readonly ICurrentUser _currentUser;

    public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
        TRequest request,
        ClientInterceptorContext<TRequest, TResponse> context,
        AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
    {
        var headers = context.Options.Headers ?? new Metadata();
        headers.Add("authorization", $"Bearer {_currentUser.AccessToken}");

        var newOptions  = context.Options.WithHeaders(headers);
        var newContext  = new ClientInterceptorContext<TRequest, TResponse>(
            context.Method, context.Host, newOptions);
        return continuation(request, newContext);
    }
}

Production issue I've seen: A gRPC client service called a downstream service with no retry logic. A transient network hiccup during a 3-second window caused 400 drug order status checks to fail simultaneously. All 400 errors surfaced as permanent failures in the pharmacy system's dashboard. A retry interceptor with exponential backoff and Unavailable status code handling would have recovered automatically.


Key Takeaway

gRPC interceptors wrap calls on server and client sides for cross-cutting concerns: logging, error handling, auditing, retry, authentication. Server interceptors register in AddGrpc(options.Interceptors.Add<T>()). Client interceptors register with channel.Intercept() or AddGrpcClient().AddInterceptor<T>(). Always catch Exception in server interceptors and wrap in RpcException with a safe message — raw exceptions expose internal details to clients.

gRPC Knowledge Check

5 questions · Test what you just learned · Instant explanations

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.