成为全栈·Node 后端篇·错误处理:异常分层与全局捕获

成为全栈·Node 后端篇·错误处理:异常分层与全局捕获

假设你的后端没有任何错误处理。会发生什么?一个没料到的 null 访问、一次数据库连接抖动、一段拼写错的字段名------任何一个未捕获的异常,都会让当前请求直接崩成框架默认的错误页,前端拿到一个既不是 JSON、也不带 code 的奇怪响应,通用拦截器当场傻眼,用户看到的是"白屏"或"网络错误"。更隐蔽的是,缺少统一错误处理的接口,前端没法用一个拦截器统一弹错,只能每个请求各自 catch、各自 alert,代码又臭又长。

错误处理不是"锦上添花",是后端的"安全气囊"。这一篇我们分层看待错误,并让它们最终都安静地落进同一个信封里

一、错误要分三类

不是所有错误都一个待遇。我们至少分三层:

  1. 业务异常(可预期) :比如"文章不存在""你没权限""令牌过期"。这些是正常业务流里会发生的,前端要据此给用户明确提示。我们用 AppError 表达。
  2. 校验异常 :入参不合法(邮箱格式错、必填缺失)。严格说它也是一种业务异常,但在我们的设计里由校验层单独抛出,归到 VALIDATION(4001)
  3. 未知异常(程序 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",既清爽又一致。这是"错误处理集中化"比"处处防御"更优的根本原因。

三、错误码体系:编译期就锁死

上一篇文章讲过 ErrCodeas 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 捞详情。

这也呼应了《工程公约》里"日志要规范、敏感信息要脱敏"的约定 工程公约------错误处理和日志,是同一套"对内透明、对外克制"哲学的两面。

七、小结与前瞻

错误处理,是把"崩溃"变成"提示"的艺术:

  1. 错误分三类:业务异常(AppError)、校验异常、未知异常(bug)。
  2. P-17 :两条路径都收敛到 failResponse 同一信封;errorHandler 是最后闸门,所有未捕获异常在此兜底。
  3. 错误码体系ErrCode as const 单一事实源,用名字不用裸数字,拼写错编译即红。
  4. P-18门禁全绿 ≠ 正确 ------4001 写成 422ACCOUNT_DISABLED 写成 403,门禁查不出语义错误;要靠"对照契约写测试 + 一致性比对脚本"兜底。
  5. P-19 :并发唯一冲突禁"先查后插",直接插、catch 里用 isUniqueConstraintError 收口转 409
  6. 生产铁律:未知异常堆栈只进日志、不进响应。

下一篇({{LINK:M1-10}})我们聊参数校验:为什么校验必须放在"最外层"(信任边界),以及 Zod 怎么把"所有外部输入不可信"这条安全意识落进代码。那是前端转后端最该建立的第一个安全直觉。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
百万运营Pro8 小时前
用 Astro + Supabase 从零构建全网盘聚合搜索引擎:PGroonga 中文检索实战
搜索引擎·前端框架·node.js·个人开发·学习方法·ai编程·资源分享
小婉1 天前
我用 Next.js + React Flow 从零搭建了一个可视化 AI 工作流编排平台
前端·人工智能·node.js
抓不住时间的沙1 天前
N1搭建守护环境以及重装 Armbian 后完整恢复整套守护环境,清理日志步骤
node.js
meilindehuzi_a1 天前
从域名到数据库:React + Node.js 项目部署全流程与用户访问链路
数据库·react.js·node.js
李游Leo1 天前
Node.js 开发环境安装与 npm/pnpm 国内镜像配置(Windows / macOS / Linux)
npm·node.js·pnpm·前端开发·开发环境
阿黎梨梨2 天前
AI也有记忆?LangChain Memory 管理指南
langchain·node.js·llm
小小龙学IT2 天前
libuv 开源异步 I/O 事件循环库深度解析:Node.js 的心脏,C++ 高性能网络程序的引擎
c++·开源·node.js
用户672465366053 天前
Node 守护进程日志转发踩坑记:stdio 管道、UTF-8 截断,和一个字符串按值传参的故事
node.js
coderCN3 天前
Nodejs express+knex(ORM框架)
前端·node.js