异常处理与 ProblemDetail:让错误响应保持一致
一个订单请求因价格变化被拒绝,客户端需要知道应重新读取价格;服务器内部故障则应提示稍后处理,并让维护人员找到对应日志。两者都要返回错误,但不能共用一段未经分类的异常消息。
HTTP 状态描述通用结果,错误主体补充应用可以识别的信息。Spring MVC 的异常解析链负责把 Java 异常转换为这种响应。
错误对象应该包含什么
HTTP 状态和 ProblemDetail 字段
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "urn:lab:price-conflict",
"title": "Price conflict",
"status": 409,
"detail": "Reload the current price before submitting",
"instance": "/errors/conflict",
"code": "PRICE_CONFLICT"
}这是实验接口 /errors/conflict 的响应形态。字段分工如下:
| 字段 | 用途 | 使用方式 |
|---|---|---|
| type | 问题类型的 URI 标识 | 客户端按稳定类型分类,避免解析自然语言 |
| title | 该问题类型的简短名称 | 可本地化,不因每次异常细节而改名 |
| status | 对应的 HTTP 状态 | 与实际响应状态保持一致 |
| detail | 本次发生问题的说明 | 告诉调用者可以怎样处理,不暴露内部堆栈 |
| instance | 本次问题的 URI 引用 | 可指向请求路径或受控的问题记录 |
| code 等扩展字段 | 应用约定的额外数据 | 明确定义类型、稳定性及客户端处理规则 |
RFC 9457定义了标准字段与扩展方式。type 是主要的问题类型标识;省略时按 about:blank 处理。实验的 urn:lab 前缀是不可解引用的实验标识,生产接口可以使用自己控制的文档地址,长期解释这一类问题。
如果 instance 只取当前请求路径,多次相同路径请求会有相同值。需要定位单次调用时,可以另外返回受控的 requestId,并把它与服务器日志关联,不要把路径误当成唯一事件编号。
选择能指导调用者的状态
| 情况 | 常见 HTTP 状态 | 客户端下一步 |
|---|---|---|
| 主体无法解析、输入约束失败 | 400 | 修正请求内容 |
| 未完成认证 | 401 | 按认证协议取得或更新凭据 |
| 已识别主体无权执行 | 403 | 停止同权限重试,申请权限或改用允许操作 |
| 资源不存在或按策略不可见 | 404 | 检查资源标识与访问范围 |
| 当前资源状态与操作冲突 | 409 | 重新读取状态,再决定提交 |
| 请求媒体类型不支持 | 415 | 使用接口接受的 Content-Type |
| 服务器无法产生可接受格式 | 406 | 调整 Accept 或使用受支持接口 |
| 服务内部故障 | 500 | 保留请求标识,按接口约定处理重试 |
| 临时无法服务 | 503 | 遵守服务提供的退避与重试提示 |
状态不能脱离具体接口语义。例如唯一约束冲突可能意味着同一幂等请求重放,也可能意味着用户正在占用已存在的名称,处理方式由业务键的定义决定。协议级状态及必需头部见 HTTP Semantics。
统一返回 HTTP 200,再把错误塞入 code=500,会让通用客户端、监控与缓存难以识别失败。反过来,只返回 409 而不说明发生了哪一类冲突,也会迫使客户端猜测。
公开说明与内部诊断分开保存
数据库 SQL、文件路径、远程凭据、完整入参和异常堆栈通常只属于受限诊断信息。公开 detail 应为经过选择的描述;不能直接使用 exception.getMessage(),因为底层消息可能包含连接地址、SQL 值或实现类型。
稳定的 code 用来支持程序分支,title 和 detail 用来帮助阅读。客户端不应通过匹配“库存不足”这几个字判断业务状态,否则文案或语言一变就可能失效。
字段错误也遵守相同原则:可以返回 field=quantity、code=MIN_VALUE 和允许下限,敏感 rejectedValue 则省略或脱敏。批量提交时还应标识具体条目,避免把多个无关问题压成难以处理的一段字符串。
Spring MVC 怎样解析异常
Resolver 的三种返回结果
请求映射、参数准备或 handler 执行发生异常后,DispatcherServlet 会调用适用的 HandlerExceptionResolver。各解析器依次尝试,返回结果控制后续行为:
返回 null 表示“我不处理”;空 ModelAndView 表示“我已经处理”。混淆两者,会导致写完响应后又调用另一个错误处理器,或没有输出却提前结束解析。实验通过真实 DispatcherServlet 调用验证:第一个返回 null,第二个设置 409 并返回空 ModelAndView,第三个不会执行。接口契约见 MVC 异常解析。
默认配置中的 ExceptionHandlerExceptionResolver 负责 @ExceptionHandler;ResponseStatusExceptionResolver 处理相应状态异常和注解;DefaultHandlerExceptionResolver 为常见框架异常映射状态。注册自定义解析器时,先确定是扩展现有列表,还是确实要替换整组默认行为。
局部处理器、全局 Advice 和优先级
Controller 自己声明的 @ExceptionHandler 先于适用的全局 Advice。实验的 LocalMissing 同时存在局部和全局处理方法,实际响应中 handledBy=controller。
@ExceptionHandler(LocalMissing.class)
ProblemDetail localHandler(LocalMissing error) {
var result = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, "Local resource missing");
result.setType(URI.create("urn:lab:local-missing"));
result.setProperty("handledBy", "controller");
return result;
}多个 Advice 按顺序选择。order 数值较小的通常先处理;在同一个处理器中,直接异常类型匹配通常优先于 cause 匹配,但一个高优先级 Advice 的 cause 匹配仍可能胜过低优先级 Advice 的根异常匹配。
异常处理方法尽量接收具体异常类型。把所有异常都写为 Exception,既容易提前接走本该由框架处理的错误,也不容易分清传入的对象究竟是包装异常还是内部 cause。Exception Mapping说明了匹配深度、多个 Advice 和重新抛出原异常的规则。
内置异常与业务异常分工
实验采用 @RestControllerAdvice,继承 ResponseEntityExceptionHandler。基类处理 MVC 内置错误,子类增加 PriceConflict 等业务异常:
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler {
@ExceptionHandler(PriceConflict.class)
ProblemDetail conflict(PriceConflict error) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"Reload the current price before submitting");
problem.setType(URI.create("urn:lab:price-conflict"));
problem.setTitle("Price conflict");
problem.setProperty("code", "PRICE_CONFLICT");
return problem;
}
}ProblemDetail 的 properties 扩展在 Jackson 序列化时作为顶层字段输出,所以响应是 code 字段,不是额外的 properties.code。直接返回 ProblemDetail 时,框架使用其 status 设置 HTTP 状态,未指定 instance 时会使用当前 URL 路径。Spring Error Responses说明了这些行为。
Boot 的 spring.mvc.problemdetails.enabled 可以为内置异常启用相应自动配置。已经自定义 ResponseEntityExceptionHandler 时,应检查实际 Advice 注册与顺序,避免同时添加几套广泛匹配的处理器。框架默认 400、405、415 的语义不应被一个 catch-all 全部改成 500。
这里还有一个容易遗漏的服务器错误:HandlerMethodValidationException 同时用于方法输入和返回值验证。输入失败通常为 400,返回值违反服务器自身声明应为 500。继承的处理逻辑保留这种区分;自定义重写时也要检查 isForReturnValue。具体入口见 MVC Validation。
请求失败后,哪些位置还能生成响应
MVC 之外的错误
Filter 位于 DispatcherServlet 外围。Filter 自己抛出的异常,或 Spring Security 的认证、拒绝访问处理,不会自动经过 Controller 的 @ExceptionHandler。
容器 / Filter / Security
├── 直接拒绝或抛错 → 对应过滤器、安全处理器或容器错误页
└── DispatcherServlet
├── 正常 handler → 正常响应
└── MVC 异常 → HandlerExceptionResolver如果需要它们都采用相同错误格式,应复用一个安全的错误构造/写出组件,并在各自的扩展点接入。认证失败还要保留协议要求的头部和跳转行为,不能只照抄 Controller Advice。
实验 /chain/filter-stop 由 Filter 直接返回 403;事件记录没有 Controller 和 advice 回调。这条对照用于确认异常处理位置,完整注册与上下文处理见 Filter 与请求上下文。
未被处理的异常可以传播给 Servlet 容器,容器按错误页配置进行 ERROR 分派;Boot 常用 /error 承接。一个已经由 Advice 正常完成的 404 响应,不必再经过 /error。也不要把 setStatus(500) 与 sendError(500) 视为同一个操作:后者请求容器执行错误处理,具体响应还受提交状态影响。Boot 错误处理及 Servlet 6.1 规范分别说明框架与容器行为。
响应已提交之后
response.isCommitted() 为 true 时,状态和头部已提交。文件下载、SSE 或普通大响应输出到一半发生异常,处理器无法把已发送的 200 改为完整的 500 ProblemDetail。
此时应停止继续写出、释放关联任务和资源,并保存可定位的服务器错误。若协议本身允许流内错误事件,且连接仍然可写,可以按预先约定发送该事件;客户端断开时通常连这一步也无法完成。
不要在 catch 中无条件 resetBuffer 再写 JSON。已提交时清空缓冲会失败,未提交时也要确认原 Content-Type、Content-Length、编码和部分输出状态得到一致处理。
异步结果怎样回到异常解析链
Callable 执行产生异常后,MVC 会把结果作为异步结果恢复处理;DeferredResult 需要通过 setErrorResult 交回错误。自己提交到其他线程池的任务若只记录日志,没有把结果交还请求,客户端可能一直等到超时。
异步超时与业务任务停止是两个事件。请求超时后仍在执行的导出、数据库查询或远程调用,需要由应用持有句柄并实施取消或其他终止策略。不同返回类型的处理见 Asynchronous Requests。
运行错误响应并检查输出
准备和首次请求
下载 MVC 实验工程,按Linux 准备步骤解包编译并启动 mvc09-binding。环境是普通 Linux 用户、Docker 与 curl;应用以 UID 10001 运行,只有 127.0.0.1:18091 发布端口。先确认 /lab/health 返回 UP。
下面用 curl 分开保存头部和主体。预计 409,因此不使用把 HTTP 错误转为非零退出的 --fail-with-body:
BASE=http://127.0.0.1:18091
HEADERS="$(mktemp)"
BODY="$(mktemp)"
STATUS="$(curl -q --noproxy '*' -sS --max-time 5 \
-D "$HEADERS" -o "$BODY" -w '%{http_code}' \
"$BASE/errors/conflict")"
TRANSFER=$?
test "$TRANSFER" -eq 0 || exit 1
test "$STATUS" = 409 || exit 1
cat "$HEADERS"
cat "$BODY"
rm -- "$HEADERS" "$BODY"结果应包含 application/problem+json、status=409、code=PRICE_CONFLICT,以及建议重新读取价格的 detail。实际响应状态和 JSON 中的 status 都要检查,避免只修了其中一个。
-q 在第一个选项位置关闭默认 curlrc,--noproxy '*' 隔离代理。若 transfer 非零,先检查连接、超时或主体截断,不要继续根据不完整 JSON 判断业务错误。curl 手册可查选项及退出码。
对照局部、内部与输入错误
| 请求 | 预期 | 观察重点 |
|---|---|---|
| GET /errors/local | 404 | handledBy=controller,局部处理优先 |
| GET /errors/unexpected | 500 | code=INTERNAL_ERROR,无内部秘密字符串 |
| GET /validation/quantity?quantity=0 | 400 | 方法输入约束失败 |
| GET /validation/broken-output | 500 | 方法返回值约束失败 |
| GET /chain/filter-stop | 403 | Filter 输出,未进入 MVC Advice |
可以沿用上一段保存和检查状态的方式逐条请求。正常输入对照是 /validation/quantity?quantity=2,它应返回 200。内部故障实验故意抛出带有内部标记的异常,响应只给出 The request could not be completed;真实生产实现还要在受限日志中保存异常及请求关联信息。
工程中的 ErrorsAndAdviceTest 断言字段、媒体类型和局部优先级,并在独立 MVC 实例中验证 Resolver 的 null 与空 ModelAndView。MappingValidationTest 负责输入/返回校验区别;DispatcherChainTest 通过真实 HTTP 检查异常回程及 Filter 拒绝。它们随 clean verify 一起执行。
Advice 不生效时按位置排查
| 观察结果 | 可能原因 | 下一步 |
|---|---|---|
| 没有进入 DispatcherServlet | 网关、容器或 Filter 已拒绝 | 检查对应层日志及实际响应来源 |
| 进入 Controller,但调用了别的处理器 | 局部方法、Advice order 或匹配范围 | 检查实际选中的异常处理方法 |
| 原来的 400 变为 500 | catch-all 接管框架异常 | 恢复内置分类或委托基类处理 |
| 只在某些 Accept 下失败 | 错误体也需要媒体协商 | 检查 ProblemDetail 转换器与错误方法 produces |
| 出现二次写出或已提交异常 | 第一次响应已完成或流已开始 | 查最早写出点,不再尝试重写状态 |
| 日志有后台异常,客户端只等到超时 | 自管异步任务没有交回结果 | 检查 setErrorResult、完成回调及任务句柄 |
一次请求应有清楚的错误处理责任。底层保存诊断信息、业务层分类、Web 层生成协议响应可以协作,但不要在每层都打印同一份完整堆栈,再反复包装成失去原始类型的新异常。
