《Java 编程实战》8.2 错误契约:RFC 9457 Problem Details

同样是 400,有的接口返回字符串,有的返回对象,客户端只能按状态码猜。本节按 RFC 9457 把 libraryd 的错误响应固定成 application/problem+json,讲清 type、title、status、detail 与扩展成员 code、requestId、errors,并用真实响应说明媒体类型被覆盖的坑。

本节目标:让 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 定义了五个标准成员:

成员类型含义
typeURI reference问题类型的稳定标识,默认 about:blank
titlestring问题类型的简短、人类可读标题
statusinteger对应的 HTTP 状态码
detailstring本次问题的具体说明
instanceURI 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 的规则是:

  1. 去掉 E_ 前缀。
  2. 转成小写。
  3. 下划线替换成连字符。
  4. 拼到 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 加一个复杂对象,异常路径就会用上。

客户端如何消费

错误契约的价值在客户端侧。推荐的消费顺序是:

  1. 先看 HTTP 状态码,决定大类:4xx 是请求问题,5xx 是服务端问题。
  2. 再看 type 或 code,决定具体分支。type 适合做文档链接和跨服务统一处理,code 适合日志和紧凑判断。
  3. 如果是校验错误,读 errors,把消息映射到表单字段。
  4. 把 requestId 展示在错误提示或日志里,用户报障时直接提供。
  5. 不要解析 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 响应 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发