跳到主要内容

错误处理与异常处理

在微服务架构中,服务间的调用可能会因为各种原因失败。本章将深入讲解 OpenFeign 的错误处理机制,帮助你构建健壮的服务调用层。

Feign 异常体系

异常继承结构

OpenFeign 定义了一套完整的异常体系,所有异常都继承自 FeignException

FeignException (abstract)
├── FeignException.BadRequest (400)
├── FeignException.Unauthorized (401)
├── FeignException.Forbidden (403)
├── FeignException.NotFound (404)
├── FeignException.MethodNotAllowed (405)
├── FeignException.NotAcceptable (406)
├── FeignException.ProxyAuthenticationRequired (407)
├── FeignException.RequestTimeout (408)
├── FeignException.Conflict (409)
├── FeignException.Gone (410)
├── FeignException.UnsupportedMediaType (415)
├── FeignException.TooManyRequests (429)
├── FeignException.InternalServerError (500)
├── FeignException.NotImplemented (501)
├── FeignException.BadGateway (502)
├── FeignException.ServiceUnavailable (503)
├── FeignException.GatewayTimeout (504)
└── FeignException.RetryableException (可重试异常)

异常信息获取

FeignException 提供了丰富的方法来获取错误信息:

try {
User user = userClient.getUserById(1L);
} catch (FeignException e) {
// 获取 HTTP 状态码
int status = e.status();

// 获取请求 URL
String url = e.request().url();

// 获取请求方法
HttpMethod method = e.httpMethod();

// 获取请求头
Map<String, Collection<String>> headers = e.request().headers();

// 获取请求体
byte[] requestBody = e.request().body();

// 获取响应体(如果有)
byte[] responseBody = e.responseBody().orElse(null);
String responseBodyString = e.contentUTF8();

// 获取异常消息
String message = e.getMessage();

log.error("Feign 调用失败: {} {}, 状态码: {}, 响应: {}",
method, url, status, responseBodyString);
}

异常类型判断

try {
User user = userClient.getUserById(userId);
} catch (FeignException e) {
if (e instanceof FeignException.NotFound) {
// 404:资源不存在
throw new ResourceNotFoundException("用户不存在: " + userId);

} else if (e instanceof FeignException.Unauthorized) {
// 401:未授权
throw new AuthenticationException("认证失败,请重新登录");

} else if (e instanceof FeignException.Forbidden) {
// 403:无权限
throw new PermissionDeniedException("无权访问该资源");

} else if (e instanceof FeignException.BadRequest) {
// 400:请求参数错误
String errorDetail = parseErrorDetail(e.contentUTF8());
throw new ValidationException("参数错误: " + errorDetail);

} else if (e instanceof FeignException.TooManyRequests) {
// 429:请求过于频繁
String retryAfter = e.responseHeaders().get("Retry-After")
.stream().findFirst().orElse("60");
throw new RateLimitException("请求过于频繁,请 " + retryAfter + " 秒后重试");

} else if (e instanceof FeignException.ServiceUnavailable) {
// 503:服务不可用
throw new ServiceUnavailableException("服务暂时不可用,请稍后重试");

} else if (e instanceof FeignException.RetryableException) {
// 可重试异常(通常是超时)
throw new ServiceTimeoutException("请求超时,请稍后重试");

} else {
// 其他异常
throw new ServiceException("服务调用失败: " + e.getMessage());
}
}

自定义错误解码器

基本实现

错误解码器允许你将 HTTP 错误响应转换为自定义异常:

public class CustomErrorDecoder implements ErrorDecoder {

private static final Logger log = LoggerFactory.getLogger(CustomErrorDecoder.class);
private final ErrorDecoder defaultDecoder = new ErrorDecoder.Default();

@Override
public Exception decode(String methodKey, Response response) {
int status = response.status();
String body = getResponseBody(response);

log.error("Feign 调用错误: method={}, status={}, body={}",
methodKey, status, body);

// 尝试解析业务错误信息
ApiError apiError = parseApiError(body);

switch (status) {
case 400:
return new BadRequestException(apiError.getMessage());
case 401:
return new UnauthorizedException(apiError.getMessage());
case 403:
return new ForbiddenException(apiError.getMessage());
case 404:
return new NotFoundException(apiError.getMessage());
case 409:
return new ConflictException(apiError.getMessage());
case 429:
return new RateLimitException(apiError.getMessage());
case 500:
case 502:
case 503:
case 504:
// 服务端错误,可能需要重试
return new RetryableException(
status,
apiError.getMessage(),
HttpMethod.valueOf(response.request().httpMethod().name()),
null,
response.request()
);
default:
return defaultDecoder.decode(methodKey, response);
}
}

private String getResponseBody(Response response) {
try {
if (response.body() != null) {
return Util.toString(response.body().asReader(StandardCharsets.UTF_8));
}
} catch (IOException e) {
log.warn("Failed to read response body", e);
}
return "";
}

private ApiError parseApiError(String body) {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(body, ApiError.class);
} catch (Exception e) {
return new ApiError("Unknown error", body);
}
}
}

业务错误响应结构

通常服务端会返回结构化的错误信息:

// API 错误响应结构
public class ApiError {
private String code; // 业务错误码
private String message; // 错误消息
private List<FieldError> errors; // 字段级错误
private String traceId; // 追踪 ID

// getter、setter 省略
}

public class FieldError {
private String field;
private String message;

// getter、setter 省略
}

注册错误解码器

public class FeignConfig {

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

或者在 YAML 中配置:

spring:
cloud:
openfeign:
client:
config:
user-service:
errorDecoder: com.example.feign.CustomErrorDecoder

全局异常处理

使用 @ControllerAdvice

在 Spring Boot 应用中,可以使用 @ControllerAdvice 统一处理 Feign 异常:

@RestControllerAdvice
public class FeignExceptionHandler {

private static final Logger log = LoggerFactory.getLogger(FeignExceptionHandler.class);

/**
* 处理 Feign 404 异常
*/
@ExceptionHandler(FeignException.NotFound.class)
public ResponseEntity<ErrorResponse> handleNotFound(FeignException.NotFound e) {
log.warn("资源不存在: {}", e.request().url());

return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("RESOURCE_NOT_FOUND", "请求的资源不存在"));
}

/**
* 处理 Feign 401 异常
*/
@ExceptionHandler(FeignException.Unauthorized.class)
public ResponseEntity<ErrorResponse> handleUnauthorized(FeignException.Unauthorized e) {
log.warn("认证失败: {}", e.request().url());

return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("UNAUTHORIZED", "认证失败,请重新登录"));
}

/**
* 处理 Feign 403 异常
*/
@ExceptionHandler(FeignException.Forbidden.class)
public ResponseEntity<ErrorResponse> handleForbidden(FeignException.Forbidden e) {
log.warn("权限不足: {}", e.request().url());

return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("FORBIDDEN", "无权访问该资源"));
}

/**
* 处理 Feign 429 异常
*/
@ExceptionHandler(FeignException.TooManyRequests.class)
public ResponseEntity<ErrorResponse> handleTooManyRequests(FeignException.TooManyRequests e) {
log.warn("请求过于频繁: {}", e.request().url());

String retryAfter = e.responseHeaders().getOrDefault("Retry-After", List.of("60"))
.get(0);

return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", retryAfter)
.body(new ErrorResponse("RATE_LIMITED", "请求过于频繁,请稍后重试"));
}

/**
* 处理 Feign 5xx 异常
*/
@ExceptionHandler({
FeignException.InternalServerError.class,
FeignException.BadGateway.class,
FeignException.ServiceUnavailable.class,
FeignException.GatewayTimeout.class
})
public ResponseEntity<ErrorResponse> handleServerError(FeignException e) {
log.error("服务端错误: url={}, status={}", e.request().url(), e.status(), e);

return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse("SERVICE_ERROR", "服务暂时不可用,请稍后重试"));
}

/**
* 处理 Feign 可重试异常(通常是超时)
*/
@ExceptionHandler(FeignException.RetryableException.class)
public ResponseEntity<ErrorResponse> handleRetryableException(FeignException.RetryableException e) {
log.error("请求超时: {}", e.request().url(), e);

return ResponseEntity
.status(HttpStatus.GATEWAY_TIMEOUT)
.body(new ErrorResponse("TIMEOUT", "请求超时,请稍后重试"));
}

/**
* 处理其他 Feign 异常
*/
@ExceptionHandler(FeignException.class)
public ResponseEntity<ErrorResponse> handleFeignException(FeignException e) {
log.error("Feign 调用失败: status={}, url={}", e.status(), e.request().url(), e);

return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("FEIGN_ERROR", "服务调用失败"));
}
}

// 错误响应结构
public record ErrorResponse(String code, String message) {}

针对不同客户端的异常处理

如果需要针对不同的 Feign 客户端使用不同的异常处理策略:

@RestControllerAdvice
public class FeignExceptionHandler {

@ExceptionHandler(FeignException.class)
public ResponseEntity<ErrorResponse> handleFeignException(
FeignException e,
HttpServletRequest request) {

// 根据请求路径判断是哪个服务的调用
String requestUri = request.getRequestURI();

if (requestUri.contains("/orders")) {
return handleOrderServiceError(e);
} else if (requestUri.contains("/users")) {
return handleUserServiceError(e);
} else {
return handleGenericError(e);
}
}

private ResponseEntity<ErrorResponse> handleOrderServiceError(FeignException e) {
// 订单服务特定的错误处理
if (e.status() == 404) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("ORDER_NOT_FOUND", "订单不存在"));
}
// ...
return handleGenericError(e);
}

private ResponseEntity<ErrorResponse> handleUserServiceError(FeignException e) {
// 用户服务特定的错误处理
// ...
return handleGenericError(e);
}

private ResponseEntity<ErrorResponse> handleGenericError(FeignException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("SERVICE_ERROR", "服务调用失败"));
}
}

重试策略

理解重试触发条件

OpenFeign 的重试机制由 Retryer 控制。默认情况下,Spring Cloud OpenFeign 使用 Retryer.NEVER_RETRY,即不重试。

以下情况可能触发重试:

  • 连接超时:无法建立 TCP 连接
  • 读取超时:连接已建立,但等待响应超时
  • 服务端返回 5xx:服务端错误
  • IOException:网络 IO 异常

自定义重试器

public class SmartRetryer implements Retryer {

private final int maxAttempts;
private final long period;
private final long maxPeriod;
private int attempt;

public SmartRetryer(int maxAttempts, long period, long maxPeriod) {
this.maxAttempts = maxAttempts;
this.period = period;
this.maxPeriod = maxPeriod;
this.attempt = 1;
}

@Override
public void continueOrPropagate(RetryableException e) {
// 超过最大重试次数
if (attempt >= maxAttempts) {
throw e;
}

// 根据异常类型决定是否重试
if (!shouldRetry(e)) {
throw e;
}

// 计算等待时间(指数退避)
long interval = calculateInterval();

try {
Thread.sleep(interval);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw e;
}

attempt++;
}

private boolean shouldRetry(RetryableException e) {
int status = e.status();

// 服务端错误可以重试
if (status >= 500 && status < 600) {
return true;
}

// 连接相关异常可以重试
Throwable cause = e.getCause();
if (cause instanceof java.net.ConnectException) {
return true;
}
if (cause instanceof java.net.SocketTimeoutException) {
// 读取超时是否重试取决于业务场景
// 这里选择不重试,避免重复操作
return false;
}

return false;
}

private long calculateInterval() {
// 指数退避算法
long interval = (long) (period * Math.pow(2, attempt - 1));
return Math.min(interval, maxPeriod);
}

@Override
public Retryer clone() {
return new SmartRetryer(maxAttempts, period, maxPeriod);
}
}

配置重试器

@Configuration
public class FeignConfig {

@Bean
public Retryer retryer() {
// 最多重试 3 次,初始间隔 100ms,最大间隔 1s
return new SmartRetryer(3, 100, 1000);
}
}

重试与幂等性

重试前必须考虑接口的幂等性:

public class IdempotentRetryer implements Retryer {

// 幂等的 HTTP 方法
private static final Set<String> IDEMPOTENT_METHODS =
Set.of("GET", "HEAD", "OPTIONS", "PUT", "DELETE");

@Override
public void continueOrPropagate(RetryableException e) {
String method = e.method().name();

// 非幂等方法不重试
if (!IDEMPOTENT_METHODS.contains(method)) {
throw e;
}

// 幂等方法可以重试
// ... 重试逻辑
}

@Override
public Retryer clone() {
return new IdempotentRetryer();
}
}

对于 POST 等非幂等方法,可以通过添加请求 ID 实现幂等:

// 添加请求 ID 拦截器
@Bean
public RequestInterceptor requestIdInterceptor() {
return template -> {
String requestId = UUID.randomUUID().toString();
template.header("X-Request-Id", requestId);
};
}

// 服务端去重处理
// 收到请求后先检查 X-Request-Id 是否已处理过
// 如果已处理,直接返回之前的结果

超时处理

超时类型

OpenFeign 涉及多种超时:

超时类型说明配置属性
连接超时建立 TCP 连接的超时时间connectTimeout
读取超时等待响应数据的超时时间readTimeout
写入超时发送请求数据的超时时间writeTimeout(OkHttp)
连接请求超时从连接池获取连接的超时时间connectionRequestTimeout

超时配置最佳实践

spring:
cloud:
openfeign:
client:
config:
default:
connectTimeout: 5000 # 连接超时 5 秒
readTimeout: 30000 # 读取超时 30 秒

# 快速响应的服务
auth-service:
connectTimeout: 3000
readTimeout: 5000 # 认证服务 5 秒足够

# 需要长时间处理的服务
report-service:
connectTimeout: 5000
readTimeout: 120000 # 报表服务 2 分钟

# 文件服务
file-service:
connectTimeout: 10000
readTimeout: 300000 # 文件上传/下载 5 分钟

超时异常处理

@Service
public class UserService {

private final UserClient userClient;

public User getUserWithFallback(Long userId) {
try {
return userClient.getUserById(userId);
} catch (FeignException.RetryableException e) {
// 超时异常
log.warn("获取用户超时: userId={}", userId);

// 尝试从缓存获取
User cachedUser = cacheService.getUserFromCache(userId);
if (cachedUser != null) {
return cachedUser;
}

// 返回默认值
return new User(userId, "未知用户", null);
}
}
}

熔断降级

与 Resilience4j 集成

// 配置类
@Configuration
public class CircuitBreakerConfig {

@Bean
public CircuitBreakerConfig customCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率 50% 触发熔断
.waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断 10 秒
.slidingWindowSize(10) // 滑动窗口大小
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.minimumNumberOfCalls(5) // 最小调用次数
.build();
}
}
# YAML 配置
resilience4j:
circuitbreaker:
configs:
default:
failureRateThreshold: 50
waitDurationInOpenState: 10s
slidingWindowSize: 10
instances:
user-service:
baseConfig: default
order-service:
baseConfig: default
failureRateThreshold: 30 # 订单服务更敏感

使用 FallbackFactory 处理异常

@Component
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {

private static final Logger log = LoggerFactory.getLogger(UserClientFallbackFactory.class);

@Override
public UserClient create(Throwable cause) {
return new UserClient() {
@Override
public User getUserById(Long id) {
// 根据异常类型返回不同的降级结果
if (cause instanceof FeignException.NotFound) {
// 404 不是真正的错误,可能是用户确实不存在
return null;
}

if (cause instanceof FeignException.Unauthorized) {
// 认证失败需要抛出异常,不能静默降级
throw new AuthenticationException("认证失败", cause);
}

if (cause instanceof FeignException.TooManyRequests) {
// 限流需要特殊处理
throw new RateLimitException("请求过于频繁", cause);
}

// 其他异常,记录日志并返回默认值
log.error("UserClient.getUserById fallback: id={}, error={}",
id, cause.getMessage());

return new User(id, "默认用户", null);
}

@Override
public List<User> getAllUsers() {
log.error("UserClient.getAllUsers fallback", cause);
return Collections.emptyList();
}
};
}
}

日志记录

结构化错误日志

@Aspect
@Component
public class FeignLoggingAspect {

private static final Logger log = LoggerFactory.getLogger(FeignLoggingAspect.class);

@AfterThrowing(pointcut = "execution(* com.example.client..*(..))", throwing = "ex")
public void logFeignException(JoinPoint joinPoint, FeignException ex) {
Map<String, Object> errorLog = new LinkedHashMap<>();

errorLog.put("timestamp", Instant.now().toString());
errorLog.put("method", joinPoint.getSignature().toShortString());
errorLog.put("httpStatus", ex.status());
errorLog.put("url", ex.request().url());
errorLog.put("httpMethod", ex.httpMethod().name());
errorLog.put("responseBody", ex.contentUTF8());
errorLog.put("exceptionClass", ex.getClass().getSimpleName());
errorLog.put("errorMessage", ex.getMessage());

// 提取 Trace ID(如果有)
ex.request().headers().get("X-Trace-Id")
.stream()
.findFirst()
.ifPresent(traceId -> errorLog.put("traceId", traceId));

log.error("Feign 调用失败: {}", errorLog);
}
}

错误追踪

public class TracingInterceptor implements RequestInterceptor {

@Override
public void apply(RequestTemplate template) {
// 添加追踪信息
String traceId = MDC.get("traceId");
if (traceId != null) {
template.header("X-Trace-Id", traceId);
}

String spanId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
template.header("X-Span-Id", spanId);

// 记录请求开始时间
template.header("X-Request-Start-Time", String.valueOf(System.currentTimeMillis()));
}
}

// 在错误解码器中添加追踪信息
public class TracingErrorDecoder implements ErrorDecoder {

@Override
public Exception decode(String methodKey, Response response) {
String traceId = response.request().headers().get("X-Trace-Id")
.stream()
.findFirst()
.orElse("unknown");

// 将追踪 ID 添加到异常信息中
FeignException exception = new Default().decode(methodKey, response);

log.error("Feign error [traceId={}]: status={}, method={}, url={}",
traceId, response.status(), methodKey, response.request().url());

return exception;
}
}

错误处理最佳实践

1. 区分可恢复和不可恢复错误

public enum ErrorRecoverability {
RECOVERABLE, // 可恢复:可以重试或降级
NON_RECOVERABLE // 不可恢复:需要用户介入
}

public class ErrorRecoveryStrategy {

public ErrorRecoverability analyze(FeignException e) {
// 4xx 错误通常是客户端问题,不可恢复
if (e.status() >= 400 && e.status() < 500) {
// 401 可能可以通过刷新 Token 恢复
if (e.status() == 401) {
return ErrorRecoverability.RECOVERABLE;
}
return ErrorRecoverability.NON_RECOVERABLE;
}

// 5xx 错误通常是服务端问题,可能可以恢复
if (e.status() >= 500) {
return ErrorRecoverability.RECOVERABLE;
}

// 网络错误可能可以恢复
if (e instanceof FeignException.RetryableException) {
return ErrorRecoverability.RECOVERABLE;
}

return ErrorRecoverability.NON_RECOVERABLE;
}
}

2. 不要吞掉异常

// 错误示例:吞掉异常
@Override
public User getUserById(Long id) {
try {
return userClient.getUserById(id);
} catch (FeignException e) {
return null; // 错误!完全隐藏了错误
}
}

// 正确示例:记录并处理
@Override
public User getUserById(Long id) {
try {
return userClient.getUserById(id);
} catch (FeignException.NotFound e) {
// 404 是预期内的错误,可以返回 null
log.debug("User not found: {}", id);
return null;
} catch (FeignException e) {
// 其他错误必须记录
log.error("Failed to get user: {}", id, e);
throw new ServiceException("获取用户失败", e);
}
}

3. 使用断路器保护

@Service
public class UserService {

private final UserClient userClient;
private final CircuitBreaker circuitBreaker;

public UserService(UserClient userClient, CircuitBreakerRegistry registry) {
this.userClient = userClient;
this.circuitBreaker = registry.circuitBreaker("user-service");
}

public User getUserById(Long id) {
return circuitBreaker.executeSupplier(() -> userClient.getUserById(id));
}

public User getUserByIdWithFallback(Long id) {
return circuitBreaker.executeSupplier(
() -> userClient.getUserById(id),
() -> new User(id, "默认用户", null) // 降级逻辑
);
}
}

4. 合理设置超时时间

// 超时时间应该基于服务的 P99 响应时间
// P99 响应时间 + 安全边际 = 超时时间

// 例如:
// 认证服务 P99 = 500ms -> 超时 = 500ms * 3 = 1500ms
// 普通业务服务 P99 = 2s -> 超时 = 2s * 3 = 6s
// 报表服务 P99 = 30s -> 超时 = 30s * 2 = 60s

小结

本章详细介绍了 OpenFeign 的错误处理机制:

主题要点
异常体系了解 FeignException 的子类及其含义
错误解码器自定义 HTTP 错误到业务异常的转换
全局异常处理使用 @ControllerAdvice 统一处理
重试策略根据异常类型和幂等性决定是否重试
超时处理合理配置各类超时时间
熔断降级使用断路器保护服务调用
日志记录结构化记录错误信息,便于追踪

下一章将介绍测试 Feign 客户端的方法。