Spring OpenFeign 的异常处理

1、概览

微服务间的 HTTP API 调用可能会出现异常。在 Spring Boot 中使用 OpenFeign 时,默认会把下游服务的 “Not Found” 等异常全部当做 “Internal Server Error” 响应给客户端。这并不是异常的最佳处理方式,幸而,Spring 和 OpenFeign 都提供了一些机制,允许我们自定义异常处理。

本文将带你了解,Spring Boot 和 OpenFeign 默认的异常传播、处理机制,以及如何实现自定义的异常处理。

2、默认的异常传播策略

2.1、Feign 中默认的异常传播

Feign 使用 ErrorDecoder.Default 内部实现类进行异常处理。每当 Feign 收到任何非 2xx 状态码时,都会将其传递给 ErrorDecoderdecode 方法。

如果 HTTP 响应有 Retry-After 头信息,decode 方法就会返回 RetryableException,否则就会返回 FeignException

重试时,如果请求在默认重试次数之后仍然失败,则会返回 FeignException

decode 方法将 HTTP 方法 key 和响应存储在 FeignException 中。

2.2、Spring Rest Controller 中的默认异常传播

只要 RestController 收到任何未处理的异常,它就会向客户端返回 500 Internal Server Error(内部服务器错误)响应。

该异常响应包含时间戳、HTTP 状态码、异常信息和路径等信息:

{
    "timestamp": "2022-07-08T08:07:51.120+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/myapp1/product/Test123"
}

下面,我们通过一个例子来深入了解一下。

3、示例应用

构建一个简单的微服务,调用另一个外部服务返回 product 信息。

首先,创建 Product Model 类。

public class Product {
    private String id;
    private String productName;
    private double price;
}

然后,在 ProductController 中实现 Get Product 端点:

@RestController("product_controller")
@RequestMapping(value ="myapp1")
public class ProductController {

    private ProductClient productClient;

    @Autowired
    public ProductController(ProductClient productClient) {
        this.productClient = productClient;
    }

    @GetMapping("/product/{id}")
    public Product getProduct(@PathVariable String id) {
        return productClient.getProduct(id);
    }
}

接下来,将 Feign Logger 注册为 Bean:

public class FeignConfig {

    @Bean
    Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
}

最后,实现 ProductClient,以调用外部 API 接口:

@FeignClient(name = "product-client", url="http://localhost:8081/product/", configuration = FeignConfig.class)
public interface ProductClient {
    @RequestMapping(value = "{id}", method = RequestMethod.GET")
    Product getProduct(@PathVariable(value = "id") String id);
}

4、默认的异常传播

4.1、使用 WireMock Server

使用 Wiremock 框架来模拟被调用的服务,以进行测试。

首先,添加 WireMockServer Maven 依赖:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.33.2</version>
    <scope>test</scope>
</dependency>

然后,配置并启动 WireMockServer

WireMockServer wireMockServer = new WireMockServer(8081);
configureFor("localhost", 8081);
wireMockServer.start();

WireMockServer 在与配置的 Feign 客户端相同的 hostport 上启动。

4.2、Feign Client 默认的异常传播

Feign 默认的 Error handler,ErrorDecoder.Default 总是抛出 FeignException

使用 WireMock.stubFor 来模拟 getProduct 方法,返回 SERVICE_UNAVAILABLE 状态(服务不可用)。

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

assertThrows(FeignException.class, () -> productClient.getProduct(productId));

如上,当 ProductClient 遇到下游服务的 503 (SERVICE_UNAVAILABLE)异常时,会抛出 FeignException

接着,使用 404 Not Found 响应进行同样的测试:

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

assertThrows(FeignException.class, () -> productClient.getProduct(productId));

同样地,客户端再次收到 FeignException。这并不合理,因为 404 NOT_FOUND 异常可能是用户提交的查询有问题。我们需要对不同的异常进行区分,以进行不同的处理。

注意,FeignException 确实具有一个包含 HTTP 状态码的 status 属性,但是 try/catch 策略是根据异常的类型而不是属性来路由异常。

4.3、Spring Rest Controller 的异常传播

接着,看一下 FeignException 是如何传播回客户端的。

ProductControllerProductClient 捕获 FeignException 时,它会将其传递给 Spring Boot 默认的异常处理器。

当 product service 不可用时,进行断言:

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

mockMvc.perform(get("/myapp1/product/" + productId))
  .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()));

如上,客户端最终得到得异常状态是 Spring 的 INTERNAL_SERVER_ERROR 状态。

5、使用 ErrorDecoder 在 Feign 中传播自定义异常

为了避免永远返回默认的 FeignException,我们可以根据 HTTP 状态码返回一些特定的异常。

自定义 ErrorDecoder 实现,覆写 decode 方法:

public class CustomErrorDecoder implements ErrorDecoder {

    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()){
            case 400:
                return new BadRequestException();
            case 404:
                return new ProductNotFoundException("Product not found");
            case 503:
                return new ProductServiceNotAvailableException("Product Api is unavailable");
            default:
                return new Exception("Exception while getting product details");
        }
    }
}

在自定义的 decode 方法中,为不同的状态码返回了不同的异常,还在异常中提供了更多的细节信息。

注意,decode 方法是返回 FeignException,而不是抛出异常。

现在,在 FeignConfig 中将 CustomErrorDecoder 配置为 Spring Bean:

@Bean
public ErrorDecoder errorDecoder() {
   return new CustomErrorDecoder();
}

或者,也可以直接在 ProductClient 中配置 CustomErrorDecoder

@FeignClient(name = "product-client-2", url = "http://localhost:8081/product/", 
   configuration = { FeignConfig.class, CustomErrorDecoder.class })

然后,测试 CustomErrorDecoder 是否会返回 ProductServiceNotAvailableException

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

assertThrows(ProductServiceNotAvailableException.class, 
  () -> productClient.getProduct(productId));

同样,再写一个测试用例,在 product 不存在时断言 ProductNotFoundException

String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

assertThrows(ProductNotFoundException.class, 
  () -> productClient.getProduct(productId));

现在,Feign Client 会根据不同的状态码返回不同的异常,但是在 Spring 捕获该异常后,还是会统一对客户端响应 “internal server error” 状态。

6、在 Spring Rest Controller 中传播自定义异常

Spring Boot 默认的 Error handler 提供了通用的异常响应。客户端可能需要更为详细的异常信息。

有多种方式可以自定义 RestController 的 Exception Handler。在这里我们使用 RestControllerAdvice 注解来处理异常。

6.1、使用 @RestControllerAdvice

@RestControllerAdvice 注解允许我们将多个异常合并到一个全局异常处理组件中。

假如:ProductController 需要根据下游服务的异常返回不同的自定义异常响应。

首先,创建 ErrorResponse 类,表示异常响应:

public class ErrorResponse {

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
    private Date timestamp;

    @JsonProperty(value = "code")
    private int code;

    @JsonProperty(value = "status")
    private String status;
    
    @JsonProperty(value = "message")
    private String message;
    
    @JsonProperty(value = "details")
    private String details;
}

现在,创建 ResponseEntityExceptionHandler 的子类实现 ProductExceptionHandler,并在异常处理方法上添加 @ExceptionHandler 注解:

@RestControllerAdvice
public class ProductExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ProductServiceNotAvailableException.class})
    public ResponseEntity<ErrorResponse> handleProductServiceNotAvailableException(ProductServiceNotAvailableException exception, WebRequest request) {
        return new ResponseEntity<>(new ErrorResponse(
          HttpStatus.INTERNAL_SERVER_ERROR,
          exception.getMessage(),
          request.getDescription(false)),
          HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler({ProductNotFoundException.class})
    public ResponseEntity<ErrorResponse> handleProductNotFoundException(ProductNotFoundException exception, WebRequest request) {
        return new ResponseEntity<>(new ErrorResponse(
          HttpStatus.NOT_FOUND,
          exception.getMessage(),
          request.getDescription(false)),
          HttpStatus.NOT_FOUND);
    }
}

如上,ProductServiceNotAvailableException 异常会响应 INTERNAL_SERVER_ERROR 状态给客户端。而,用户特定的异常(如 ProductNotFoundException)会以不同的方式处理,并返回一个 NOT_FOUND 响应。

6.2、测试 Spring Rest Controller

在 product service 不可用时测试 ProductController

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));

MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
  .andExpect(status().isInternalServerError()).andReturn();

ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(500, errorResponse.getCode());
assertEquals("Product Api is unavailable", errorResponse.getMessage());

接着,再次测试同一个 ProductController,但这次会返回 “Product not found” 异常消息:

String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
  .willReturn(aResponse()
  .withStatus(HttpStatus.NOT_FOUND.value())));

MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
  .andExpect(status().isNotFound()).andReturn();

ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(404, errorResponse.getCode());
assertEquals("Product not found", errorResponse.getMessage());

上述测试显示了 ProductController 如何根据下游服务的异常返回不同状态的异常响应。

如果我们没有实现自定义的 CustomErrorDecoder,那么需要使用 RestControllerAdvice 来直接处理 Feign Client 默认的 FeignException

7、总结

在本文中,我们学习了如何在 Feign Client 中使用 ErrorDecoder 以及在 Rest Controller 中使用 RestControllerAdvice 进行自定义异常处理。


参考:https://www.baeldung.com/category/spring