Error handling in gRPC with public RESTFul API

Getting error handling right is hard in the gRPC applications. It’s harder if your application consists of many microservices, exposing REST and gRPC services. The consumer of your API needs a consistent experience of error handling. You must hide internal implementation details from the API consumers.

In this article, I will explain how to develop an error handling framework, that works with RESTFul and gRPC APIs. Although the example is based on Spring Boot and Java gRPC the concept is common for all gRPC languages.

Code Example

The working code example of this article is listed on GitHub . To run the example, clone repository, and import grpc-spring-boot as a project in your favorite IDE.

As a reminder, the code example consists of two microservices –

  • Product Gateway – acts as an API Gateway (client of Product Service) and exposes REST APIs (Gradle module product-api-gateway)
  • Product Service – exposes gRPC APIs (Gradle module product-service)

There is a 3rd Gradle module, called commons, which contains common Exception classes consumed by both Product Gateway Service and Product Service.

You can start these services from IDE by calling the main method of ProductGatewayApplication and ProductApplication respectively.

You can test the application by calling Product Gateway Service API as :


curl --location --request GET 'http://localhost:8080/products/32c29935-da42-4801-825a-ac410584c281' 
--data-raw ''

Problem Description

The Product service throws a domain exception ResourceNotFoundException when productId is not found as:


public Product get(String productId) {
   var product = Optional.ofNullable(productStorage.get(productId));
   return product.orElseThrow(
       () ->
           new ResourceNotFoundException(
               "Product ID not found",
               Map.of("resource_id", productId, "message", "Product ID not found")));
 }

This exception needs to be handled by the client application Product Gateway and show a meaningful error response to the caller. The error response must not expose internal implementation detail.

Error handling implementation

So, you need to develop an error handling framework to handle errors thrown by the gRPC service. You also need to propagate those errors in a standard error response to the API consumer. You must take care to not expose the internal details of the system.

The sequence diagram of error handling looks like this:



The application flow for a GET /products/{productId} API is :

  1. API consumer makes API call to public RESTful API.
  2. The API call is delegated to gRPC client (ProductService), which is responsible for calling gRPC API.
  3. The gRPC client calls gRPC API using the gRPC channel.
  4. The gRPC API calls the repository (application layer) to fetch the product from the database.
  5. If the product is not found in the database, the application layer throws a domain exception (ResourceNotFoundException).
  6. The domain exception is handled by an gRPC interceptor, which creates a gRPC specific error detail object and throws gRPC specific StatusRuntimeException.
  7. On receiving StatusRuntimeException, the client service creates and throws a domain-specific ServiceException.
  8. The ServiceException is caught by the GlobalExceptionHandler, which returns a standard error response to the API client.

gRPC Error Model

As you know from the previous article Getting Error Handling right in gRPC, you can propagate error information from the gRPC service using google.rpc.Status. For that, you need to define a custom error model as:


message ErrorDetail {
  // Error code
  string errorCode = 1;
  //Error message
  string message = 2;
  // Additional metadata associated with the Error
  map<string, string> metadata = 3;
}

Here, the value for the “code” name/value pair is a language-independent string. Its value is a service-defined error code that should be human-readable.

Your application can define a handful of Error Codes as:


public enum ErrorCode {

  RESOURCE_NOT_FOUND("ResourceNotFound", "Resource not found", HttpStatus.NOT_FOUND),
  BAD_ARGUMENT("BadArgument", "Bad argument", HttpStatus.BAD_REQUEST),
  // Other error codes
}

gRPC error interceptor

The gRPC service defines an error interceptor ExceptionHandler, which transforms domain-specific Exception to the StatusRuntimeException as:


@GrpcExceptionHandler(ResourceNotFoundException.class)
public StatusRuntimeException handleResourceNotFoundException(ResourceNotFoundException cause) {

  ErrorDetail errorDetail =
      ErrorDetail.newBuilder()
          .setErrorCode(cause.getErrorCode().getShortCode())
          .setMessage(cause.getMessage())
          .putAllMetadata(cause.getErrorMetaData())
          .build();

  var status =
      com.google.rpc.Status.newBuilder()
          .setCode(Code.NOT_FOUND.getNumber())
          .setMessage("Resource not found")
          .addDetails(Any.pack(errorDetail))
          .build();

  return StatusProto.toStatusRuntimeException(status);
}

Exception mapping at the gRPC Client

As StatusRuntimeException thrown by the gRPC server is an internal implementation detail, you need to map it to the domain exception. On the gRPC client, you can catch StatusRuntimeException and map to domain-specific ServiceException by calling ServiceExceptionMapper.map(error) as :


public Product getProduct(String productId) {
  Product product;
  try {
    //gRPC Server call
    var request = GetProductRequest.newBuilder().setProductId(productId).build();
    var productApiServiceBlockingStub = ProductServiceGrpc.newBlockingStub(managedChannel);
    var response = productApiServiceBlockingStub.getProduct(request);
  } catch (StatusRuntimeException error) {
    throw ServiceExceptionMapper.map(error);
  }
  return product;
}

Where ServiceExceptionMapper maps StatusRuntimeException to a domain-specific ServiceException as :


public static ServiceException map(StatusRuntimeException error) {

  var status = io.grpc.protobuf.StatusProto.fromThrowable(error);
  ErrorDetail errorDetail = null;

  for (Any any : status.getDetailsList()) {
    if (!any.is(ErrorDetail.class)) {
      continue;
    }
    try {
      errorDetail = any.unpack(ErrorDetail.class);
    } catch (InvalidProtocolBufferException cause) {
      errorDetail =
          ErrorDetail.newBuilder()
              .setCode(ErrorCode.INVALID_OPERATION.getMessage())
              .setMessage(cause.getMessage())
              .putAllMetadata(Map.of())
              .build();
    }
  }

  return new ServiceException(
      ErrorCode.errorCode(errorDetail.getCode()),
      errorDetail.getMessage(),
      errorDetail.getMetadataMap());
}

Exception handling in a RESTful API

Before we delve into exception handling in a RESTful API, it’s important to understand the importance of standard error messages.

API error message

As an API producer, you may want to provide a consistent experience for your API consumer. You may want to return a consistent response format for all error conditions. One example of such error format could be:


{
    "error": {
        "code": "ResourceNotFound",
        "message": "Resource not found",
        "details": {
            "message": "Product ID not found",
            "resource_id": "32c29935-da42-4801-825a-ac410584c281"
        }
    }
}

Here, the value for the “code” name/value pair is a language-independent string. Its value is a service-defined error code that should be human-readable. This code serves as a more specific indicator of the error than the HTTP error code specified in the response. Services should have a relatively small number (about 20) of possible values for “code”, and all clients MUST be capable of handling all of them.

You can check Microsoft REST API Guidelines or Google Error Model for reference.

The next step is to create a GlobalExceptionHandler in the Spring Boot application Product Gateway. The responsibility of the GlobalExceptionHandler to handle errors and return a standard error response to the RESTful API client. Before we implement GlobalExceptionHandler, let’s see how can we handle errors in a Spring Boot application.

Default error message in spring boot

Spring Boot allows a Spring project to be set up with minimal configuration. Spring Boot creates sensible defaults automatically when it detects certain key classes and packages on the classpath. It also has a default error-handling mechanism built in. Let’s try to understand how default exception handling works in a Spring Boot application.

Let’s assume we have a ProductController, which exposes an API GET /products/{productId}, whose getProduct(..) method throw exception in case of error conditions as:


@GetMapping("/products/{productId}")
public ResponseEntity<Product> getProduct(@PathVariable @NotBlank String productId) {
  //Throws Service Exception
  var product = productService.getProduct(productId);
  return new ResponseEntity<>(product, HttpStatus.CREATED);
}

In case of error, the GET /products/{productId} API returns the response as :

{
    "timestamp": "2021-06-06T04:23:32.050+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "",
    "path": "/products/32c29935-da42-4801-825a-ac410584c281"
}

As you can see, the default message provided by Spring is helpful but not much. First of all, it says all errors as HTTP status as 500, which is stands for internal server error. As per documentation

The HyperText Transfer Protocol (HTTP) 500 Internal Server Error server error response code indicates that the server encountered an unexpected condition that prevented it from fulfilling the request.

Also, one of the most important parameters’message‘ is empty.

So, how does this default error mechanism works?

In the event of any unhandled error, Spring Boot forwards internally to /error. Spring Boot sets up a BasicErrorController to handle any request to /error. The controller adds error information to the internal model and returns error as the logical view.

Spring boot also provides us some basic ability to customize this error using Spring Boot Server properties.

Customizing Error handling using @ResponseStatus

The simplest way to customize error handling is by using @ResponseStatus, which can be applied to the Exception class itself as:


@ResponseStatus (value = HttpStatus.I_AM_A_TEAPOT)
public class ServiceException extends BaseException {
  //
}

Now, the error message response looks like this:


{
    "timestamp": "2021-06-06T11:39:32.453+00:00",
    "status": 418,
    "error": "I'm a teapot",
    "message": "",
    "path": "/products/32c29935-da42-4801-825a-ac410584c281"
}

And yes, there is a status code called 418 I’m a teapot

If you believe in the separation of concern, this way of error handling may seem wrong to you as the error class itself specifies how to handle the error. The error, such as ServiceException belongs to the application logic and it should not make any assumption about how the error should be handled ( HTTP belongs to the transport/presentation layer).

Once you have defined a standard error response, you can implement global error handling in a common shared library so that it can be imported by all microservices. Obviously, this works only when you have the same language stack in all of your microservices.

Let’s see how we can implement a global mechanism for handling error conditions in Spring Boot microservices.

Global Exception handling using @ControllerAdvice

In a spring boot microservice application, you can define a class with annotation @ControllerAdvice and define methods annotated with @ExceptionHandler as:


@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleException(ResourceNotFoundException cause) {
        var errorResponse = new ErrorResponse();
        errorResponse.setCode(Code.RESOURCE_NOT_FOUND);
        errorResponse.setMessage(cause.getMessage());
        errorResponse.setDetails(cause.getErrorMetaData());
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }
}

Where the ErrorResponse class is defined as:


@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME, visible = true)
@JsonTypeName("error")
public class ErrorResponse {
  private Code code;
  private String message;
  private Map<String, String> details;
}

First two lines @JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME, visible = true) and @JsonTypeName("error") helps us to output error JSON as "error": {..}.

It’s not required to have a class annotated with @ControllerAdvice, we can even define @ExceptionHandler(ResourceNotFoundException.class) methods in controller classes but that will not help in achieving consistent global exception handling.

You can define GlobalExceptionHandler in the common library which can be imported by all microservices by adding a dependency on this library. Now, all you need to do is to tell Spring Boot about GlobalExceptionHandler, which can be done as:


@SpringBootApplication(scanBasePackages = {"dev.techdozo.api.product", "dev.techdozo.commons"})
public class ProductGatewayApplication {

  public static void main(String[] args) {
    SpringApplication.run(ProductGatewayApplication.class, args);
  }
}

Please note the package of error handling class is ‘dev.techdozo.commons

Putting it all together

All set. Now, if you call GET products/{productId} API with nonexistent productId, you’ll see an error message like:

Summary

Getting error handling right can be very tricky in a microservices application consisting of a mixture of gRPC and RESTful API. We must provide consistent error responses to the API consumer without exposing internal implementation details. Using google.rpc.Status we can propagate rich error models to gRPC clients. Spring Boot provides very flexible and powerful error handling capabilities using @ControllerAdvice.

Social Share !
Default image
Pankaj
Software Architect @ Schlumberger ``` Cloud | Microservices | Programming | Kubernetes | Architecture | Machine Learning | Java | Python ```