Koa 错误处理实战指南:从中间件 try-catch 到默认错误处理器与 Error 事件
本篇指南以 Koa 官方文档中的错误处理章节为核心,结合仓库源码(lib/application.js、lib/context.js)与对应测试用例,完整讲清楚 Koa 的错误传播机制:如何在中间件中用 try-catch 包裹 next、默认错误处理器如何决定响应状态码与响应体、如何编写自己的错误处理中间件,以及如何通过 app.on('error') 事件实现集中式日志。读完本文,你将能够读懂 Koa 错误处理链的完整调用路径,并写出可复制运行的错误处理代码。
Koa 的错误传播流程
Koa 基于 async/await 的中间件模型让错误处理变得直接:任何中间件中抛出的异常(或 await next() 下游抛出的异常)会沿中间件链向上传播,最终由框架层面的兜底逻辑处理。
从源码看,这个流程的起点在 lib/application.js 的 handleRequest 方法中:
// 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') 等写法都会产生带 status 和 expose 属性的标准化错误。测试文件 tests/context/throw.test.js 印证了其中的规则:仅传消息或 Error 实例时 status 默认为 500 且 expose 为 false;当第一个参数是合法状态码(如 ctx.throw(400, 'name required'))时,expose 会被置为 true,即允许把 err.message 暴露给客户端;还可通过第三个参数混入自定义属性。这些属性正是默认错误处理器判断"响应什么内容"的依据。
此外,lib/application.js 在文件末尾还导出了 HttpError,方便使用者不必直接依赖 http-errors 包就能构造标准 HTTP 错误。
默认错误处理器的工作规则
文档将默认错误处理器描述为"位于中间件链最前端的 try-catch",其具体规则可以完整概括为:
- 使用
err.status(或err.statusCode)作为响应状态码,缺省为 500; - 如果
err.expose为true,响应体就是err.message; - 否则使用状态码对应的标准文案(例如 500 对应 "Internal Server Error");
- 响应前会清空该请求上已设置的所有响应头,然后设置
err.headers中声明的头; - 响应强制为
text/plain。
对照 lib/context.js 中 Context#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)
}
几个值得注意的实现细节:
- 非法状态码的回落:
statusCode必须是数字且在statuses包中可查到对应消息,否则强制为 500。测试用例 tests/context/onerror.test.js 验证了err.statusCode = 'notnumber'、err.status = 9999这类非法值最终都返回 500 和 "Internal Server Error"; - 响应头先清后设:先
removeHeader清除全部已有响应头,再通过this.set(err.headers)只恢复错误对象上显式声明的头。测试文件 tests/context/onerror.test.js 中的 "should unset all headers" 与 "should set headers specified in the error" 两个用例证实了:中间件提前设置的Vary、X-CSRF-Token会被清掉,而错误对象headers中的X-New-Header会被保留到最终响应; - 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,把领域错误转换成带
status、expose、headers的结构化错误后再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.expose为true、app.silent为true、err.status为 404。
默认监听器的注册时机在 lib/application.js 的 callback() 中(第 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.js 与 lib/context.js 中都能找到明确的一行代码对应,配合 tests/context/onerror.test.js 与 tests/application/onerror.test.js 即可对每一类行为做回归验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00