本节目标:让 libraryd 的每一种错误都有同一个 JSON 形状、同一个媒体类型和稳定的类型 URI。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、Jackson 2.22.4。
承接的问题
8.1 节把字段校验接上了,但校验失败只是抛一个 ValidationFailure。如果没有统一错误契约,这个异常最后可能变成三种响应:
HTTP/1.1 400
Content-Type: text/plain
title must not be blank
{"error":"validation failed","fields":["title"]}
{"code":400,"message":"bad request"}
客户端要写三套解析逻辑,而且每新增一个错误码,就可能多一种形状。更麻烦的是,错误信息里缺少请求 ID,用户报障时只能提供「我点借书失败了」,服务端无法定位是哪一次请求。
Problem Details 就是为这件事设计的。RFC 9457 定义了一种通用的错误对象,媒体类型是 application/problem+json,字段含义稳定,允许扩展。libraryd 采用它,并且约定:
- 所有 4xx/5xx 响应都是
application/problem+json。 type是稳定的 URI,客户端可以按它分支。code是 libraryd 的错误码,日志和客户端都能用。requestId与响应头X-Request-Id一致。errors是字段级错误的 Map,校验失败时使用。
核心机制一:RFC 9457 的五个标准成员
RFC 9457 定义了五个标准成员:
| 成员 | 类型 | 含义 |
|---|---|---|
type | URI reference | 问题类型的稳定标识,默认 about:blank |
title | string | 问题类型的简短、人类可读标题 |
status | integer | 对应的 HTTP 状态码 |
detail | string | 本次问题的具体说明 |
instance | URI reference | 本次问题实例的标识 |
它们的分工不是「五个都写一遍」,而是各回答一个问题:
type回答「这是哪一类问题」。同一个类型在不同请求里应该保持不变,客户端按它做分支、做文档链接。title回答「这类问题叫什么」。它应该和type一一对应,不随请求变化。status回答「HTTP 层是什么结果」。它必须和响应的状态行一致,不能响应 400 却在 body 里写 200。detail回答「这一次具体哪里错了」。它可以包含 ISBN、字段名这类上下文,但不应该包含堆栈、SQL 或内部主机名。instance回答「是哪一次请求」。可以用请求 URI,也可以用一个错误实例 ID;libraryd 用requestId扩展成员承担这个职责,因此没有再填instance。
RFC 9457 允许扩展成员,只要不与标准成员重名。libraryd 加了三个:
package com.plume.library.web.dto;
import java.util.Map;
public record ApiError(
String type,
String title,
int status,
String detail,
String code,
String requestId,
Map<String, String> errors) {
}
code 是机器可读的错误码,比 type 短,适合日志聚合;requestId 把响应和服务端日志串起来;errors 是字段级错误,前端可以直接映射到表单。三个都是扩展成员,标准客户端即使不认识,也能按 type、status、detail 正常展示。
核心机制二:type URI 从 ErrorCode 推导
type 一旦发布就不应该改。客户端可能把它写进重试策略、告警规则或文档链接。libraryd 的错误码在 ErrorCode 里已经有稳定的 wire 值,例如 E_BOOK_ALREADY_BORROWED。把错误码转成 URI 的规则是:
- 去掉
E_前缀。 - 转成小写。
- 下划线替换成连字符。
- 拼到
https://plumphp.com/problems/后面。
代码只有几行:
public static String type(ErrorCode code) {
String raw = code.code();
if (raw.startsWith("E_")) {
raw = raw.substring(2);
}
return TYPE_PREFIX + raw.toLowerCase().replace('_', '-');
}
完整的映射在 ErrorCode 上跑一遍,本机输出如下:
E_BOOK_NOT_FOUND 404 https://plumphp.com/problems/book-not-found
E_MEMBER_NOT_FOUND 404 https://plumphp.com/problems/member-not-found
E_BOOK_ALREADY_BORROWED 409 https://plumphp.com/problems/book-already-borrowed
E_DUPLICATE_ISBN 400 https://plumphp.com/problems/duplicate-isbn
E_LOAN_NOT_FOUND 404 https://plumphp.com/problems/loan-not-found
E_LOAN_LIMIT_EXCEEDED 409 https://plumphp.com/problems/loan-limit-exceeded
E_RENEWAL_NOT_ALLOWED 409 https://plumphp.com/problems/renewal-not-allowed
E_INVALID_COMMAND 400 https://plumphp.com/problems/invalid-command
E_VALIDATION_FAILED 400 https://plumphp.com/problems/validation-failed
E_DB_UNAVAILABLE 503 https://plumphp.com/problems/db-unavailable
E_INTERNAL 500 https://plumphp.com/problems/internal
这套 URI 有两个好处:
- 它不依赖请求路径。
/api/books和/api/loans返回同一个BOOK_NOT_FOUND时,type相同,客户端只需要一套处理逻辑。 - 它和错误码同源。新增错误码时,URI 自动生成,不需要在 web 层再维护一张表。反过来说,重命名错误码会改变
type,所以错误码本身要当成公开契约对待。
type 用 https:// 而不是 urn:,是因为它可以指向一个真实文档页。https://plumphp.com/problems/book-already-borrowed 将来可以渲染成「这本书已经被借出,请先归还或换一本」的说明页。即使文档页还没上线,URI 仍然稳定。
核心机制三:title 按状态码,detail 按场景
title 不应该由每个 handler 自己拼。它和状态码绑定,用一张小表就能覆盖:
public static String title(int status) {
return switch (status) {
case 400 -> "Bad Request";
case 404 -> "Not Found";
case 409 -> "Conflict";
case 500 -> "Internal Server Error";
case 503 -> "Service Unavailable";
default -> "Error";
};
}
为什么不把 title 写成「Book already borrowed」这种更具体的文本?因为 RFC 9457 的 title 是「问题类型」的标题,不是「这一次」的标题。BOOK_ALREADY_BORROWED 和 LOAN_LIMIT_EXCEEDED 都是 409,但它们的 type 不同;如果 title 也跟着变,客户端就要同时维护两套映射。保持 title 与状态码一致,客户端可以先按 status 做粗分类,再按 type 做细分支。
detail 则相反,它是本次请求的具体说明。同一个 BOOK_NOT_FOUND,detail 可以是:
book not found: 9787111213826
book not found: does-not-exist
这两条信息的 type、title、status、code 都一样,只有 detail 不同。客户端不应该解析 detail,它只用于展示和排查。
detail 的边界是「能帮助客户端修正请求,但不暴露内部实现」。可以写 ISBN、字段名、限制值;不要写 SQL、堆栈、数据库连接串、内部 IP。500 的 detail 固定为 internal server error,真正的异常留在日志里,用 requestId 关联。
在 libraryd 里落地:ProblemDetails.write
把上面的规则收进一个类:
package com.plume.library.web;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.plume.library.domain.ErrorCode;
import com.plume.library.web.dto.ApiError;
import io.javalin.http.Context;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ProblemDetails {
public static final String MEDIA_TYPE = "application/problem+json";
private static final String TYPE_PREFIX = "https://plumphp.com/problems/";
private static final Logger log = LoggerFactory.getLogger(ProblemDetails.class);
private static final ObjectMapper MAPPER = JsonSupport.objectMapper();
private static final String FALLBACK = """
{"type":"https://plumphp.com/problems/internal",
"title":"Internal Server Error",
"status":500,
"detail":"error response could not be serialized",
"code":"E_INTERNAL",
"errors":{}}
""";
private ProblemDetails() {
}
public static void write(Context context, ErrorCode code, String detail,
Map<String, String> errors) {
ApiError body = new ApiError(
type(code),
title(code.httpStatus()),
code.httpStatus(),
detail,
code.code(),
RequestContext.requestId(context),
errors == null ? Map.of() : errors);
try {
context.status(code.httpStatus())
.contentType(MEDIA_TYPE)
.result(MAPPER.writeValueAsString(body));
} catch (JsonProcessingException failure) {
log.error("could not serialize problem details requestId={}",
RequestContext.requestId(context), failure);
context.status(500).contentType(MEDIA_TYPE).result(FALLBACK);
}
}
}
三个设计点:
errors为null时替换成空 Map,避免响应里出现"errors":null。客户端可以始终按对象处理。status取code.httpStatus(),HTTP 状态行也取同一个值。两处同源,不会出现 body 写 409、响应行写 400 的情况。- 序列化失败时退化成固定的
FALLBACK,不再抛异常。FALLBACK是手写的 JSON 字符串,不含请求 ID;请求 ID 仍然在响应头里,排查不受影响。
MAPPER 是静态共享的 ObjectMapper。ObjectMapper 在配置完成后是线程安全的,可以复用。每次 new ObjectMapper() 会重复注册模块,增加开销,也没有必要。
核心机制四:媒体类型与序列化顺序
这是本节最容易踩的坑。Javalin 的 Context.json(Object) 内部会无条件把 Content-Type 设成 application/json:
// Javalin 7.2.4 的 Context.json 行为,等价于:
context.contentType(ContentType.APPLICATION_JSON);
context.result(jsonMapper.toJsonString(object, type));
所以下面两种写法得到的结果不同:
// 写法 A:先设置 problem+json,再 json()
ctx.status(409).contentType("application/problem+json").json(body);
// 写法 B:先 json(),再覆盖 Content-Type
ctx.status(409).json(body);
ctx.contentType("application/problem+json");
本机用三个最小路由验证,输出是:
/before -> ct=application/json body={"type":"x","status":400}
/after -> ct=application/problem+json;charset=utf-8 body={"type":"x","status":400}
/result -> ct=application/problem+json body={"type":"x"}
/before 对应写法 A:contentType 先设置,随后被 json() 覆盖成 application/json。/after 对应写法 B:json() 先写,随后覆盖成 problem+json,Javalin 还补了 charset=utf-8。/result 用 contentType + result(String),完全绕开 json(),得到不带 charset 的 application/problem+json。
ProblemDetails.write 选择 /result 路线:自己用 MAPPER.writeValueAsString(body) 序列化,再 result(String)。这样做有两个好处:内容类型不会被覆盖;序列化异常可以在同一个方法里捕获,退化成 FALLBACK。
代价是要自己处理序列化。ApiError 只包含字符串、整数和 Map<String,String>,writeValueAsString 实际上不会失败;但代码仍然保留 catch,因为「不会失败」是当前字段类型的性质,不是契约。将来给 ApiError 加一个复杂对象,异常路径就会用上。
客户端如何消费
错误契约的价值在客户端侧。推荐的消费顺序是:
- 先看 HTTP 状态码,决定大类:4xx 是请求问题,5xx 是服务端问题。
- 再看
type或code,决定具体分支。type适合做文档链接和跨服务统一处理,code适合日志和紧凑判断。 - 如果是校验错误,读
errors,把消息映射到表单字段。 - 把
requestId展示在错误提示或日志里,用户报障时直接提供。 - 不要解析
detail做逻辑判断,它只是说明文本。
重试策略也按状态码和类型分:
| 情况 | 建议 |
|---|---|
400 VALIDATION_FAILED | 不重试,修正请求 |
404 BOOK_NOT_FOUND | 不重试,资源不存在 |
409 BOOK_ALREADY_BORROWED | 不盲目重试,先查当前状态 |
503 DB_UNAVAILABLE | 可以有限次退避重试 |
500 INTERNAL | 可以重试,但要带幂等键或确认操作幂等 |
归还接口本身是幂等的:重复归还同一本已归还的书会返回同样的回执。借阅不是幂等的:同一个请求重试两次,第二次会得到 BOOK_ALREADY_BORROWED。客户端不能因为「网络超时」就无条件重试借阅。
常见错误与边界
错误一:先 contentType 再 json。 这是本节实测的坑,最终响应是 application/json,不是 application/problem+json。要么先 json 再覆盖,要么用 result(String)。
错误二:HTTP 状态和 body 里的 status 不一致。 有的代码在异常处理器里写 context.status(400),但 ApiError 用错误码里的 409。客户端以哪个为准都会出问题。两处都从 ErrorCode.httpStatus() 取。
错误三:title 随请求变化。 把 title 写成 "ISBN 9787111213826 already borrowed",客户端就无法按类型聚合。具体信息放 detail,title 保持与 type 对应。
错误四:detail 泄露内部信息。 直接把 SQLException.getMessage() 放进 detail,可能暴露表名、列名、约束名甚至 SQL 片段。500 的 detail 必须是固定文本,详细异常只进日志。
错误五:errors 为 null。 没有字段错误时返回 "errors":null,客户端要额外判空。统一返回 {},结构稳定。
错误六:只给业务异常做 Problem Details。 未注册的路由、405、请求体过大、JSON 解析失败,可能仍然返回 Javalin 的默认 JSON。本机对一个未注册路径的请求输出是:
status=404 ct=application/json
{"title":"Endpoint GET /no-such-route not found","status":404,
"type":"https://javalin.io/documentation#endpointnotfound","details":{}}
这个形状和 ApiError 不同,details 还是复数。要让所有错误统一,需要注册 config.routes.error(404, ...) 和 config.routes.error(405, ...),并把 config.http.prefer405over404 打开。本机验证过:打开后,DELETE /api/books 返回 405,Allow: GET,响应体可以由自己的处理器生成。
边界:instance 和 requestId 的关系。 instance 的语义是「本次问题实例的 URI」,requestId 是追踪 ID。两者可以同时存在,也可以只保留一个。libraryd 只保留 requestId,因为它同时出现在响应头、日志和错误体里,关联成本最低。如果将来要支持「错误详情页」,可以再加 instance,指向 /problems/instances/{requestId}。
边界:内容协商。 如果客户端发送 Accept: application/json,严格的服务器可能返回 406。libraryd 不做内容协商,错误响应始终是 application/problem+json;这个媒体类型是 application/json 的子集语义,主流客户端都能解析。若要对老客户端兼容,可以在 Accept 不含 problem+json 时退回 application/json,但那样就失去了契约的稳定性。
小结
- RFC 9457 用
type、title、status、detail、instance描述错误,媒体类型是application/problem+json。 type从ErrorCode推导:去掉E_、转小写、下划线换连字符、拼到固定前缀后面。title与状态码绑定,detail描述本次请求;具体信息放detail,类型信息放type。- 扩展成员
code、requestId、errors分别服务机器判断、日志关联和表单映射。 - Javalin 的
json()会覆盖之前设置的Content-Type;要用result(String)或先json再覆盖。 - 未注册路由和 405 不走业务异常处理器,需要单独注册
error(404)、error(405)才能保持同一契约。
错误响应的形状定下来后,还剩最后一个问题:领域层、持久层、web 层各自抛什么异常,谁负责把它们翻译成哪个错误码。下一节把异常映射的整条链路串起来。
阅读导航:上一节:8.1 参数校验:Jakarta Validation 与手写规则 · 下一节:8.3 异常映射:从领域错误到 HTTP 响应 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。