成为全栈·Node 后端篇·错误处理:异常分层与全局捕获
假设你的后端没有任何错误处理。会发生什么?一个没料到的 null 访问、一次数据库连接抖动、一段拼写错的字段名------任何一个未捕获的异常,都会让当前请求直接崩成框架默认的错误页,前端拿到一个既不是 JSON、也不带 code 的奇怪响应,通用拦截器当场傻眼,用户看到的是"白屏"或"网络错误"。更隐蔽的是,缺少统一错误处理的接口,前端没法用一个拦截器统一弹错,只能每个请求各自 catch、各自 alert,代码又臭又长。
错误处理不是"锦上添花",是后端的"安全气囊"。这一篇我们分层看待错误,并让它们最终都安静地落进同一个信封里。

一、错误要分三类
不是所有错误都一个待遇。我们至少分三层:
- 业务异常(可预期) :比如"文章不存在""你没权限""令牌过期"。这些是正常业务流里会发生的,前端要据此给用户明确提示。我们用
AppError表达。 - 校验异常 :入参不合法(邮箱格式错、必填缺失)。严格说它也是一种业务异常,但在我们的设计里由校验层单独抛出,归到
VALIDATION(4001)。 - 未知异常(程序 bug) :没被任何逻辑接住的崩溃,比如
undefined.x、数据库驱动突然报错。这是程序自身的缺陷,绝不该把细节暴露给用户。
这三类里,前两类是"已知的已知",第三类是"未知的未知"。好的错误处理体系,让前两类变成清晰的业务提示,让第三类既不吓到用户、也不掩盖问题。
二、P-17:两条路径,收敛到同一个信封
无论错误从哪来,最终都必须长成上一篇文章讲的那个统一信封------否则前端又得为"错误长啥样"写分支。这就是 P-17 的纪律:业务异常路径和校验异常路径,都收敛到同一个 failResponse 构造器。
看我们的顶层错误中间件 src/middleware/error.ts:
ts
export const errorHandler: ErrorHandler = (err, _c) => {
if (err instanceof AppError) {
return failResponse(err.code, err.httpStatus, err.details);
}
// 兜底:未知异常不应向客户端泄露堆栈
console.error('[unhandled]', err);
return failResponse(ErrCode.INTERNAL, 500);
};
逻辑极其清爽:
- 如果抛的是
AppError(业务/校验异常都继承自它),就直接用它的code/httpStatus/details包成信封返回。注意details能携带字段级错误(比如"哪个字段格式不对"),前端可以精确高亮输入框。 - 如果是其他任何未捕获异常,一律兜底成
5000(INTERNAL)内部错误,但只返回统一的错误信封,不返回任何内部细节。
挂载点是 Hono 的 app.onError(errorHandler)(在 app.ts 里),它是"统一包络"的最后一道闸门------任何 handler 里漏掉的异常,都会在这里被兜住,绝不会以裸错误页的形式逃到前端。
这也解释了为什么我们不在每个 handler 里手写 try/catch 去拼错误响应 ------那样错误格式注定会漂移(A 接口返回 {error:'...'},B 接口返回 {msg:'...'}),而且业务代码会被错误处理的噪音淹没。把兜底收口到 errorHandler 这一处,handler 里只管"正常路径 + 该抛时抛 AppError",既清爽又一致。这是"错误处理集中化"比"处处防御"更优的根本原因。
三、错误码体系:编译期就锁死
上一篇文章讲过 ErrCode 用 as const 定义,配合 HttpForCode / ErrorMessages 的计算属性键,做到"契约新增错误码、这里漏配就编译报错"。这背后是一种工程态度:错误码是单一事实源,不是谁随手写的数字。
ts
export const ErrCode = {
OK: 0,
USERNAME_OR_PASSWORD_ERROR: 1001,
TOKEN_INVALID: 1002,
FORBIDDEN: 2001,
NOT_FOUND: 3001,
CONFLICT: 3002,
STATE_CONFLICT: 3003,
VALIDATION: 4001,
INTERNAL: 5000,
RATE_LIMITED: 5001,
} as const;
业务代码里永远 throw new AppError(ErrCode.NOT_FOUND),而不是 throw new AppError(3001)。用名字而非裸数字,可读性是一方面,更关键的是类型系统能校验这个名字确实存在 ------拼错 NOT_FUND 编译期就红,不会等上线才发现某个错误码返回了 undefined 文案。

四、P-18:门禁全绿 ≠ 正确(最该记牢的一课)
这是我在整个项目里学到最贵的一课(P-18),必须单独强调:所有自动化门禁(类型检查、lint、契约校验、单测)全绿,不等于你的实现是对的。
举个真实例子。契约规定"参数校验失败返回业务码 4001、HTTP 400"。结果我在某处手滑写成了 422(很多框架默认校验失败是 422)。跑门禁------tsc 0 错、契约结构校验通过、单测断言"返回 400 状态码"也因为我测试里写的是 422 而通过。三道门禁全绿,但实现违反了契约 。直到做跨端一致性比对(把代码实际返回的码和契约逐字段比对)时,才发现 4001 配的居然是 422 而非 400。
另一个变体:ACCOUNT_DISABLED 本该是 401(和"未登录"同族),有人写成了 403。门禁同样发现不了------结构没错、测试跟着错写也对。
结论有两层,都很重要:
第一,门禁查的是"结构对不对",查不出"语义你写错了"。 契约校验能保证"返回里有个 code 字段且是数字",但保证不了"这个业务码对应的 HTTP 码符合契约约定"。
第二,预防这类错误,要靠"对照契约写测试",而不是"对照自己的实现写测试"。 测试断言必须来自契约(4001 → 400),而不是来自你手上的代码(否则测试只是给 bug 盖章)。我们后来补了一套"契约一致性比对"脚本,专门把代码实际行为对拍契约,这才兜住了 P-18 这类"绿了但错"的坑。这条经验我单独写成了一篇增补文章,因为它值得每个全栈工程师刻进骨头里。
五、P-19:并发唯一冲突,绝不能用"先查后插"
最后一个实战坑(P-19),涉及数据库并发。场景:用户名必须唯一,注册时你直觉会写"先查有没有这个用户名,没有再插"------这在并发下是错的。
两个请求同时进来,都查到"用户名不存在",然后都去插,后一个就会撞唯一约束,数据库抛错。更糟的是,如果你用"先查后插",在查和插之间的时间窗口里,另一个请求已经插进去了------你查到的"不存在"早已过时。这就是经典的竞态(race condition)。
正确做法是:直接插,在 catch 里收口唯一约束冲突 。我们 src/shared/db-error.ts 专门把底层驱动的错误"翻译"成领域语义:
ts
const UNIQUE_CONSTRAINT_CODES = ['SQLITE_CONSTRAINT_UNIQUE', 'SQLITE_CONSTRAINT'] as const;
export const isUniqueConstraintError = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const code = (err as { code?: unknown }).code;
return typeof code === 'string' && UNIQUE_CONSTRAINT_CODES.includes(code);
};
业务层(如注册)的写法变成:
ts
try {
await db.insert(users).values({ username, ... }).run();
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new AppError(ErrCode.CONFLICT, 409); // 用户名已存在
}
throw err; // 其他错误照常上抛
}
数据库的唯一索引成了"最后一道防线",冲突由 catch 接住并转成 409 CONFLICT 信封。这既避免了先查后插的竞态,又不用在路由里散落 any 或字符串嗅探 ------底层驱动的差异(better-sqlite3 与 D1 的错误码)被 isUniqueConstraintError 这一层收敛掉了。这个模式在注册登录 {{LINK:M1-13}} 和文章 slug 唯一 {{LINK:M1-15}} 里都会用到。
六、生产环境:堆栈绝不能泄露
回到 errorHandler 的兜底分支------未知异常时,我们 console.error('[unhandled]', err) 把完整错误(含堆栈)打到服务端日志 ,但返回给客户端的只有 failResponse(ErrCode.INTERNAL, 500) 这个干干净净的信封。
为什么这么较真?因为异常堆栈里往往包含敏感信息:你的源码路径、函数名、甚至SQL片段和内部 IP。一旦返回给前端,等于把"怎么攻击你"的地图送给攻击者。所以铁律是:生产环境,错误详情只进日志、不进响应 。前端拿到 5000 内部错误,知道"出事了、稍后再试"就够了;真正排查时,开发去日志系统按 requestId 捞详情。
这也呼应了《工程公约》里"日志要规范、敏感信息要脱敏"的约定 工程公约------错误处理和日志,是同一套"对内透明、对外克制"哲学的两面。
七、小结与前瞻
错误处理,是把"崩溃"变成"提示"的艺术:
- 错误分三类:业务异常(AppError)、校验异常、未知异常(bug)。
- P-17 :两条路径都收敛到
failResponse同一信封;errorHandler是最后闸门,所有未捕获异常在此兜底。 - 错误码体系 :
ErrCode as const单一事实源,用名字不用裸数字,拼写错编译即红。 - P-18 :门禁全绿 ≠ 正确 ------
4001写成422、ACCOUNT_DISABLED写成403,门禁查不出语义错误;要靠"对照契约写测试 + 一致性比对脚本"兜底。 - P-19 :并发唯一冲突禁"先查后插",直接插、
catch里用isUniqueConstraintError收口转409。 - 生产铁律:未知异常堆栈只进日志、不进响应。
下一篇({{LINK:M1-10}})我们聊参数校验:为什么校验必须放在"最外层"(信任边界),以及 Zod 怎么把"所有外部输入不可信"这条安全意识落进代码。那是前端转后端最该建立的第一个安全直觉。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
