首页
/ Koa 错误处理实战指南:从中间件 try-catch 到默认错误处理器与 Error 事件

Koa 错误处理实战指南:从中间件 try-catch 到默认错误处理器与 Error 事件

2026-09-05 19:23:52作者:乔或婵

本篇指南以 Koa 官方文档中的错误处理章节为核心,结合仓库源码(lib/application.jslib/context.js)与对应测试用例,完整讲清楚 Koa 的错误传播机制:如何在中间件中用 try-catch 包裹 next、默认错误处理器如何决定响应状态码与响应体、如何编写自己的错误处理中间件,以及如何通过 app.on('error') 事件实现集中式日志。读完本文,你将能够读懂 Koa 错误处理链的完整调用路径,并写出可复制运行的错误处理代码。

Koa 的错误传播流程

Koa 基于 async/await 的中间件模型让错误处理变得直接:任何中间件中抛出的异常(或 await next() 下游抛出的异常)会沿中间件链向上传播,最终由框架层面的兜底逻辑处理。

从源码看,这个流程的起点在 lib/application.jshandleRequest 方法中:

// lib/application.js 第 198-205 行
handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  const onerror = (err) => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}

这里有两个关键点:

  • fnMiddleware(ctx).catch(onerror):整个由 koa-compose 组合出的中间件链一旦 reject,就会进入 ctx.onerror(err),也就是 lib/context.js 第 106-162 行定义的 Context#onerror——这就是文档中所说的"位于中间件链最前端的 try-catch"在实现上的等价物;
  • onFinished(res, onerror):借助 on-finished 包监听响应完成,即使错误发生在响应已经发出的过程中(例如流写入失败),错误同样会进入 ctx.onerror,此时框架会将其标记为 err.headerSent = true 并只做上报,不再尝试改写响应。

用 Try-Catch 包裹 next

Koa 错误处理文档的第一个要点:既然中间件是 async 函数,你可以直接对 await next() 做 try-catch。官方给出的示例是给所有错误补充一个 .status 字段后再抛回链上:

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    err.status = err.statusCode || err.status || 500;
    throw err;
  }
});

这个模式的作用在于"错误整形":捕获错误、修改它(比如补全 status、附加 headers、记录日志),然后 throw err 继续向上传播,最终仍交由默认错误处理器或更外层的 handler 决定响应。由于中间件按 use 顺序组成洋葱模型,这个中间件放在哪里,就决定了它兜住哪一段中间件的错误——放在链最前面则能捕获所有后续中间件的异常。

一个重要的相关 API 是 ctx.throw(),其实现位于 lib/context.js 第 95-97 行,直接委托给 http-errors 包:

throw (...args) {
  throw createError(...args)
}

因此 ctx.throw(403)ctx.throw(400, 'name required')ctx.throw('something exploded') 等写法都会产生带 statusexpose 属性的标准化错误。测试文件 tests/context/throw.test.js 印证了其中的规则:仅传消息或 Error 实例时 status 默认为 500 且 exposefalse;当第一个参数是合法状态码(如 ctx.throw(400, 'name required'))时,expose 会被置为 true,即允许把 err.message 暴露给客户端;还可通过第三个参数混入自定义属性。这些属性正是默认错误处理器判断"响应什么内容"的依据。

此外,lib/application.js 在文件末尾还导出了 HttpError,方便使用者不必直接依赖 http-errors 包就能构造标准 HTTP 错误。

默认错误处理器的工作规则

文档将默认错误处理器描述为"位于中间件链最前端的 try-catch",其具体规则可以完整概括为:

  • 使用 err.status(或 err.statusCode)作为响应状态码,缺省为 500;
  • 如果 err.exposetrue,响应体就是 err.message
  • 否则使用状态码对应的标准文案(例如 500 对应 "Internal Server Error");
  • 响应前会清空该请求上已设置的所有响应头,然后设置 err.headers 中声明的头;
  • 响应强制为 text/plain

对照 lib/context.jsContext#onerror 的实现,上述每一条规则都有对应代码:

// lib/context.js 第 106-162 行(节选)
onerror (err) {
  // err == null 时直接返回,允许把 this.onerror 传给 node 风格回调
  if (err == null) return

  // 非原生 Error(含跨 vm 作用域创建的 Error)统一包装
  const isNativeError =
    Object.prototype.toString.call(err) === '[object Error]' ||
    err instanceof Error
  if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err))

  let headerSent = false
  if (this.headerSent || !this.writable) {
    headerSent = err.headerSent = true
  }

  // 委托给 app 级 error 事件
  this.app.emit('error', err, this)

  // 响应头已发出时不再改写响应
  if (headerSent) {
    return
  }

  const { res } = this

  // 先清空所有已设置的响应头
  /* istanbul ignore else */
  if (typeof res.getHeaderNames === 'function') {
    res.getHeaderNames().forEach(name => res.removeHeader(name))
  } else {
    res._headers = {} // Node < 7.7
  }

  // 再设置 err.headers 中声明的头
  this.set(err.headers)

  // 强制 text/plain
  this.type = 'text'

  let statusCode = err.status || err.statusCode

  // 非法状态码回落到 500
  // default to 500
  if (typeof statusCode !== 'number' || !statuses.message[statusCode]) statusCode = 500

  // 响应
  const code = statuses.message[statusCode]
  const msg = err.expose ? err.message : code
  this.status = err.status = statusCode
  this.length = Buffer.byteLength(msg)
  res.end(msg)
}

几个值得注意的实现细节:

  1. 非法状态码的回落statusCode 必须是数字且在 statuses 包中可查到对应消息,否则强制为 500。测试用例 tests/context/onerror.test.js 验证了 err.statusCode = 'notnumber'err.status = 9999 这类非法值最终都返回 500 和 "Internal Server Error";
  2. 响应头先清后设:先 removeHeader 清除全部已有响应头,再通过 this.set(err.headers) 只恢复错误对象上显式声明的头。测试文件 tests/context/onerror.test.js 中的 "should unset all headers" 与 "should set headers specified in the error" 两个用例证实了:中间件提前设置的 VaryX-CSRF-Token 会被清掉,而错误对象 headers 中的 X-New-Header 会被保留到最终响应;
  3. headerSent 短路:若响应头已发出(this.headerSent || !this.writable),错误对象上会打上 err.headerSent = true,且框架只把错误交给 app 级 handler,不再尝试改写响应——此时应由监听器自行结束响应。

编写自己的错误处理中间件

文档指出:默认错误处理器本质上就是链最前端的 try-catch,因此要替换它,只需在链的最前面再挂一个 try-catch 并在那里处理错误即可。官方给出的自定义 handler 示例(只返回 JSON 响应):

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    // will only respond with JSON
    ctx.status = err.statusCode || err.status || 500;
    ctx.body = {
      message: err.message
    };
  }
})

这里的关键在于"catch 住后不要再 throw":一旦错误被捕获且没有重新抛出,中间件链就视为正常完成,请求会走常规的 respond 流程,响应体由你设置的 ctx.status / ctx.body 决定。这也是后文 Error 事件一节中"被 catch 且未重新抛出的错误不会到达错误监听器"的根本原因。

实践中可以按"分层"思路组织:

  • 外层(链最前):全局兜底 handler,负责统一 JSON 错误格式、日志上报;
  • 内层(业务模块边界):模块级 try-catch,把领域错误转换成带 statusexposeheaders 的结构化错误后再 throw 出去,交给外层统一决定响应。

需要提醒的是:自定义 handler 只接管了"向客户端响应"这一环,app.emit('error') 的日志上报环仍然存在(它发生在 Context#onerror 中,而自定义 handler 若自行构造响应则绕过了 Context#onerror)。若希望集中日志,配合下文的 Error 事件使用即可。

The Error Event:app.on('error') 与 app.onerror

文档的第三个主题:错误事件监听器通过 app.on('error') 指定。其行为规则为:

  • 如果指定了错误监听器,所有沿中间件链"回来"的错误都会经过监听器;被捕获且未重新抛出的错误不会传给错误监听器
  • 如果未指定任何错误事件监听器,则使用 app.onerror,它只会把错误打到 stderr,且满足以下任一条件时不打日志:err.exposetrueapp.silenttrueerr.status 为 404。

默认监听器的注册时机在 lib/application.jscallback() 中(第 167-183 行):

callback () {
  const fn = this.compose(this.middleware)

  if (!this.listenerCount('error')) this.on('error', this.onerror)
  // ...
}

也就是说,只有在你自己没有注册过任何 error 监听器时,框架才会把 this.onerror 挂上去;你自己注册的监听器会"顶替"掉默认日志逻辑。

Application#onerror 的实现(lib/application.js 第 238-252 行):

onerror (err) {
  // 处理跨全局环境(cross-globals)时的 instanceof 失效问题
  const isNativeError =
    Object.prototype.toString.call(err) === '[object Error]' ||
    err instanceof Error
  if (!isNativeError) { throw new TypeError(util.format('non-error thrown: %j', err)) }

  if (err.status === 404 || err.expose) return
  if (this.silent) return

  const msg = err.stack || err.toString()
  console.error(`\n${msg.replace(/^/gm, '  ')}\n`)
}

对应测试 tests/application/onerror.test.js 逐条验证了这些行为:404 错误不打日志、app.silent = true 时不打日志、正常错误会把 err.stack 输出到 stderr、传入非 Error 值会抛出 TypeError: non-error thrown: foo、来自其他 vm 作用域的 Error 也能被正确识别。

docs/api/index.md 中的 API 说明进一步补充了监听器签名的两种形式:

// 常规情况
app.on('error', err => {
  log.error('server error', err)
});

// 当错误发生在 req/res 周期中且已无法向客户端响应时,
// Context 实例会作为第二个参数传入
app.on('error', (err, ctx) => {
  log.error('server error', err, ctx)
});

这与源码中 Context#onerror 里的 this.app.emit('error', err, this) 完全一致:emit 的第二个参数就是当前 ctx,监听器可以据此读取请求路径等信息,并在 err.headerSent 为真时自行结束响应(测试 tests/context/onerror.test.js 中 "should ignore error after headerSent" 用例演示了 flushHeaders 之后抛错、监听器拿到 err.headerSent === true 并调用 res.end() 的场景)。

边界情况速查

综合源码与测试,Koa 错误处理有几个值得记住的边界行为:

场景 框架行为 依据
抛出字符串、对象等非 Error 值 包装为 Error('non-error thrown: ...') 后按 500 处理 lib/context.js 第 115-118 行
err.status / err.statusCode 非法(非数字或无对应消息) 回落为 500 lib/context.js 第 151-154 行
响应头已发出后再抛错 err.headerSent = true,仅触发 error 事件,不再改写响应 lib/context.js 第 120-133 行
流写入失败(响应过程中出错) 通过 onFinished(res, onerror) 进入错误处理 lib/application.js 第 203 行
err.expose 为 true 响应体为 err.message,且默认 app.onerror 不记录日志 lib/context.js 第 158 行、lib/application.js 第 247 行
app.silent = true 默认 handler 静默,不打 stderr 日志 lib/application.js 第 248 行

小结

Koa 的错误处理可以归纳为两层协作:响应层由中间件链最前端的 try-catch(默认即 Context#onerror)完成,按 err.status / err.expose / err.headers 规则生成安全的 text/plain 响应;观测层error 事件完成,未自定义监听器时由 app.onerror 负责 stderr 日志,并遵守 404 / expose / silent 的静默规则。想接管响应就"在链头加 try-catch 且 catch 后不 throw",想做集中日志就"注册 app.on('error')"——两条路径在 lib/application.jslib/context.js 中都能找到明确的一行代码对应,配合 tests/context/onerror.test.jstests/application/onerror.test.js 即可对每一类行为做回归验证。

登录后查看全文
热门项目推荐
相关项目推荐