2. 默认异常传播策略

微服务间调用HTTP API时遇到错误是家常便饭。在Spring Boot集成OpenFeign的场景下,默认的错误处理器会将下游服务的错误(比如404 Not Found)统一包装成500 Internal Server Error返回。这种处理方式显然不够优雅,好在Spring和OpenFeign都提供了自定义错误处理的机制。

本文将深入分析默认异常传播机制,并展示如何实现自定义错误处理。

2.1. Feign中的默认异常传播

Feign使用ErrorDecoder.Default作为默认错误处理器。当收到非2xx状态码时:

  • 若响应包含Retry-After头,返回RetryableException
  • 否则直接返回FeignException
  • 重试失败后最终也会抛出FeignException

decode方法会将HTTP方法键和响应信息封装到异常对象中。

2.2. Spring Rest Controller的默认异常传播

RestController遇到未处理异常时,会返回500错误并附带结构化响应:

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

下面通过实例深入分析。

3. 示例应用

构建一个从外部服务获取产品信息的微服务:

首先定义产品模型:

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

实现控制器:

@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日志:

public class FeignConfig {

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

定义Feign客户端:

@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模拟服务

添加依赖:

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

启动模拟服务:

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

4.2. Feign客户端的默认行为

模拟503服务不可用:

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

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

模拟404资源不存在:

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

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

踩坑提示:无论下游返回什么4xx/5xx错误,Feign默认都抛出FeignException,虽然异常对象包含状态码,但基于异常类型的处理策略很难区分具体错误。

4.3. 控制器层的异常传播

测试服务不可用场景:

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()));

无论下游是404还是503,上游统一返回500,这种粗暴处理显然不够精细。

5. 在Feign中实现自定义异常传播

通过自定义ErrorDecoder根据状态码返回特定异常:

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");
        }
    }
}

注册自定义解码器:

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

或直接在客户端配置:

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

验证503场景:

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

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

验证404场景:

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

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

注意:虽然Feign客户端能抛出特定异常,但Spring控制器层仍会将其转换为500错误,需要进一步优化。

6. 在Spring控制器中传播自定义异常

使用@RestControllerAdvice实现全局异常处理:

6.1. 全局异常处理器实现

定义错误响应结构:

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;
}

实现全局异常处理:

@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);
    }
}

6.2. 控制器异常处理测试

测试服务不可用场景:

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());

测试资源不存在场景:

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());

关键点

  • 服务不可用(503)→ 返回500 + 详细错误信息
  • 资源不存在(404)→ 返回404 + 明确错误提示
  • 若未实现CustomErrorDecoder,需在@RestControllerAdvice中处理默认FeignException作为兜底方案

7. 总结

本文系统分析了Feign和Spring的默认异常处理机制,并提供了两套优化方案:

  1. Feign层:通过CustomErrorDecoder将HTTP状态码转换为业务异常
  2. Spring层:使用@RestControllerAdvice实现全局异常处理

这种分层处理策略既能保留原始错误信息,又能提供符合业务场景的响应格式,是微服务异常处理的最佳实践。完整代码示例可在GitHub仓库中获取。


原始标题:Propagating Exceptions With OpenFeign and Spring | Baeldung