Express 错误处理:Error、AppError、错误流转与日志

本教程结合当前 Sakila API 工程,解释下面几个容易混淆的问题:

本文涉及的主要文件:

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 非预期、只应在服务器内部处理的错误

例如:

这些错误通常属于程序缺陷、运行环境故障或者基础设施故障。它们的原始消息可能包含:

因此,服务器需要记录真实异常以便排查,但不能把原始异常直接返回给客户端。

客户端只应该收到稳定的通用响应:

{
  "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 的能力,因此它仍然拥有:

下面两个判断都会成立:

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 响应

每一层的职责不同:

错误可能在任意一层产生,但不需要在每一层重复转换和响应。


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

这样同时满足两个目标:

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 语义的常见方案是:

本教程继续遵守当前项目的 HTTP 200 约定,不在错误处理教程中顺带改变 API 契约。但学习时需要知道,这是项目选择,不是 Express 的硬性要求。


8. 返回错误和记录错误是两件事

8.1 客户端响应的目标

客户端响应需要:

前端应该判断:

if (response.code === "ACCOUNT_LOCKED") {
  // 展示账号锁定提示
}

不应该通过中文文案判断:

if (response.message === "账号已被锁定") {
  // 不稳定
}

因为文案可能修改或进行国际化,而 code 应保持稳定。

8.2 服务器日志的目标

服务器日志主要供开发、测试和运维排查,通常需要包含:

当前未知请求错误记录为:

console.error({
  message: "未处理的请求错误",
  method: req.method,
  path: req.originalUrl,
  error,
});

它记录完整错误对象供服务器内部排查,但客户端只收到通用错误消息。

需要注意,不同日志库对 Error 和嵌套 cause 的序列化能力不同。将来切换到 Pino 时,应使用日志库推荐的错误字段写法,并通过实际输出确认 stack 和 cause 没有丢失。

8.3 哪些信息不能写入日志

即使日志不返回客户端,也不能随意记录敏感数据。通常禁止记录:

错误日志不是安全区域。日志通常会被长期保存、集中收集,并允许多人查询,一旦写入敏感数据,泄露范围可能比一次 HTTP 响应更大。

8.4 哪些错误值得记录

可以采用下面的基本策略:

错误 是否记录 error 日志 原因
普通参数校验失败 通常不需要 高频、属于正常输入错误
单次密码错误 通常不记 error 避免日志噪声和账号枚举信息
账号连续失败并锁定 可记录安全事件 可能反映攻击或异常行为
数据库异常 应记录 需要排查基础设施或程序问题
JWT 签发异常 应记录 登录能力已经不可用
未知程序异常 应记录 可能是代码缺陷

“不记录 error 日志”不代表完全不关心。登录失败次数、锁定次数等安全数据更适合进入审计事件或监控指标,而不是每次都输出一大段异常调用栈。

8.5 当前工程的日志现状

当前工程中存在几种不同输出:

  1. morgan("dev"):开发环境的 HTTP 访问日志;
  2. console.error():错误中间件中的未知请求异常;
  3. CLI 中的结构化 console.log() / console.error();
  4. 项目已安装 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 需要自己决定:

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);

这样可能丢失:

更适合记录完整错误对象,并让日志工具按错误类型序列化。

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() 统一成结构化日志,并明确:

11.3 增加 request ID

一次请求可能产生:

给请求分配 request ID 后,可以通过同一个 ID 关联这些记录,而不需要记录完整请求数据。

11.4 区分运行日志与安全审计日志

普通运行错误和管理员登录安全事件用途不同:

两类日志的保存时间、访问权限和字段设计可能不同。

11.5 进程级异常处理

Node.js 还存在:

process.on("uncaughtException", ...);
process.on("unhandledRejection", ...);

它们是最后一道进程级保护和日志机会,不能代替 Express 错误中间件。

请求级错误应该在请求链中处理。真正未捕获的进程级异常可能表示应用状态已经不可靠,生产系统通常应记录错误、优雅停止接收请求并让进程管理器重启,而不是简单打印后继续无限运行。


12. 如何测试错误处理

错误处理不仅要测试响应内容,还要测试敏感信息没有泄露。

建议覆盖以下场景。

12.1 AppError 响应

12.2 普通 Error 响应

构造包含内部信息的异常:

throw new Error("连接 mysql://user:password@internal-host 失败");

断言:

12.3 Express 5 异步传播

创建一个返回 rejected Promise 的异步处理器,确认不需要手动 next(error) 也能进入最终错误中间件。

12.4 JWT cause

让 jwt.sign() 失败,确认上层错误包含:

message = 管理员 Access Token 签发失败
cause   = 原始 jsonwebtoken 异常

同时确认客户端看不到 cause。

12.5 第三方错误映射

分别构造:

确认它们被映射为项目稳定的错误码和安全消息。


13. 最终判断清单

准备抛出错误时,可以依次问:

1. 这是应用预期内的业务失败吗?
   │
   ├─ 否 → 使用或保留普通 Error
   │       内部记录真实异常
   │       对外返回通用错误
   │
   └─ 是
       ↓
2. code 和 message 是否由应用主动设计并确认安全?
   │
   ├─ 否 → 不要转换成 AppError
   │
   └─ 是 → 可以使用 AppError

收到未知错误时,再问:

我是否需要增加当前业务步骤的上下文?
  │
  ├─ 否 → 直接 throw error
  │
  └─ 是 → throw new Error("业务上下文", { cause: error })

记录日志时,还要问:

日志中是否可能包含密码、Token、Secret、Authorization、SQL 参数或个人信息?

如果答案是“可能”,应先做字段白名单或脱敏,不能直接记录完整请求对象。


14. 总结

当前工程的核心错误处理原则可以概括为:

  1. AppError 表示应用主动认可、预期之内且可以安全公开的错误;
  2. 普通 Error 表示默认不能公开的内部异常;
  3. Service 可以抛错,但不负责组织 HTTP 响应;
  4. Express 5 会把异步 Controller 的 rejected Promise 自动交给错误处理链;
  5. 最终错误中间件使用白名单方式判断哪些错误能够公开;
  6. 未知错误记录在服务器内部,客户端只收到稳定的通用消息;
  7. 客户端错误响应和服务器错误日志是两个不同的输出边界;
  8. CLI 没有 Express 帮助捕获错误,必须在顶层显式 .catch();
  9. 无论 Web 还是 CLI,都不能在响应或日志中泄露密码、Token 和 Secret。

理解这条完整链路以后,就不需要在 Router、Controller、Service 的每一层都重复 try/catch。每一层只在自己确实能够处理、转换或补充上下文时捕获错误,其余错误继续向上交给统一边界处理。