要点
业务错误、外部服务错误和程序缺陷需要不同响应。统一入口映射错误有利于一致,但不能把内部堆栈直接返回客户端。
两类错误,两种策略
| 类型 | 例子 | 策略 |
|---|---|---|
| 操作性错误(预期内) | 数据库连不上、文件不存在、请求超时、输入非法 | 记日志、降级、返回 4xx/5xx,服务继续运行 |
| 程序员错误(Bug) | 未定义变量、类型用错、逻辑漏判 | 修复代码;进程不可信,应崩溃重启 |
这个区分是 Node 错误处理的基石:不要试图捕获 Bug 后继续运行,状态可能已损坏。
错误传播:三种语境
同步代码:throw + try/catch
function parseConfig(raw: string) {
const cfg = JSON.parse(raw); // 可能同步抛出
return cfg;
}
try {
parseConfig(badJson);
} catch (err) {
console.error("配置解析失败", err);
}
Promise/async-await:自动传播
async 函数内 throw 会变成 Promise rejection,沿 await 链向上传播——但必须有人接住:
async function bootstrap() {
await connectDb(); // 失败 → rejection 传给调用方
}
// ✅ 在调用处捕获
bootstrap().catch((err) => {
console.error("启动失败", err);
process.exit(1);
});
Hono 的进步:路由处理器抛出的错误(含 async rejection)自动转发给 app.onError,无需手写 try/catch,也不需要老框架时代的 async 包装补丁。
可直接运行的错误因果链示例(Node.js)
这段是上面包装规则的无框架最小版。它只验证 Error 的 cause 会保留原始错误对象;日志工具是否展开 cause 链仍取决于日志工具的序列化配置。
class ServiceError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.name = "ServiceError";
}
}
const root = new Error("database unavailable");
const wrapped = new ServiceError("query failed", { cause: root });
console.log(`${wrapped.name}|${wrapped.message}|${wrapped.cause.message}`);
回调式 API:error-first 约定
遗留回调式 API 遵循 (err, result) 首参错误约定——不处理也不传出的回调错误会变成 uncaughtException。现代代码应改用 node:fs/promises 等 Promise 版本。
错误包装保留因果链
class ServiceError extends Error {
code?: string;
constructor(message: string, options?: { code?: string; cause?: unknown }) {
super(message, { cause: options?.cause }); // Error cause 标准属性
this.name = "ServiceError";
this.code = options?.code;
}
}
try {
await db.query(sql);
} catch (err) {
throw new ServiceError("查询任务失败", { code: "DB_ERROR", cause: err });
// cause 保留原始堆栈,排查时不丢失根因
}
code 用于程序分支或错误响应分类,message 用于人类阅读,cause 用于诊断原始失败;三者不要互相替代。对外响应只暴露经过设计的 code 和安全消息,不能把数据库错误字符串直接透传。
Hono 集中式错误处理
注册错误出口:app.onError
与”四参错误中间件”的老模式不同,Hono 的错误出口是一个显式注册点,且注册位置无关(写在前在后都拦截全站):
import { Hono } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
interface AppError extends Error {
status?: number;
code?: string;
}
const app = new Hono();
app.onError((err, c) => {
// AppError 是 interface(编译期被擦除),不能用于 instanceof;按结构断言取 status。
// c.json 的第二参要求 ContentfulStatusCode,动态计算出的 number 需显式收窄。
const status = ((err as AppError).status ?? 500) as ContentfulStatusCode;
// 500 及以上必须记录;4xx 属于客户端问题,按需记录
if (status >= 500) {
console.error(`[500] ${c.req.method} ${c.req.path}`, err);
}
return c.json(
{
error: {
code: (err as AppError).code ?? "INTERNAL_ERROR",
message:
status >= 500 && process.env.NODE_ENV === "production"
? "服务器内部错误" // 生产环境不泄露内部细节
: err.message,
},
},
status,
);
});
// 未匹配路由的 404 不是错误:单独注册 notFound 出口
app.notFound((c) => c.json({ error: { code: "NOT_FOUND" } }, 404));
业务错误统一抛出
class HttpError extends Error {
status: number;
code: string;
constructor(status: number, message: string, code: string) {
super(message);
this.status = status;
this.code = code;
}
}
// 路由中只管抛
app.get("/users/:id", async (c) => {
const user = await db.findUser(c.req.param("id"));
if (!user) {
throw new HttpError(404, "用户不存在", "USER_NOT_FOUND");
}
return c.json(user);
});
// Hono 自动把 throw 的错误送进 onError
进程级兜底
任何逃逸的错误最终到达进程层。默认策略:记日志、清理、退出,交给进程管理器重启。
// src/fatal.ts —— 在应用入口最先 import
import process from "node:process";
process.on("uncaughtException", (err) => {
console.error("未捕获异常,进程即将退出", err);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error("未处理的 Promise 拒绝", reason);
process.exit(1);
});
process.on("SIGTERM", () => console.log("收到 SIGTERM")); // 优雅退出见 02-first-server
要点:
uncaughtException后 Node 文档明确不建议继续运行——正确姿势是退出并让 systemd/K8s 重启unhandledRejection的默认处理曾随 Node 版本变化,也可由--unhandled-rejections改写;不要把某个默认模式当作恢复策略。入口应记录失败并以受监督的方式退出。- 兜底处理器里只做同步日志,不要再启动可能抛错的异步操作
最佳实践
在应用边界统一错误响应形状,业务内部保留可区分的原因。能恢复的操作在局部处理,未知错误交给边界兜底;“统一出口”不意味着所有代码只能有一个 catch。
cause 链帮助保留底层原因,日志经过脱敏后记录上下文,客户端只得到安全消息。对无法保证继续运行正确的进程状态制定退出与监督重启策略,但不能把普通请求异常都当作必须崩溃。测试错误发生后是否返回预期状态且没有留下部分写入。
常见问题
Q1: 我的 async 路由抛错,客户端拿到的是什么?
A: Hono 默认返回 500 文本。注册 app.onError 后由你接管格式;也可能是你在处理器里 try/catch 吞掉了错误后没返回响应。
Q2: 错误日志里堆栈很乱看不到根因?
A: 用 err.cause 层层包装后,打印顶层错误不会带出 cause 堆栈。日志库(如 pino)序列化时记得开启 err 序列化器输出 cause 链。
练习与实践
练习一:给路由加上完整错误流
任务要求: 1. 在 05 课的任务清单 API 上实现 HttpError 与 app.onError 2. 制造三类错误:校验失败(400)、查不到资源(404)、模拟数据库宕机(503) 3. 验证三者都从同一出口输出统一格式
练习二:可观察的崩溃
挑战任务: – 写一个每 100 次请求抛一次 uncaughtException 的”故障服务” – 观察:进程退出后,验证由进程管理器(systemd 或 docker restart: always)自动拉起
提示: 容器场景在 Dockerfile 的 CMD 直接跑 node,重启交给编排层。