症状
典型形态是一个永远不完成的请求,加上一条解释了原因但从未被关联起来的进程日志:unhandledRejection 的堆栈指向路由内部,而 Express 错误中间件毫无动静。“rejection 堆栈来自路由 + 错误中间件沉默”这个组合,就是 Express 4 丢弃 promise 的指纹。客户端最终以超时收场,socket 一直挂着,负载高时还会泄漏连接。
诊断思路
先给 unhandledRejection 挂上带堆栈的日志确认来源;再检查错误中间件签名——必须声明全部四个参数 (err, req, res, next),三参数会被当成普通中间件永远收不到错误;最后用 curl -m 5 请求失败路由复现:无 500 响应、随后出现 unhandledRejection 日志,即完全复现生产行为。同时排查 catch 块吞错误却忘了调用 next(err) 的路径,它产生同样的挂起但没有 rejection 日志。
根因
Express 4 同步调用处理器,只通过 next(err) 这类回调调用感知错误。async 函数 rejection 时返回的是一个被拒绝的 promise,Express 4 对它不做任何处理:错误中间件不会运行,响应路径也不会执行,请求 socket 一直保持打开,直到客户端放弃。
修复与验证
用一个集中的 asyncHandler 包装器把 promise 桥接到 next:Promise.resolve(fn(req, res, next)).catch(next),四参数错误中间件放在最后。新代码可直接选 Express 5,它原生转发处理器的被拒绝 promise。验证三步:curl 请求失败路由,应在毫秒级收到 500 而不是超时;错误中间件恰好记录一条日志;进程日志中 unhandledRejection 计数保持为零。再在 CI 中断言所有 async (req 处理器都位于包装器内,防止回归。全局 unhandledRejection 监听可以保留,但只能作为兜底安全网。
代码示例
2026-10-22T08:14:03.112Z unhandledRejection Error: connection refused
at queryUser (src/routes/users.js:41:19)
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', wrap(async (req, res) => {
const user = await queryUser(req.params.id);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
res.json(user);
}));
// last middleware, all four arguments present
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});