API 错误契约
API 错误契约规定错误响应的状态和字段,让客户端稳定判断失败类型。本文从错误字段入手,介绍 Spring MVC 怎样映射异常,再说明 HTTP 状态、日志和异常处理的范围。最后用契约测试检查不同错误入口是否遵守同一约定。文中的 ProblemDetail 示例适用于 Spring 6,也就是 Boot 3.x 使用的框架版本。
1. 面试核心
先记住这几条,再读正文- 异常映射。
@RestControllerAdvice配合@ExceptionHandler,通过 MVC 异常解析链将异常集中映射为错误响应。卡 · 错误字段 - 错误响应字段。 Spring 6 的
ProblemDetail提供标准问题字段,并支持业务错误码和 trace ID 等扩展字段。 - 处理器匹配。 同一 Controller 或 Advice 内,Spring MVC 优先匹配顶层异常,再按继承距离选择更具体的类型。卡 · 异常处理器选择
- 异步异常。 通过 MVC 异步派发返回的异常,仍能交给
@ExceptionHandler处理。 - 响应提交边界。 响应已提交后,错误处理器无法重新设置完整响应。
2. 错误字段
错误响应需要同时服务于程序判断和用户展示。Spring 6 的 ProblemDetail 用来表示 HTTP API 中的问题,本文按 RFC 9457 说明其五个标准字段:
type用 URI 标识问题类型。title提供该问题类型的简短描述。status记录本次响应的 HTTP 状态码。detail解释本次失败的具体情况。instance用 URI 标识这一次问题出现的位置或实例。
下面的响应表示订单状态冲突,并扩展业务错误码和请求关联标识:
{
"type": "https://coding101.dev/problems/order-state",
"title": "Order state conflict",
"status": 409,
"detail": "The order has already been paid",
"instance": "/orders/101",
"code": "ORDER_ALREADY_PAID",
"traceId": "..."
}
示例中的 code 和 traceId 是业务扩展字段。traceId 用于关联同一次请求的服务端日志,便于根据报错追踪原因。客户端按 HTTP 状态、type 或稳定错误码选择处理分支,字段校验错误也要采用稳定结构。detail 用于展示,内容可随语言变化,因此应与程序判断分开。
响应使用 application/problem+json。正文的 status 应与实际 HTTP 状态一致。Spring MVC 的消息转换器支持该格式。
3. 异常处理器
异常处理器把 Java 异常转换成客户端能理解的响应。@RestControllerAdvice 集中声明处理方法,@ExceptionHandler 指定方法接收哪些异常。
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(OrderStateException.class)
ResponseEntity<ProblemDetail> handle(OrderStateException exception) {
ProblemDetail problem = ProblemDetail.forStatus(409);
problem.setTitle("Order state conflict");
problem.setProperty("code", "ORDER_STATE_CONFLICT");
return ResponseEntity.status(409).body(problem);
}
}
示例把订单状态冲突映射为 HTTP 409,并返回稳定的 code 字段。异常类型承载失败分类,HTTP 状态告诉客户端和中间代理该怎样理解结果。
应用应分别映射五类失败:
- 输入错误说明请求结构或参数需要修改。
- 认证授权错误说明身份凭证或访问权限有问题。
- 业务冲突说明当前资源状态无法接受该操作。
- 依赖失败说明外部服务调用未能完成。
- 未知异常返回 500,并在服务端日志中保留根因。
这样,客户端才能区分需要修改请求、重新认证或稍后重试的情况。
ResponseEntityExceptionHandler 提供 MVC 标准异常处理基类。继承它可统一绑定错误、媒体类型错误等响应。
4. 状态码
选择状态码时,要表达当前请求为何失败。下表区分了客户端输入、业务状态和服务端处理中的常见问题。
| 状态 | 语义 |
|---|---|
| 400 | 请求语法、参数或校验错误 |
| 401 | 缺少有效认证凭证;需返回 WWW-Authenticate |
| 403 | 服务端理解请求,但拒绝执行 |
| 404 | 资源不存在,或服务端拒绝披露其存在 |
| 409 | 操作与资源当前状态冲突 |
| 429 | 请求频率超过限制 |
| 500 | 服务端未预期错误 |
| 502 | 网关收到无效的上游响应 |
| 503 | 服务暂时无法处理请求 |
| 504 | 网关等待上游响应超时 |
客户端决定重试前,还要检查幂等性,也就是重复请求是否会产生额外副作用。响应中的 Retry-After 可指示等待多久后再尝试,因此仅凭 5xx 无法决定是否安全重试。
依赖失败要结合当前服务的角色映射。例如 502 和 504 表达网关与上游之间的问题,普通业务服务应按实际失败原因选择状态。
5. 日志与响应
错误响应提供客户端处理失败所需的信息,服务端日志则保留排查依据。响应只包含公开字段,堆栈、SQL、文件路径和内部类名留在受控诊断范围,密钥等敏感值应脱敏。
日志应记录 cause,也就是原始异常,以及 trace ID 和必要业务标识。路由可记录为 /orders/{id} 这类规范化模板,便于聚合同类请求。日志中的用户数据和敏感参数同样要脱敏。
预期业务冲突可记录简要信息,未知异常则在处理边界记录一次完整堆栈。若 Controller 和 Advice 都记录同一异常,会产生重复日志,增加定位成本。
6. ControllerAdvice 的处理范围
判断异常能否统一处理,要先看它从哪里抛出。ControllerAdvice 参与 DispatcherServlet 的异常解析链。链中的 HandlerExceptionResolver 负责寻找合适的方式,把异常转换成响应。
需要区分以下三种异常路径:
- Filter 在进入 MVC 前抛出的异常,由过滤链或 Servlet 容器处理。安全过滤链可使用自己的错误处理器,容器也可发起 error dispatch,即错误派发。
- MVC 的
Callable抛错后,会触发异步派发,重新进入 MVC 处理流程。DeferredResult.setErrorResult(exception)也会把异常交给 MVC resolver。 - 独立后台任务没有对应的 MVC 请求派发,其失败由任务结果或任务异常处理器承接。
响应提交后,状态和已写出的正文已经交给客户端,错误处理器无法重新设置完整响应。这时应记录序列化或写出失败,再按连接状态结束处理。客户端断开造成的 I/O 错误应单独统计,避免全部计为应用 500。
7. 字段校验错误
客户端要把校验错误显示在对应输入框旁,就需要稳定的字段路径和错误码。下面的响应表示 quantity 未通过正数校验:
{
"code": "VALIDATION_FAILED",
"errors": [
{"field": "quantity", "code": "Positive"}
]
}
示例用 field 定位字段,用 code 表达校验规则。数组和嵌套对象也要约定路径表示方式,客户端才能找到对应输入项。响应省略密码等敏感拒绝值,展示文本则可由客户端或服务端按语言生成。这样,调整提示文案就不会改变稳定错误码。
8. 契约测试
契约测试用于确认每个入口都返回约定的错误格式。对每类公开错误,检查 HTTP 状态、Content-Type、业务 code 和字段结构。再用敏感值作为输入,确认响应已省略这些值。
测试应覆盖以下错误入口与边界:
- 路由不存在、请求方法错误和不可接受的媒体类型。
- 参数校验失败和 Filter 拒绝请求。
- MVC 异步异常与下游调用超时。
- 序列化或响应写出失败,以及响应已经提交的情况。
这些用例能发现不同入口是否产生了互不兼容的格式。响应已提交的用例则检查记录和结束处理的行为。
9. 异常处理器选择
多个方法都能处理同一个异常时,需要按查找范围和优先级判断。Controller 内的局部 @ExceptionHandler 优先于全局 Advice。多个 Advice 按 Ordered 或 @Order 排序。较小的值具有较高优先级。
同一 Controller 或 Advice 内,先匹配顶层异常。多个类型匹配时,选择继承距离更近的类型。顶层无匹配时再查 cause 链,也就是逐层查找包装在内部的原始异常。Spring 5.3 起支持任意深度的 cause,因此 Boot 2.7 和 3.x 都支持这条规则。
跨 Advice 时,优先级先决定查找顺序。高优先级 Advice 的 cause 匹配可先于低优先级的顶层匹配。所以,应将 Exception 兜底放在低优先级 Advice,避免提前截获本应由具体处理器处理的错误。方法在源码中的书写顺序不参与选择。
10. 常见问题
10.1 所有响应都要使用统一包装吗?
成功响应可以直接返回资源,错误响应保持稳定结构即可。团队使用统一包装时,也要保留正确的 HTTP 状态。如果所有结果都返回 200,代理、监控和客户端就会把失败当作成功,影响告警与重试判断。
10.2 可以捕获 Exception 作为最后兜底吗?
可以在请求边界捕获未知异常,脱敏后返回 500,并记录根因。兜底 Advice 应设置较低优先级,让具体处理器先处理已知错误。响应已提交时,则保留已发出的响应并记录失败。对于请求取消,应结束后续工作,并单独记录取消结果。
11 · 面试题卡片
11.1 Spring MVC 怎样实现统一异常处理
出现公司:CVTE、珍爱网
考察重点:解释异常如何经过 resolver 链映射为 HTTP 状态和稳定错误字段,并区分 Filter、异步和响应提交的边界。
参考回答
使用 @RestControllerAdvice 集中声明 @ExceptionHandler,按异常类型生成统一错误响应。
业务冲突、校验、认证授权、依赖失败和未知异常分别映射到合适状态。Spring 6 可返回 ProblemDetail,再用 ResponseEntity 明确 HTTP 状态。响应附带稳定错误码和 trace ID,完整根因保留在脱敏后的服务端日志中。
这些处理器参与 MVC 的异常解析链。MVC 异步派发中的异常仍可进入该链,Filter 自身异常则交给过滤链或容器。独立后台任务由任务自己的机制承接失败。响应已提交后,应记录失败并结束当前写出。最后用契约测试检查状态、媒体类型、字段和敏感信息是否符合约定。
11.2 多个异常处理器都能匹配时,Spring MVC 怎样选择?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:按局部处理器、Advice 优先级和异常继承关系,推导顶层异常与 cause 最终会匹配哪个方法。
相关内容:异常处理器选择。
参考回答
Spring MVC 优先查找 Controller 的局部处理器,再按优先级查找全局 Advice。
@Order 数值越小,Advice 越早参与查找。
同一 Controller 或 Advice 内,先匹配顶层异常,多个类型匹配时选择继承距离更近的类型。顶层无匹配时,再沿 cause 链查找。因此,高优先级 Advice 的 cause 匹配可以先于低优先级的顶层匹配,源码中的方法顺序则不影响选择。