1. Overview

In this tutorial, we’ll examine the role of interceptors in gRPC server applications to handle global exceptions.

Interceptors can validate or manipulate the request before it reaches the RPC methods. Hence, they are useful in handling common concerns like logging, security, caching, auditing, authentication and authorization, and much more for applications.

Applications may also use interceptors as global exception handlers.

2. Interceptors as Global Exception Handlers

Majorly, interceptors can help handle exceptions of two types:

  • Handle unknown runtime exceptions escaping from methods that couldn’t handle them
  • Handle exceptions that escape from any other downstream interceptors

Interceptors can help create a framework to handle exceptions in a centralized manner. This way the applications can have a consistent standard and robust approach to handle exceptions.

They can treat exceptions in various ways:

  • Log or persist the exceptions for auditing or reporting purposes
  • Create support tickets
  • Modify or enrich the error responses before sending it back to the clients

3. High-Level Design of a Global Exception Handler

The interceptor can forward the incoming request to the target RPC service. However, when the target RPC method throws an exception back, it can capture it and then handle it appropriately.

Let’s assume there’s an order processing microservice. We’ll develop a global exception handler with the help of an interceptor to catch the exception escaped from the RPC methods in the microservice. Additionally, the interceptor catches the exceptions that escaped from any of the downstream interceptors. Then, it calls a ticket service to raise tickets in a ticketing system. Finally, the response is sent back to the client.

Let’s take a look at the traversal path of the request when it fails in the RPC endpoint:

interceptor sequence

Similarly, let’s see the traversal path of the request when it fails in the log interceptor:

interceptor sequence failed at log

First, we’ll begin defining the base classes for the order processing service in the protobuf file order_processing.proto:

syntax = "proto3";

package orderprocessing;

option java_multiple_files = true;
option java_package = "com.baeldung.grpc.orderprocessing";

message OrderRequest {
  string product = 1;
  int32 quantity = 2;
  float price = 3;
}
message OrderResponse {
  string response = 1;
  string orderID = 2;
  string error = 3;
}
service OrderProcessor {
  rpc createOrder(OrderRequest) returns (OrderResponse){}
}

The order_processing.proto file defines OrderProcessor with a remote method createOrder() and two DTOs OrderRequest and OrderResponse.

Let’s have a look at the major classes we’ll implement in the upcoming sections:

order processing cld

Later, we can use the order_processing.proto file for generating the supporting Java source code for implementing OrderProcessorImpl and GlobalExeptionInterceptor. The Maven plugin generates the classes OrderRequest, OrderResponse, and OrderProcessorGrpc.

We’ll discuss each of these classes in the implementation section.

4. Implementation

We’ll implement an interceptor that can handle all kinds of exceptions. The exception could be explicitly raised due to some failed logic or it could be an exception due to some unforeseen error.

4.1. Implement Global Exception Handler

Interceptors in a gRPC application have to implement the interceptCall() method of the ServerInterceptor interface:

public class GlobalExceptionInterceptor implements ServerInterceptor {
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata headers,
        ServerCallHandler<ReqT, RespT> next) {
        ServerCall.Listener<ReqT> delegate = null;
        try {
            delegate = next.startCall(serverCall, headers);
        } catch(Exception ex) {
            return handleInterceptorException(ex, serverCall);
        }
        return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(delegate) {
            @Override
            public void onHalfClose() {
                try {
                    super.onHalfClose();
                } catch (Exception ex) {
                    handleEndpointException(ex, serverCall);
                }
            }
        };
    }

    private static <ReqT, RespT> void handleEndpointException(Exception ex, ServerCall<ReqT, RespT> serverCall) {
        String ticket = new TicketService().createTicket(ex.getMessage());
        serverCall.close(Status.INTERNAL
            .withCause(ex)
            .withDescription(ex.getMessage() + ", Ticket raised:" + ticket), new Metadata());
    }

    private <ReqT, RespT> ServerCall.Listener<ReqT> handleInterceptorException(Throwable t, ServerCall<ReqT, RespT> serverCall) {
        String ticket = new TicketService().createTicket(t.getMessage());
        serverCall.close(Status.INTERNAL
            .withCause(t)
            .withDescription("An exception occurred in a **subsequent** interceptor:" + ", Ticket raised:" + ticket), new Metadata());

        return new ServerCall.Listener<ReqT>() {
            // no-op
        };
    }
}

The method interceptCall() takes in three input parameters:

  • ServerCall: Helps receive response messages
  • Metadata: Holds the metadata of the incoming request
  • ServerCallHandler: Helps dispatch the incoming server call to the next processor in the interceptor chain

The method has two trycatch blocks. The first one handles the uncaught exception thrown from any subsequent downstream interceptors. In the catch block, we call the method handleInterceptorException() which creates a ticket for the exception. Finally, it returns an object of ServerCall.Listener which is a call-back method.

Similarly, the second trycatch block handles the uncaught exceptions thrown from the RPC endpoints. The interceptCall() method returns ServerCall.Listener that acts as a callback for incoming RPC messages. Specifically, it returns an instance of ForwardingServerCallListener.SimpleForwardingServerCallListener which is a subclass of ServerCall.Listener.

To handle the exception thrown from the downstream methods we’ve overridden the method onHalfClose() in the class ForwardingServerCallListener.SimpleForwardingServerCallListener. It gets invoked once the client has completed sending messages.

In this method, super.onHalfClose() forwards the request to the RPC endpoint createOrder() in the OrderProcessorImpl class. If there’s an uncaught exception in the endpoint, we catch the exception and then call handleEndpointException() to create a ticket. Finally, we call the method close() on the serverCall object to close the server call and send the response back to the client.

4.2. Register Global Exception Handler

We register the interceptor while creating the io.grpc.Server object during the boot-up:

public class OrderProcessingServer {
    public static void main(String[] args) throws IOException, InterruptedException {
        Server server = ServerBuilder.forPort(8080)
          .addService(new OrderProcessorImpl())
          .intercept(new LogInterceptor())
          .intercept(new GlobalExceptionInterceptor())
          .build();
        server.start();
        server.awaitTermination();
    }
}

We pass the GlobalExceptionInterceptor object to the intercept() method of io.grpc.ServerBuilder class. This ensures that any RPC call to the OrderProcessorImpl service goes through GlobalExceptionInterceptor. Similarly, we call the addService() method to register the OrderProcessorImpl service.  At the end, we invoke the start() method on the Server object to start the server application.

4.3. Handle Uncaught Exception From Endpoints

To demonstrate the exception handler, let’s first take a look at the OrderProcessorImpl class:

public class OrderProcessorImpl extends OrderProcessorGrpc.OrderProcessorImplBase {
    @Override
    public void createOrder(OrderRequest request, StreamObserver<OrderResponse> responseObserver) {
        if (!validateOrder(request)) {
             throw new StatusRuntimeException(Status.FAILED_PRECONDITION.withDescription("Order Validation failed"));
        } else {
            OrderResponse orderResponse = processOrder(request);

            responseObserver.onNext(orderResponse);
            responseObserver.onCompleted();
        }
    }

    private Boolean validateOrder(OrderRequest request) {
        int tax = 100/0;
        return false;
    }

    private OrderResponse processOrder(OrderRequest request) {
        return OrderResponse.newBuilder()
          .setOrderID("ORD-5566")
          .setResponse("Order placed successfully")
          .build();
    }
}

The RPC method createOrder() validates the order first and then processes it by calling the processOrder() method. In the validateOrder() method, we deliberately force a runtime exception by dividing a number by zero.

Now, let’s run the service and see how it handles the exception:

@Test
void whenRuntimeExceptionInRPCEndpoint_thenHandleException() {
    OrderRequest orderRequest = OrderRequest.newBuilder()
      .setProduct("PRD-7788")
      .setQuantity(1)
      .setPrice(5000)
      .build();

    try {
        OrderResponse response = orderProcessorBlockingStub.createOrder(orderRequest);
    } catch (StatusRuntimeException ex) {
        assertTrue(ex.getStatus()
          .getDescription()
          .contains("Ticket raised:TKT"));
    }
}

We create the OrderRequest object and then pass it to the createOrder() method in the client stub. As expected, the service throws back the exception. When we inspect the description in the exception, we find the ticket information embedded in it. Hence, it shows that the GlobalExceptionInterceptor did its job.

This is equally effective for streaming cases as well.

4.4. Handle Uncaught Exceptions From Interceptors

Let’s suppose there’s a second interceptor that gets invoked after the GlobalExceptionInterceptor. LogInterceptor logs all the incoming requests for auditing purposes. Let’s take a look at it:

public class LogInterceptor implements ServerInterceptor {
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata,
        ServerCallHandler<ReqT, RespT> next) {
        logMessage(serverCall);
        ServerCall.Listener<ReqT> delegate = next.startCall(serverCall, metadata);
        return delegate;
    }

    private <ReqT, RespT> void logMessage(ServerCall<ReqT, RespT> call) {
        int result = 100/0;
    }
}

In LogInterceptor, the interceptCall() method invokes logMessage() to log the messages before forwarding the request to the RPC endpoint. The logMessage() method deliberately performs division by zero to raise a runtime exception for demonstrating the capability of GlobalExceptionInterceptor.

Let’s run the service and see how it handles the exception raised from LogInterceptor:

@Test
void whenRuntimeExceptionInLogInterceptor_thenHandleException() {
    OrderRequest orderRequest = OrderRequest.newBuilder()
        .setProduct("PRD-7788")
        .setQuantity(1)
        .setPrice(5000)
        .build();

    try {
        OrderResponse response = orderProcessorBlockingStub.createOrder(orderRequest);
    } catch (StatusRuntimeException ex) {
        assertTrue(ex.getStatus()
            .getDescription()
            .contains("An exception occurred in a **subsequent** interceptor:, Ticket raised:TKT"));
    }
    logger.info("order processing over");
}

First, we call the createOrder() method on the client stub. This time, the GlobalExceptionInterceptor catches the exception that escaped from the LogInterceptor in the first trycatch block.  Subsequently, the client receives the exception with ticket information embedded in the description.

5. Conclusion

In this article, we explored the role of interceptors in the gRPC framework as global exception handlers. They’re excellent tools for handling common concerns on exceptions, such as logging, creating tickets, enriching error responses, and much more.

The code used in this article can be found over on GitHub.