本节目标:让每一个异常在正确的层被翻译成正确的错误码,最后由 web 层统一写成 HTTP 响应。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、PostgreSQL JDBC 42.7.14。
承接的问题
到 8.2 节为止,错误响应的形状已经统一了。但「谁来产生这个响应」还没有完全说清。libraryd 里至少有三类异常:
- 持久层:
SQLException,SQLState 可能是23505、23503、08006、57P01。 - 领域层:
LibraryException,携带ErrorCode,例如BOOK_NOT_FOUND、RENEWAL_NOT_ALLOWED。 - web 层:
ValidationFailure,携带字段错误 Map。
如果让每个路由方法自己 catch,会出现两个问题。第一,12 个 handler 要写 12 套 catch,漏一个就变成 500。第二,同一个错误在不同接口里可能被映射成不同状态码:借阅接口把「书已借出」返回 409,删除接口返回 400,客户端无法写统一策略。
这一节的原则是:每一层只翻译自己理解的异常,HTTP 状态只在错误码上定义一次。
核心机制一:错误码携带 HTTP 状态
ErrorCode 是领域模块里的枚举,每个值带三个属性:
package com.plume.library.domain;
public enum ErrorCode {
BOOK_NOT_FOUND("E_BOOK_NOT_FOUND", 2, 404),
MEMBER_NOT_FOUND("E_MEMBER_NOT_FOUND", 2, 404),
BOOK_ALREADY_BORROWED("E_BOOK_ALREADY_BORROWED", 3, 409),
DUPLICATE_ISBN("E_DUPLICATE_ISBN", 3, 400),
LOAN_NOT_FOUND("E_LOAN_NOT_FOUND", 2, 404),
LOAN_LIMIT_EXCEEDED("E_LOAN_LIMIT_EXCEEDED", 3, 409),
RENEWAL_NOT_ALLOWED("E_RENEWAL_NOT_ALLOWED", 3, 409),
INVALID_COMMAND("E_INVALID_COMMAND", 2, 400),
VALIDATION_FAILED("E_VALIDATION_FAILED", 2, 400),
DB_UNAVAILABLE("E_DB_UNAVAILABLE", 1, 503),
INTERNAL("E_INTERNAL", 1, 500);
private final String code;
private final int severity;
private final int httpStatus;
ErrorCode(String code, int severity, int httpStatus) {
this.code = code;
this.severity = severity;
this.httpStatus = httpStatus;
}
public String code() {
return code;
}
public int severity() {
return severity;
}
public int httpStatus() {
return httpStatus;
}
}
三个属性各有用途:
code是 wire 值,进日志、进 Problem Details 的code字段,也用来推导typeURI。severity是运维分级:1 表示基础设施故障,2 表示请求或资源问题,3 表示业务冲突。它不直接等于日志级别,但异常处理器用它决定要不要打 WARN。httpStatus是 HTTP 映射。路由和处理器都从这里取,不再写switch (errorCode)。
把 httpStatus 放在领域枚举里,是一个有争议的选择。纯化论者会说领域层不应该知道 HTTP。替代方案是维护一张 web 层的 Map<ErrorCode, HttpStatus>。libraryd 选择放在枚举里,理由是:错误码本身就是跨层的稳定契约,状态码是它的一部分;放在一处比放在两处更难漂移。代价是领域模块多了一个 HTTP 概念,但这个项目没有非 HTTP 的消费者,收益大于成本。
如果将来领域模块要被 CLI 或消息消费者复用,可以把 httpStatus 拆到一个 web 层的映射表,领域枚举只保留 code 和 severity。这不影响 wire 契约,因为 code 和 type 不变。
核心机制二:异常处理器按具体类型匹配
Javalin 的 config.routes.exception(Class, Handler) 按异常类型匹配,选择最具体的那一个,和注册顺序无关。本机用两个相反顺序验证:
先注册 RuntimeException,再注册 IllegalArgumentException,最后 Exception
抛 IllegalArgumentException -> status=400 body=illegal-argument
先注册 Exception,再注册 IllegalArgumentException,最后 RuntimeException
抛 IllegalArgumentException -> status=400 body=illegal-argument
两次都命中 IllegalArgumentException 的处理器。Javalin 找的是类型距离最近的处理器,不是「第一个能接住的」。因此注册顺序可以按可读性排列,先具体、后兜底:
config.routes.exception(LibraryException.class, this::handleLibraryException);
config.routes.exception(ValidationFailure.class, this::handleValidationFailure);
config.routes.exception(Exception.class, this::handleUnexpected);
Exception.class 的兜底必须有。没有它,未捕获异常会走 Javalin 的默认错误页。处理器本身也不能再抛异常:本机让 IllegalStateException 的处理器抛 RuntimeException,外层虽然注册了 Exception.class,响应仍然是:
status=500 ct=text/plain body=Server Error
外层的 Exception 处理器没有接住处理器内部抛出的异常。Javalin 已经把请求交给「处理这个异常的处理器」,处理器再失败就退到框架的默认 500。这也是为什么 ProblemDetails.write 要自己捕获序列化失败,而不是把异常继续往外扔。
核心机制三:三层翻译的分工
异常映射不是一层的事。每一层把自己能理解的异常翻译成上一层能理解的类型:
SQLException
│ JdbcErrors.translate:看 SQLState 和约束名
▼
LibraryException(ErrorCode)
│ handleLibraryException:看 severity 和 errorCode
▼
ApiError + HTTP status
持久层:SQLState 到错误码
持久层最了解数据库,也最了解约束名。JdbcErrors.translate 把 SQLException 翻译成领域异常:
package com.plume.library.persistence;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import java.sql.SQLException;
final class JdbcErrors {
private JdbcErrors() {
}
static LibraryException translate(SQLException exception) {
String state = exception.getSQLState();
String message = exception.getMessage() == null ? "" : exception.getMessage();
if ("23505".equals(state)) {
if (message.contains("ux_loans_active_isbn")) {
return new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
"book already has an active loan", exception);
}
return new LibraryException(ErrorCode.DUPLICATE_ISBN,
"isbn already exists", exception);
}
if ("23503".equals(state)) {
return new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
"row is still referenced by loans", exception);
}
if (state != null && (state.startsWith("08") || state.startsWith("57P"))) {
return new LibraryException(ErrorCode.DB_UNAVAILABLE,
"database is unavailable", exception);
}
return new LibraryException(ErrorCode.INTERNAL,
"database operation failed", exception);
}
}
本机用构造的 SQLException 跑这套规则,输出是:
23505 (ux_loans_active_isbn) -> E_BOOK_ALREADY_BORROWED 409
23505 (books_pkey) -> E_DUPLICATE_ISBN 400
23503 -> E_BOOK_ALREADY_BORROWED 409
08006 -> E_DB_UNAVAILABLE 503
57P01 -> E_DB_UNAVAILABLE 503
22P02 -> E_INTERNAL 500
几个判断的依据:
23505是唯一约束冲突。同样是23505,撞到ux_loans_active_isbn说明这本书已有活动借阅,映射成 409;撞到主键则是 ISBN 重复,映射成 400。约束名是持久层的知识,所以判断放在这里。23503是外键冲突。删除一本书时,如果loans还引用它,数据库会拒绝。语义上是「资源仍被引用」,映射成 409 比 500 更准确。08开头是连接异常,57P是管理员终止连接(例如数据库重启、连接被 kill)。这两类都应该返回 503,让客户端知道可以重试。- 其他 SQLState 映射成
INTERNAL500。不要把原始SQLException.getMessage()放进detail,它可能包含表名和 SQL 片段。
领域层:明确抛出业务错误
领域层不做 SQLState 判断,它只抛语义明确的错误:
if (loans.findActiveByIsbn(normalizedIsbn).isPresent()) {
throw new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
"book is already borrowed: " + normalizedIsbn);
}
if (loans.countActiveByMember(normalizedMemberId) >= MAX_ACTIVE_LOANS) {
throw new LibraryException(ErrorCode.LOAN_LIMIT_EXCEEDED,
"member " + normalizedMemberId + " reached the active loan limit");
}
LibraryException 只是一个带错误码的运行时异常:
public class LibraryException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final ErrorCode errorCode;
public LibraryException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public LibraryException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode errorCode() {
return errorCode;
}
}
它不携带 HTTP 状态,也不携带响应体。领域层不知道 Problem Details 的存在。
web 层:错误码到响应
web 层只做最后一跳:把 LibraryException 的 ErrorCode 和 message 交给 ProblemDetails.write。status、type、title 都从错误码推导,路由和处理器里没有第二个 switch。
在 libraryd 里落地:三个处理器
完整的处理器实现如下。它和 7.2 节的结构一致,这里重点看日志分级:
private void handleLibraryException(LibraryException exception, Context context) {
if (exception.errorCode().severity() >= 3) {
log.warn("request rejected requestId={} code={} message={}",
RequestContext.requestId(context),
exception.errorCode().code(),
exception.getMessage());
} else if (exception.errorCode() == ErrorCode.DB_UNAVAILABLE
|| exception.errorCode() == ErrorCode.INTERNAL) {
log.error("request failed requestId={} code={}",
RequestContext.requestId(context),
exception.errorCode().code(),
exception);
}
ProblemDetails.write(context, exception.errorCode(), exception.getMessage(), Map.of());
}
private void handleValidationFailure(ValidationFailure exception, Context context) {
log.warn("validation failed requestId={} errors={}",
RequestContext.requestId(context), exception.errors());
ProblemDetails.write(context, ErrorCode.VALIDATION_FAILED,
"request validation failed", exception.errors());
}
private void handleUnexpected(Exception exception, Context context) {
log.error("unhandled request failure requestId={}",
RequestContext.requestId(context), exception);
ProblemDetails.write(context, ErrorCode.INTERNAL, "internal server error", Map.of());
}
分级规则可以写成一张表:
| 条件 | 日志级别 | 是否带堆栈 | 例子 |
|---|---|---|---|
severity >= 3 | WARN | 否 | BOOK_ALREADY_BORROWED、RENEWAL_NOT_ALLOWED |
DB_UNAVAILABLE | ERROR | 是 | 连接被数据库终止 |
INTERNAL | ERROR | 是 | 持久层未知异常 |
ValidationFailure | WARN | 否 | 字段为空、超长 |
未捕获 Exception | ERROR | 是 | 空指针、序列化失败 |
severity 为 2 的 BOOK_NOT_FOUND、LOAN_NOT_FOUND、INVALID_COMMAND 在这个处理器里不打日志。这不是遗漏:它们是正常的 4xx,访问日志已经记录了 method、path、status 和 requestId;再打一条业务日志只会增加噪音。如果合规要求审计「谁查了不存在的书」,应该单独加 INFO 日志,而不是把它们升级成 WARN。
本机实际产生的两条 WARN 是:
WARN com.plume.library.web.LibraryApi - validation failed requestId=... errors={title=不能为空}
WARN com.plume.library.web.LibraryApi - request rejected requestId=... code=E_BOOK_ALREADY_BORROWED message=book is already borrowed: isbn-1
两条都带 requestId,可以和访问日志、客户端报障对上。
核心机制四:404、405 与框架默认响应
有一类错误不经过业务异常处理器:请求根本没有匹配到路由。7.1 节提到过,Javalin 会返回自己的 404 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.http.prefer405over404 = true;
config.routes.error(404, context ->
ProblemDetails.write(context, ErrorCode.BOOK_NOT_FOUND,
"endpoint not found: " + context.path(), Map.of()));
config.routes.error(405, context ->
ProblemDetails.write(context, ErrorCode.INVALID_COMMAND,
"method not allowed: " + context.method(), Map.of()));
本机验证的结果:
GET /nope -> 404 ct=application/problem+json body={"type":"not-found"}
DELETE /api/books -> 405 ct=application/problem+json allow=GET body={"type":"method-not-allowed"}
两个细节:
prefer405over404默认是false。不开它,DELETE /api/books会返回 404,因为路径存在但没有 DELETE 路由;开了之后才返回 405,并带Allow: GET。- 404 的
ErrorCode用BOOK_NOT_FOUND并不完全准确:路径不存在不等于书不存在。更干净的做法是给ErrorCode加一个ENDPOINT_NOT_FOUND,映射成 404。这里为了不扩展错误码表,先用现有码;如果要严格,应该新增。
405 用 INVALID_COMMAND(400)也不准确,405 应该是独立状态。生产项目里建议新增 METHOD_NOT_ALLOWED,httpStatus=405。错误码表是公开契约,新增比复用更安全。
常见错误与边界
错误一:在每个路由里 catch (Exception)。 12 个 handler 就有 12 套映射,漏掉的异常变成默认 500,而且每个接口的状态码可能不同。统一交给 config.routes.exception。
错误二:把所有 23505 都映射成 DUPLICATE_ISBN。 借阅表的 ux_loans_active_isbn 也是 23505,映射错会让「书已借出」返回 400,客户端以为请求格式错了。要看约束名。
错误三:把 23503 映射成 INTERNAL。 外键冲突是资源状态问题,不是服务端 bug。删除有借阅历史的书返回 409 比 500 更准确,也不会触发无意义的告警。
错误四:500 的 detail 用异常消息。 SQLException.getMessage() 可能包含表名、列名、SQL 片段。500 的 detail 固定为 internal server error,堆栈只进日志。
错误五:异常处理器里再抛异常。 本机验证过,外层 Exception 处理器接不住处理器内部抛出的异常,最终是 text/plain 的 Server Error。处理器要么自己捕获,要么保证不失败。
错误六:依赖注册顺序。 Javalin 按类型距离选处理器,不按注册顺序。但不要因此把 Exception.class 注册在最前面就以为能兜住具体异常;具体类型仍然优先。
错误七:忘记注册 Exception.class。 没有兜底时,未捕获异常走框架默认响应,格式和契约不一致。兜底处理器是最后一道网,不能省。
边界:ErrorOverflow 与 StackOverflowError。 config.routes.exception(Exception.class, ...) 只接 Exception,不接 Error。OutOfMemoryError、StackOverflowError 不会进入这个处理器,JVM 可能已经无法安全地写响应。这类错误应该靠进程重启和健康检查处理,不要试图在应用层恢复。
边界:异步任务的异常。 CompletableFuture 或线程池里的异常不会经过 Javalin 的异常处理器,因为它们不在请求线程上。异步边界要么把异常包装成结果,要么在任务内部记录并转换成领域错误;第 10 章会展开。
边界:错误码的兼容性。 code 和 type 一旦发布就当成公开 API。重命名枚举常量会改变 wire 值,客户端的分支会失效。要改语义就新增错误码,不要改旧的。
小结
- 每一层只翻译自己理解的异常:持久层看 SQLState,领域层抛
LibraryException,web 层写 Problem Details。 ErrorCode同时携带 wire 值、severity 和 HTTP 状态,状态码只定义一次。- Javalin 按最具体的异常类型匹配处理器,和注册顺序无关;
Exception.class兜底必须存在。 - 异常处理器本身不能再抛异常,否则会退化成框架默认的
text/plain500。 23505要结合约束名区分「ISBN 重复」和「书已借出」,23503是资源仍被引用,08/57P是数据库不可用。- 404 和 405 不走业务异常处理器,需要
config.routes.error(...)才能保持同一错误契约。
到这里,HTTP 层的入口、中间件、停机和错误契约都齐了。下一章换一个方向:请求线程本身是有限资源,阻塞查询不能无限占用 Jetty 线程。9.1 节从 ExecutorService 的参数和队列开始,把工作线程池的边界画清楚。
阅读导航:上一节:8.2 错误契约:RFC 9457 Problem Details · 下一节:9.1 ExecutorService:线程池参数与队列 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。