Express 错误处理:Error、AppError、错误流转与日志
本教程结合当前 Sakila API 工程,解释下面几个容易混淆的问题:
- 普通
Error和自定义AppError有什么区别; - Service 或 Controller 中
throw出去的错误去了哪里; - Express 5 如何接住异步函数中的错误;
- 哪些错误可以返回给客户端,哪些只能写入服务器日志;
- Web 请求和 CLI 脚本的错误处理为什么不一样。
本文涉及的主要文件:
app.ts
errors/app-error.ts
middlewares/error-handler.ts
routes/auth.ts
controllers/auth.controller.ts
services/auth.service.ts
services/access-token.service.ts
scripts/create-initial-admin.ts
1. 先区分两类错误
错误处理的第一步不是选择错误类,而是判断这个错误属于哪一种业务性质。
1.1 预期内、可以安全公开的错误
例如:
- 用户名或密码错误;
- 账号已禁用;
- 账号已锁定;
- 请求字段缺失;
- 上传文件超过大小限制。
这些情况虽然被称为“错误”,但它们其实是应用已经预料到的正常业务分支。
应用知道失败的具体原因,也能够提前设计稳定、安全的错误码和错误文案,例如:
{
"code": "INVALID_CREDENTIALS",
"message": "用户名或密码错误",
"data": null
}
这类错误可以主动返回给客户端。
1.2 非预期、只应在服务器内部处理的错误
例如:
- MySQL 连接中断;
- Sequelize 执行查询失败;
- JWT Secret 配置不正确;
jwt.sign()内部抛出异常;- 访问了
undefined的属性; - 文件系统或网络调用失败。
这些错误通常属于程序缺陷、运行环境故障或者基础设施故障。它们的原始消息可能包含:
- SQL 语句;
- 数据库表名和字段名;
- 服务器文件路径;
- 第三方库内部实现;
- 请求参数或其他敏感数据。
因此,服务器需要记录真实异常以便排查,但不能把原始异常直接返回给客户端。
客户端只应该收到稳定的通用响应:
{
"code": "INTERNAL_SERVER_ERROR",
"message": "服务器内部错误",
"data": null
}
1.3 错误类不能代替业务判断
不能简单理解为:
Error = 严重错误
AppError = 不严重的错误
真正的判断标准是:
这个错误是否属于应用预期内的业务结果?
它的 code 和 message 是否经过应用设计并确认可以安全公开?
只有两个答案都是“是”,才适合使用当前工程中的 AppError。
2. 普通 Error 是什么
JavaScript 提供了内置的 Error 类:
const error = new Error("管理员 Access Token 签发失败");
一个普通 Error 通常包含以下信息:
| 属性 | 用途 |
|---|---|
name |
错误类型名称,默认是 Error |
message |
对错误的简短描述 |
stack |
错误调用栈,说明错误从哪里产生 |
cause |
可选的底层原始异常 |
2.1 throw 做了什么
当代码执行:
throw new Error("数据库查询失败");
当前函数会立即停止正常执行,并沿着调用栈向上寻找能够处理它的 catch。
function levelThree(): void {
throw new Error("执行失败");
}
function levelTwo(): void {
levelThree();
}
function levelOne(): void {
try {
levelTwo();
} catch (error: unknown) {
console.error(error);
}
}
错误虽然在 levelThree() 中产生,但 levelOne() 的 catch 仍然可以捕获它,因为中间的 levelTwo() 没有处理错误,错误会继续向上传播。
这个过程通常称为错误冒泡或异常传播。
2.2 为什么 catch 参数使用 unknown
推荐写法是:
try {
// 可能失败的操作
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message);
}
}
不建议直接写成:
catch (error: any) {
console.error(error.message);
}
JavaScript 允许抛出任何值:
throw "失败";
throw 123;
throw { reason: "失败" };
因此 TypeScript 不能假设捕获到的一定是 Error。unknown 会强制我们先检查类型,再读取属性。这正体现了 TypeScript 相比 JavaScript 的价值。
业务代码仍然应该坚持抛出 Error 对象,不要抛字符串或数字,因为只有 Error 能稳定提供错误类型和调用栈。
2.3 使用 cause 保留原始异常
当前 services/access-token.service.ts 中有下面的代码:
try {
const access_token = jwt.sign(payload, secret, options);
return {
access_token,
expires_in: expires_in_seconds,
};
} catch (error: unknown) {
throw new Error("管理员 Access Token 签发失败", {
cause: error,
});
}
这里不是简单地把错误“再包一层”,而是在增加业务上下文:
上层错误:管理员 Access Token 签发失败
底层 cause:jsonwebtoken 抛出的真实异常
如果不增加上层信息,日志可能只能看到一条第三方库消息,不容易判断错误发生在哪个业务步骤。
如果增加新错误却不保存 cause,底层异常和调用线索又可能丢失。
cause 解决的是服务器内部排查问题,不表示底层错误可以公开给客户端。
3. 当前工程中的 AppError
当前项目在 errors/app-error.ts 中定义了:
export class AppError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
this.name = "AppError";
}
}
3.1 extends Error
class AppError extends Error
表示 AppError 继承了 JavaScript 内置 Error 的能力,因此它仍然拥有:
message;name;stack;instanceof Error判断能力。
下面两个判断都会成立:
const error = new AppError("ACCOUNT_LOCKED", "账号已被锁定");
error instanceof AppError; // true
error instanceof Error; // true
3.2 super(message)
子类构造函数通过:
super(message);
调用父类 Error 的构造函数,让父类创建正确的 message 和错误调用栈。
3.3 参数属性 public readonly code
下面的写法:
constructor(public readonly code: string, message: string)
是 TypeScript 的参数属性语法,近似等价于:
class AppError extends Error {
public readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
readonly 表示构造完成后,TypeScript 不允许再给 code 赋值:
error.code = "OTHER_CODE"; // TypeScript 报错
这样可以避免错误在传播过程中被意外改变类型。
3.4 为什么设置 name
this.name = "AppError";
默认情况下,继承自 Error 的错误名称可能仍显示为 Error。显式设置后,日志和调试器更容易表明这是应用定义的错误。
错误处理中真正可靠的分类方式仍然是:
error instanceof AppError
而不是比较字符串:
error.name === "AppError"
3.5 AppError 的用途
当前工程实际上把 AppError 定义为一种边界标记:
这是应用主动定义、预期之内,而且
code和message可以安全返回客户端的错误。
例如 services/auth.service.ts 中:
function invalidCredentialsError(): AppError {
return new AppError(
ErrorCodes.INVALID_CREDENTIALS,
"用户名或密码错误",
);
}
调用方可以:
throw invalidCredentialsError();
错误中间件看到 AppError 后,就知道它可以返回:
if (error instanceof AppError) {
res.status(200).json({
code: error.code,
message: error.message,
data: null,
});
return;
}
3.6 Error 与 AppError 对比
| 特征 | 普通 Error |
当前项目的 AppError |
|---|---|---|
继承自 Error |
是 | 是 |
message |
有 | 有 |
stack |
有 | 有 |
业务 code |
默认没有 | 有 |
| 适合预期业务错误 | 不够明确 | 是 |
| 默认允许返回原始 message | 否 | 是,本项目约定 |
| 适合数据库、JWT 等未知异常 | 是 | 否 |
3.7 为什么不能把所有异常都包装成 AppError
下面的代码很危险:
try {
await AdminUser.findOne();
} catch (error: unknown) {
throw new AppError(
ErrorCodes.INTERNAL_SERVER_ERROR,
(error as Error).message,
);
}
因为当前错误中间件会把所有 AppError.message 原样返回客户端。如果数据库错误中包含 SQL 或内部字段,信息就会泄露。
未知异常最安全的默认做法通常是继续抛出:
catch (error: unknown) {
throw error;
}
如果需要增加业务上下文,可以使用普通 Error 和 cause:
catch (error: unknown) {
throw new Error("查询登录管理员失败", { cause: error });
}
4. Express 请求的正常执行链路
当前登录路由定义为:
router.post(
"/login",
loginValidation,
validateRequest,
loginAdminController,
);
它被挂载到:
router.use("/api/auth", authRouter);
因此请求路径是:
POST /api/auth/login
正常请求的执行顺序是:
HTTP 请求
↓
express.json()
↓
routes
↓
loginValidation
↓
validateRequest
↓
loginAdminController
↓
loginAdmin
↓
verifyPassword / signAdminAccessToken
↓
Controller 调用 res.json()
↓
HTTP 响应
每一层的职责不同:
- Router 决定 HTTP method、URL 和中间件顺序;
- Validator 检查 HTTP 输入格式;
- Controller 读取
req、调用 Service、组织res; - Service 执行业务规则;
- Model 和底层服务负责数据库、密码、JWT 等具体能力。
错误可能在任意一层产生,但不需要在每一层重复转换和响应。
5. Express 的错误流转机制
5.1 普通中间件和错误中间件
普通中间件通常有三个参数:
(req, res, next) => {
// ...
}
错误处理中间件必须有四个参数:
(error, req, res, next) => {
// ...
}
即使错误中间件暂时不用 next,也应保留第四个参数。Express 通过函数签名区分普通中间件和错误中间件。
当前项目还通过 TypeScript 类型明确表达了这一点:
export const errorHandler: ErrorRequestHandler = (
error: unknown,
req,
res,
next,
) => {
// ...
};
5.2 同步 throw 如何进入错误中间件
假设路由中同步抛出错误:
router.get("/demo", () => {
throw new Error("同步执行失败");
});
Express 执行路由处理函数时,会捕获同步异常。错误流程变成:
throw Error
↓
Express 捕获错误
↓
跳过后续普通中间件
↓
向后寻找错误处理中间件
↓
errorHandler(error, req, res, next)
这里的 catch 由 Express 框架内部完成,不需要我们在路由外再写一个全局 try/catch。
5.3 Express 5 如何处理 async/await 异常
当前项目使用 Express 5:
"express": "^5.2.1"
登录 Controller 是异步函数:
export async function loginAdminController(
req: Request,
res: Response,
): Promise<void> {
const result = await loginAdmin({
username: req.body.username,
password: req.body.password,
});
res.json({ code: "OK", message: "登录成功", data: result });
}
如果 loginAdmin() 抛出错误,await 不会得到正常结果。因为 Controller 是 async 函数,所以它返回的 Promise 会变成 rejected 状态:
loginAdmin() throw
↓
loginAdminController() 返回 rejected Promise
↓
Express 5 发现 Promise rejection
↓
Express 5 自动把错误交给错误处理链
效果相当于 Express 内部执行了:
next(error);
因此当前项目不需要在每个异步 Controller 中重复写:
try {
// ...
} catch (error: unknown) {
next(error);
}
需要注意,这是 Express 5 的重要能力。旧的 Express 4 项目通常需要异步包装函数或显式调用 next(error),不能把其他项目的写法不加判断地照搬到当前工程。
5.4 next(error) 的意义
普通调用:
next();
表示继续执行下一个普通中间件。
传入错误:
next(error);
表示进入错误处理流程。Express 会跳过后面的普通中间件,继续寻找具有四个参数的错误中间件。
5.5 为什么 errorHandler 必须放在路由后面
当前 app.ts 的注册顺序是:
app.use(express.json({ limit: "100kb" }));
app.use(routes);
app.use(errorHandler);
Express 按照注册顺序执行中间件,并在错误发生位置之后继续寻找错误处理器。
因此错误中间件通常必须放在全部路由之后。如果写成:
app.use(errorHandler);
app.use(routes);
后面的路由发生错误时,Express 不会倒退回前面重新调用 errorHandler。
5.6 headersSent 为什么需要单独处理
当前错误中间件开头是:
if (res.headersSent) {
next(error);
return;
}
res.headersSent 表示 HTTP 响应头已经发送给客户端。
如果此时再次执行:
res.json({ ... });
可能产生:
ERR_HTTP_HEADERS_SENT
因为一次请求不能发送两份互相冲突的响应头。此时当前中间件无法安全地重新组织错误 JSON,只能继续调用 next(error),交给 Express 后续或默认错误处理机制完成连接处理。
6. 当前项目的三条具体错误链路
6.1 用户名或密码错误
services/auth.service.ts 中:
if (admin === null) {
throw invalidCredentialsError();
}
invalidCredentialsError() 返回:
new AppError(
ErrorCodes.INVALID_CREDENTIALS,
"用户名或密码错误",
);
完整流转过程:
loginAdmin()
↓ throw AppError
loginAdminController() 的 Promise rejected
↓ Express 5 自动传递错误
errorHandler 收到 error
↓ error instanceof AppError === true
返回 error.code 和 error.message
最终响应为:
{
"code": "INVALID_CREDENTIALS",
"message": "用户名或密码错误",
"data": null
}
这里故意不区分“用户名不存在”和“密码错误”,避免攻击者通过响应枚举有效用户名。
6.2 JWT 签发失败
services/access-token.service.ts 中:
try {
const access_token = jwt.sign(payload, secret, options);
// ...
} catch (error: unknown) {
throw new Error("管理员 Access Token 签发失败", {
cause: error,
});
}
完整流转过程:
jsonwebtoken 抛出原始异常
↓
access-token.service 捕获
↓
抛出带业务上下文和 cause 的普通 Error
↓
loginAdmin() 终止
↓
loginAdminController() 的 Promise rejected
↓
Express 5 进入错误处理链
↓
errorHandler 发现它不是 AppError,也不是 MulterError
↓
console.error() 记录内部错误
↓
客户端只收到 INTERNAL_SERVER_ERROR
这样同时满足两个目标:
- 开发人员能够从服务器日志中排查真实问题;
- 客户端看不到 JWT 库、配置或服务器内部细节。
6.3 Multer 上传错误
Multer 会抛出自己的错误类型:
multer.MulterError
当前错误中间件明确识别这种第三方错误:
if (error instanceof multer.MulterError) {
const response = getMulterErrorResponse(error);
res.status(200).json({
code: response.code,
message: response.message,
data: null,
});
return;
}
这里不是把 Multer 的全部原始错误直接发送给客户端,而是将已知错误映射为应用认可的安全响应:
LIMIT_FILE_SIZE
→ FILE_TOO_LARGE
→ 上传文件不能超过 20 MB
这说明除了 AppError 之外,明确识别的第三方错误也可以经过安全映射后公开。
7. 最终错误中间件如何分类
当前 middlewares/error-handler.ts 可以概括为以下决策树:
收到 error
│
├─ res.headersSent === true
│ └─ next(error),不再重复发送响应
│
├─ error instanceof AppError
│ └─ 返回应用设计的 code 和 message
│
├─ error instanceof multer.MulterError
│ └─ 映射成安全的上传错误后返回
│
└─ 其他未知错误
├─ console.error() 记录内部详情
└─ 返回通用 INTERNAL_SERVER_ERROR
这个顺序体现了“白名单公开”原则:
只有被应用明确识别并确认安全的错误才能公开,其余错误默认都属于内部错误。
相比“先把所有错误公开,再逐个隐藏敏感错误”,白名单策略更安全,因为开发人员不可能预先列出所有未来会出现的底层异常。
7.1 当前项目为什么都返回 HTTP 200
当前错误中间件中有这样的项目约定:
res.status(200).json({
code: "INTERNAL_SERVER_ERROR",
message: "服务器内部错误",
data: null,
});
也就是说,调用方通过 JSON 中的 code 判断成功或失败,而不是通过 HTTP 状态码判断。
这在一些既有系统中确实存在,但更符合 HTTP 语义的常见方案是:
- 参数错误:
400 Bad Request; - 未认证:
401 Unauthorized; - 无权限:
403 Forbidden; - 资源不存在:
404 Not Found; - 冲突:
409 Conflict; - 未知内部错误:
500 Internal Server Error。
本教程继续遵守当前项目的 HTTP 200 约定,不在错误处理教程中顺带改变 API 契约。但学习时需要知道,这是项目选择,不是 Express 的硬性要求。
8. 返回错误和记录错误是两件事
8.1 客户端响应的目标
客户端响应需要:
- 格式稳定;
- 文案安全;
- 业务码可供程序判断;
- 不包含服务器内部实现。
前端应该判断:
if (response.code === "ACCOUNT_LOCKED") {
// 展示账号锁定提示
}
不应该通过中文文案判断:
if (response.message === "账号已被锁定") {
// 不稳定
}
因为文案可能修改或进行国际化,而 code 应保持稳定。
8.2 服务器日志的目标
服务器日志主要供开发、测试和运维排查,通常需要包含:
- 错误发生时间;
- 请求 method;
- 请求 path;
- 稳定的事件名;
- 错误类型;
- 错误调用栈;
- 底层
cause; - 可用于串联请求的 request ID。
当前未知请求错误记录为:
console.error({
message: "未处理的请求错误",
method: req.method,
path: req.originalUrl,
error,
});
它记录完整错误对象供服务器内部排查,但客户端只收到通用错误消息。
需要注意,不同日志库对 Error 和嵌套 cause 的序列化能力不同。将来切换到 Pino 时,应使用日志库推荐的错误字段写法,并通过实际输出确认 stack 和 cause 没有丢失。
8.3 哪些信息不能写入日志
即使日志不返回客户端,也不能随意记录敏感数据。通常禁止记录:
- 明文密码;
password_hash;- JWT 完整字符串;
- JWT Secret;
- 数据库密码;
- Cookie 和 Authorization 请求头;
- 包含敏感字段的完整
req.body; - 不必要的个人信息。
错误日志不是安全区域。日志通常会被长期保存、集中收集,并允许多人查询,一旦写入敏感数据,泄露范围可能比一次 HTTP 响应更大。
8.4 哪些错误值得记录
可以采用下面的基本策略:
| 错误 | 是否记录 error 日志 | 原因 |
|---|---|---|
| 普通参数校验失败 | 通常不需要 | 高频、属于正常输入错误 |
| 单次密码错误 | 通常不记 error | 避免日志噪声和账号枚举信息 |
| 账号连续失败并锁定 | 可记录安全事件 | 可能反映攻击或异常行为 |
| 数据库异常 | 应记录 | 需要排查基础设施或程序问题 |
| JWT 签发异常 | 应记录 | 登录能力已经不可用 |
| 未知程序异常 | 应记录 | 可能是代码缺陷 |
“不记录 error 日志”不代表完全不关心。登录失败次数、锁定次数等安全数据更适合进入审计事件或监控指标,而不是每次都输出一大段异常调用栈。
8.5 当前工程的日志现状
当前工程中存在几种不同输出:
morgan("dev"):开发环境的 HTTP 访问日志;console.error():错误中间件中的未知请求异常;- CLI 中的结构化
console.log()/console.error(); - 项目已安装 Pino,但当前错误中间件还没有统一使用 Pino。
访问日志和错误日志解决的问题不同:
访问日志:谁在什么时候访问了哪个接口,响应状态和耗时是什么
错误日志:请求为什么失败,异常栈和底层原因是什么
仅有 Morgan 访问日志并不足以排查内部错误。
9. Web 请求与 CLI 脚本的错误处理差异
Express 只负责 Express 请求链中的错误。独立执行的 CLI 脚本没有 Express 帮忙接住异常。
当前 scripts/create-initial-admin.ts 使用:
void main()
.catch((error: unknown) => {
// 处理顶层错误
process.exitCode = 1;
})
.finally(async () => {
await db.close();
});
9.1 Web 请求
异步 Controller rejected
→ Express 5 错误链
→ errorHandler
→ 返回 HTTP 响应
一次请求失败后,Node.js 服务进程通常继续运行,继续处理其他请求。
9.2 CLI 脚本
main() rejected
→ 顶层 .catch()
→ 输出安全错误
→ process.exitCode = 1
→ finally 释放数据库连接
→ 进程结束
CLI 需要自己决定:
- 向 stdout 还是 stderr 输出;
- 退出码是 0 还是非 0;
- 是否关闭数据库连接;
- 哪些错误信息允许显示给终端用户。
9.3 CLI 中同样需要错误脱敏
当前脚本区分了 AppError 和未知异常:
if (error instanceof AppError) {
console.error({
event: "initial_admin_create_failed",
code: error.code,
message: error.message,
});
} else {
console.error({
event: "initial_admin_create_failed",
code: "INTERNAL_SERVER_ERROR",
message: "创建初始管理员失败",
});
}
CLI 的终端输出也可能被 CI/CD 系统或部署平台长期保存,所以不能因为“只有管理员能运行脚本”就把数据库异常、密码或环境变量值全部输出。
另一方面,完全丢弃未知异常也不利于排查。更成熟的工程会把安全摘要输出给操作人员,同时把完整异常写入受保护的内部日志系统。
10. 常见错误写法
10.1 在每个 Controller 中重复 try/catch
不必要的写法:
export async function controller(req, res, next): Promise<void> {
try {
const result = await service();
res.json(result);
} catch (error: unknown) {
next(error);
}
}
在当前 Express 5 项目里,如果 catch 只是调用 next(error),可以直接让 rejected Promise 交给 Express:
export async function controller(req, res): Promise<void> {
const result = await service();
res.json(result);
}
如果 catch 需要释放本地资源、补充错误上下文或执行回滚,那么仍然有存在价值,但处理完成后必须继续抛出或传递错误。
10.2 吞掉错误
try {
await saveData();
} catch {
// 什么也不做
}
这会让上层误以为操作成功,也不会留下排查线索。
除非业务明确允许忽略这个失败,否则至少应该继续抛出错误或返回明确结果。
10.3 把未知 error.message 直接返回客户端
危险写法:
res.json({
code: "ERROR",
message: (error as Error).message,
});
未知消息可能包含内部细节。正确方向是内部记录原始异常,对外使用固定通用文案。
10.4 只记录 error.message
console.error(error.message);
这样可能丢失:
- 错误类型;
- Stack;
- Cause;
- 结构化请求上下文。
更适合记录完整错误对象,并让日志工具按错误类型序列化。
10.5 记录完整请求体
console.error({ body: req.body, error });
登录请求体中包含明文密码,这种日志会直接造成密码泄露。应该只记录经过白名单选择的非敏感上下文。
10.6 发送响应后继续执行
if (!user) {
res.json({ code: "NOT_FOUND" });
}
res.json({ code: "OK" });
缺少 return 会尝试发送第二次响应:
if (!user) {
res.json({ code: "NOT_FOUND" });
return;
}
10.7 误以为外层 try/catch 能捕获所有异步错误
下面的外层 try/catch 捕获不到定时器回调稍后抛出的错误:
try {
setTimeout(() => {
throw new Error("异步回调失败");
}, 0);
} catch (error: unknown) {
// 不会执行
}
因为回调执行时,外层 try 所在的同步调用栈早已结束。
Promise/async 函数应使用 await 或 .catch() 建立错误传播关系;Node 风格回调应通过 callback 参数传递错误。
11. 当前工程的后续改进方向
下面是可以逐步练习的改进,不要求一次完成。
11.1 收紧 AppError 的 code 类型
当前定义是:
public readonly code: string
因此任何字符串都能传入。将来可以考虑使用统一错误码类型,让拼写错误在编译阶段被发现。
但要注意,项目中还有上传等不同模块的错误码。类型设计应覆盖真实需求,不要为了收紧类型建立过度复杂的泛型结构。
11.2 使用 Pino 统一日志
项目已经安装 Pino。后续可以把零散 console.error() 统一成结构化日志,并明确:
- Error 应使用什么字段记录;
cause如何序列化;- 开发和生产环境使用什么日志级别;
- 哪些字段需要自动脱敏。
11.3 增加 request ID
一次请求可能产生:
- 访问日志;
- Service 日志;
- 数据库错误日志;
- 安全审计事件。
给请求分配 request ID 后,可以通过同一个 ID 关联这些记录,而不需要记录完整请求数据。
11.4 区分运行日志与安全审计日志
普通运行错误和管理员登录安全事件用途不同:
- 运行日志用于排查程序和基础设施故障;
- 审计日志用于回答谁在什么时间进行了什么安全相关操作。
两类日志的保存时间、访问权限和字段设计可能不同。
11.5 进程级异常处理
Node.js 还存在:
process.on("uncaughtException", ...);
process.on("unhandledRejection", ...);
它们是最后一道进程级保护和日志机会,不能代替 Express 错误中间件。
请求级错误应该在请求链中处理。真正未捕获的进程级异常可能表示应用状态已经不可靠,生产系统通常应记录错误、优雅停止接收请求并让进程管理器重启,而不是简单打印后继续无限运行。
12. 如何测试错误处理
错误处理不仅要测试响应内容,还要测试敏感信息没有泄露。
建议覆盖以下场景。
12.1 AppError 响应
- 抛出
INVALID_CREDENTIALS; - 响应包含指定
code; - 响应包含安全文案;
- 响应不包含 Stack。
12.2 普通 Error 响应
构造包含内部信息的异常:
throw new Error("连接 mysql://user:password@internal-host 失败");
断言:
- 客户端只收到
INTERNAL_SERVER_ERROR; - 客户端看不到内部异常消息;
- 服务器日志记录到了错误对象。
12.3 Express 5 异步传播
创建一个返回 rejected Promise 的异步处理器,确认不需要手动 next(error) 也能进入最终错误中间件。
12.4 JWT cause
让 jwt.sign() 失败,确认上层错误包含:
message = 管理员 Access Token 签发失败
cause = 原始 jsonwebtoken 异常
同时确认客户端看不到 cause。
12.5 第三方错误映射
分别构造:
LIMIT_FILE_SIZE;LIMIT_FILE_COUNT;LIMIT_UNEXPECTED_FILE。
确认它们被映射为项目稳定的错误码和安全消息。
13. 最终判断清单
准备抛出错误时,可以依次问:
1. 这是应用预期内的业务失败吗?
│
├─ 否 → 使用或保留普通 Error
│ 内部记录真实异常
│ 对外返回通用错误
│
└─ 是
↓
2. code 和 message 是否由应用主动设计并确认安全?
│
├─ 否 → 不要转换成 AppError
│
└─ 是 → 可以使用 AppError
收到未知错误时,再问:
我是否需要增加当前业务步骤的上下文?
│
├─ 否 → 直接 throw error
│
└─ 是 → throw new Error("业务上下文", { cause: error })
记录日志时,还要问:
日志中是否可能包含密码、Token、Secret、Authorization、SQL 参数或个人信息?
如果答案是“可能”,应先做字段白名单或脱敏,不能直接记录完整请求对象。
14. 总结
当前工程的核心错误处理原则可以概括为:
AppError表示应用主动认可、预期之内且可以安全公开的错误;- 普通
Error表示默认不能公开的内部异常; - Service 可以抛错,但不负责组织 HTTP 响应;
- Express 5 会把异步 Controller 的 rejected Promise 自动交给错误处理链;
- 最终错误中间件使用白名单方式判断哪些错误能够公开;
- 未知错误记录在服务器内部,客户端只收到稳定的通用消息;
- 客户端错误响应和服务器错误日志是两个不同的输出边界;
- CLI 没有 Express 帮助捕获错误,必须在顶层显式
.catch(); - 无论 Web 还是 CLI,都不能在响应或日志中泄露密码、Token 和 Secret。
理解这条完整链路以后,就不需要在 Router、Controller、Service 的每一层都重复 try/catch。每一层只在自己确实能够处理、转换或补充上下文时捕获错误,其余错误继续向上交给统一边界处理。