Async route: vì sao Promise reject trong Express 4 không tới error middleware, và cách Fastify tự bắt lỗi
Một async route handler trông giống một handler bình thường, nhưng cơ chế truyền lỗi của nó khác hẳn. Express 4 bọc lời gọi handler bằng try/catch đồng bộ; một hàm async return về một Promise trước khi Promise đó settle, nên try/catch đồng bộ không bắt được rejection. Kết quả: lỗi rơi ra khỏi handler dưới dạng unhandledRejection, không tới error middleware, không được gửi thành 500 JSON, và từ Node 15 mặc định làm process crash. Fastify đi hướng khác — pipeline hook tự await handler, rejection được framework bắt và chuyển tới setErrorHandler. Hiểu điểm khác biệt này giải thích vì sao cùng một dòng code await db.query(...) lại có hai hệ quả production hoàn toàn khác nhau giữa hai framework.
Cơ chế hoạt động
Trong Express 4, Layer.handle_request gọi handler đồng bộ trong một khối try/catch. Nếu handler ném exception đồng bộ, catch được, forward qua next(err). Nhưng handler async không ném — nó return về một Promise. Đến khi Promise reject, control đã ra khỏi khối try/catch từ lâu:
// Đây là bản giản lược của cách Express 4 gọi handler:
try {
const ret = handler(req, res, next) // với async handler, ret là Promise (đã pending)
// handler đã "return" — try/catch không còn hiệu lực với reject xảy ra sau
} catch (err) {
next(err) // chỉ chạy khi handler ném đồng bộ
}
Vì Express 4 không await ret và cũng không ret.catch(next), một Promise reject không có bên nào bắt. Node emit unhandledRejection ở tầng process. Cách sửa chuẩn là wrapper:
// asyncHandler: bọc handler async, forward reject vào next(err)
export const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await db.orders.findById(req.params.id)
if (!order) { res.status(404).json({ error: 'not_found' }); return }
res.json(order)
}))
Express 5 (bản major stable phát hành 2024) sửa vấn đề này ở tầng framework: theo Express docs mục "Migrating to Express 5", route/middleware trả về Promise reject sẽ tự bị forward vào error chain, không cần wrapper nữa. Nhưng phần lớn service production vẫn đang ở Express 4, nên wrapper vẫn là kiến thức bắt buộc.
Fastify không có vấn đề tương đương. Theo Fastify docs mục "Errors", pipeline hook await từng bước; nếu handler async reject (throw hay return rejected Promise đều được), Fastify bắt, dẫn qua hook onError rồi gọi error handler (setErrorHandler nếu có, mặc định trả JSON với statusCode):
app.setErrorHandler((err, req, reply) => {
req.log.error({ err }, 'request failed')
reply.status(err.statusCode ?? 500).send({ error: err.code ?? 'internal' })
})
app.get('/orders/:id', async (req, reply) => {
const order = await app.db.orders.findById(req.params.id)
if (!order) throw app.httpErrors.notFound() // Fastify bắt, chuyển tới setErrorHandler
return order
})
Vấn đề gặp trong production
Failure mode 1: async handler Express 4 không bọc — process crash. Đây là failure mode phổ biến nhất khi migrate từ callback sang async/await:
// SAI: Express 4 không bắt Promise reject từ handler async
app.get('/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id) // reject nếu DB down
res.json(order)
})
Khi db.orders.findById reject (DB timeout, connection pool exhausted, syntax error), handler return một Promise reject. Client thấy request treo cho tới khi socket timeout. Ở tầng process, Node emit unhandledRejection; từ Node 15 mặc định là --unhandled-rejections=throw (theo Node docs process event unhandledRejection), nghĩa là process crash. Trên K8s, pod bị OOM/crash-restart; một lỗi DB làm rơi cả instance. Sửa bằng wrapper asyncHandler (xem trên) hoặc dùng thư viện express-async-errors (monkey-patch Layer.handle_request để tự await) — thêm vào import 'express-async-errors' ở đầu app.
Failure mode 2: fire-and-forget bên trong handler. Cả trong Express lẫn Fastify, nếu bên trong handler có một async call không được await, framework không thấy Promise đó, không có bên nào bắt reject:
// SAI: emit event log không await, reject rơi vào void
app.post('/orders', asyncHandler(async (req, res) => {
const order = await db.orders.create(req.body)
auditLog.write({ event: 'order.created', orderId: order.id }) // async, không await, không .catch
res.status(201).json(order)
}))
Nếu auditLog.write reject, không ai bắt — kể cả wrapper asyncHandler cũng bó tay vì handler đã return xong. Đây là lỗi bất kể framework. Sửa: await nếu cần chờ, hoặc .catch(err => log.error({ err }, 'audit failed')) nếu cố tình fire-and-forget để không block response.
Failure mode 3: try/catch nuốt lỗi rồi vẫn res.send bình thường. Ngược với failure mode 1: bọc try/catch quá rộng và không phản hồi đúng status:
// SAI: catch tất, nuốt lỗi, trả 200 với body rỗng
app.get('/orders/:id', asyncHandler(async (req, res) => {
try {
const order = await db.orders.findById(req.params.id)
res.json(order)
} catch (err) {
res.json({}) // nuốt lỗi, client thấy 200 OK payload rỗng — monitor mù
}
}))
Alerts không kích hoạt (status 200), nhưng downstream nhận payload rỗng và vỡ. Quy tắc: nếu bắt exception ở tầng handler, hoặc next(err) để error middleware xử lý thống nhất, hoặc res.status(...) với status thật (không phải 200). Đừng ép về 200 {}.
Failure mode 4: middleware async không bọc — treo request không log. Middleware async cũng bị đúng vấn đề như handler async trong Express 4:
// SAI: auth middleware async, verifyToken reject, không có bên bắt
app.use(async (req, _res, next) => {
req.user = await verifyToken(req.headers.authorization) // reject nếu token invalid
next()
})
Reject không tới error middleware, request treo, unhandledRejection bắn. Sửa: bọc bằng cùng wrapper asyncHandler áp dụng cho middleware, hoặc dùng try/catch trong middleware và next(err) thủ công.
Failure mode 5: Fastify — setErrorHandler thiếu, response mặc định lộ chi tiết. Fastify tự bắt lỗi async, nhưng nếu không đăng ký setErrorHandler và error không phải instance của httpErrors, mặc định trả 500 với message của error. Trên môi trường prod, tránh lộ chi tiết implementation; luôn đăng ký setErrorHandler để chuẩn hóa payload lỗi và log theo request id.
Cách debug và monitor
Triệu chứng: request treo tới requestTimeout, không log access; process log có dòng UnhandledPromiseRejection; ở K8s, pod restart bất chợt sau khi có traffic lỗi DB. Cách xử lý:
-
Bắt
unhandledRejectionvàuncaughtExceptionở tầng process. Theo Node docsprocess, đăng ký listener để log structured trước khi crash — nếu không, chỉ thấy stack thô trên stderr, khó phân biệt request nào gây ra. Log kèmerr.stack,err.code, và nếu có context (AsyncLocalStoragerequest id) thì càng tốt.
process.on('unhandledRejection', (reason) => {
log.fatal({ err: reason }, 'unhandled_rejection')
// để process exit tự nhiên theo --unhandled-rejections=throw của Node 15+
})
process.on('uncaughtException', (err) => {
log.fatal({ err }, 'uncaught_exception')
process.exit(1)
})
Không "nuốt" bằng listener. Đừng dùng
unhandledRejectionhandler để "cứu" process (chỉ log rồi return). Node docs nói rõ: kể từ Node 15, hành vi mặc định là crash — vì process ở trạng thái không xác định sau một lỗi không bắt. Log để observability, nhưng để process chết và để orchestrator restart.Metric. Counter
nodejs_unhandled_rejections_total(tên tự đặt) tăng khi listener chạy; alert khi rate khác 0. Kèm HTTP error rate theo route/status để biết endpoint nào là nguồn. Với Fastify,setErrorHandlerlà điểm tự nhiên để tăng counter theoerr.code/err.statusCode.CLI flag để chọn hành vi.
node --unhandled-rejections=strictép crash ngay lần đầu (kể cả trên Node < 15 nếu cần backport).warncho môi trường dev nếu muốn tiếp tục chạy dù có lỗi. Ở production, để mặc định (throw) để phát hiện lỗi sớm.ESLint rule.
no-floating-promises(từ@typescript-eslint) ngăn commit code có Promise khôngawait/.catch— chặn failure mode 2 và 4 ngay ở CI. Với JS thuần,promise/no-floating-promisestừeslint-plugin-promiselàm việc tương tự.Tracing. OpenTelemetry
@opentelemetry/instrumentation-expressvà@opentelemetry/instrumentation-fastify(theo OpenTelemetry docs mục JavaScript instrumentation) tạo span cho từng middleware/handler. Khi async reject không được bắt, span kết thúc với statusERRORkèm exception attribute — trace hiện ra chính hàm ném lỗi mà không phải grep log.
Rule phòng ngừa: (1) mọi route/middleware async trong Express 4 phải qua asyncHandler hoặc import express-async-errors; (2) không fire-and-forget Promise trong handler mà không có .catch; (3) luôn có listener unhandledRejection để log structured; (4) bật ESLint no-floating-promises ở CI; (5) trong Fastify luôn đăng ký setErrorHandler để chuẩn hóa payload lỗi.
Tradeoff
Wrapper trong Express 4 rẻ về runtime (một closure và một .catch), nhưng đắt về kỷ luật: chỉ cần một route mới quên bọc là lại tái hiện failure mode 1. express-async-errors monkey-patch giúp DX, nhưng đổi lại là behavior không rõ ràng từ code — người đọc phải biết có import 'express-async-errors' ở đâu đó. Express 5 giải quyết ở tầng framework, giá là chi phí migrate (breaking changes ở path-to-regexp, req.query immutable, một số middleware cũ chưa tương thích).
Fastify auto-catch giảm code boilerplate và loại bỏ cả một class lỗi ở review, nhưng cần chú ý: mọi throw trong handler đều đi qua error handler — không có "throw để log rồi tiếp tục". Nếu muốn behavior khác (ví dụ retry trong cùng handler), phải try/catch thủ công. Ngoài ra setErrorHandler mặc định (khi không tự đăng ký) trả message error thô ra client — cần override ở production.
Rule of thumb: nếu vẫn ở Express 4, bọc asyncHandler cho mọi async route/middleware, coi đó là boilerplate cứng như import; bật ESLint no-floating-promises. Nếu bắt đầu service mới, chọn Fastify hoặc Express 5 để bỏ hẳn lớp lỗi này. Không dùng listener unhandledRejection để "cứu" process — chỉ log rồi để crash.
Câu hỏi phỏng vấn
Vì sao lỗi ném ra từ một
asyncroute handler trong Express 4 không tới error middleware, và cần làm gì để nó tới?
Express 4 bọc lời gọi handler bằng try/catch đồng bộ. Một hàm async không ném lỗi — nó return về một Promise. Đến khi Promise reject, control đã ra khỏi try/catch từ lâu, nên catch block không chạy và Express không gọi next(err). Reject rơi ra tầng process thành unhandledRejection; từ Node 15 mặc định --unhandled-rejections=throw, nghĩa là process crash. Cách sửa chuẩn là wrapper asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next) — biến Promise reject thành next(err) để error middleware bắt. Có thể thay bằng express-async-errors (monkey-patch tự await) hoặc migrate lên Express 5 (native support). Điểm ăn điểm là gọi được ba thứ: cơ chế (try/catch đồng bộ không bắt được Promise reject), hậu quả production (một lỗi DB làm crash pod thay vì trả 500 JSON), và cách phòng thêm ngoài wrapper — listener unhandledRejection để log structured, ESLint no-floating-promises để chặn ở CI, và đối chiếu với Fastify — pipeline hook tự await handler nên không có class lỗi này ngay từ đầu.
Hands-on
Dựng hai server cạnh nhau, tái hiện async reject không bọc, xem process crash, rồi sửa. Toàn bộ chạy được với Node 18+.
Chuẩn bị:
mkdir async-route && cd async-route
npm init -y
npm pkg set type=module
npm i express fastify pino
npm i -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
Tạo express-broken.mjs — bản Express 4 KHÔNG bọc:
import express from 'express'
import pino from 'pino'
const log = pino()
const app = express()
process.on('unhandledRejection', (reason) => {
log.fatal({ err: reason }, 'unhandled_rejection')
})
process.on('uncaughtException', (err) => {
log.fatal({ err }, 'uncaught_exception')
process.exit(1)
})
// giả lập DB call reject
const fakeDbFind = async (id) => {
if (id === 'bad') throw new Error('db_connection_refused')
return { id, total: 100 }
}
// SAI: async handler không bọc, reject không tới error middleware
app.get('/orders/:id', async (req, res) => {
const order = await fakeDbFind(req.params.id)
res.json(order)
})
app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'internal' })
})
const server = app.listen(3001, () => log.info('express-broken :3001'))
server.requestTimeout = 5000
Tạo express-fixed.mjs — bản có wrapper:
import express from 'express'
import pino from 'pino'
const log = pino()
const app = express()
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
const fakeDbFind = async (id) => {
if (id === 'bad') throw new Error('db_connection_refused')
return { id, total: 100 }
}
app.get('/orders/:id', asyncHandler(async (req, res) => {
const order = await fakeDbFind(req.params.id)
res.json(order)
}))
app.use((err, req, res, _next) => {
log.error({ err, url: req.url }, 'request_failed')
res.status(500).json({ error: 'internal' })
})
app.listen(3002, () => log.info('express-fixed :3002'))
Tạo fastify-server.mjs — Fastify auto-catch:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
const fakeDbFind = async (id) => {
if (id === 'bad') throw new Error('db_connection_refused')
return { id, total: 100 }
}
app.setErrorHandler((err, req, reply) => {
req.log.error({ err }, 'request_failed')
reply.status(err.statusCode ?? 500).send({ error: err.code ?? 'internal' })
})
app.get('/orders/:id', async (req) => {
return fakeDbFind(req.params.id) // reject sẽ được Fastify bắt tự động
})
await app.listen({ port: 3003 })
Chạy và quan sát:
# Terminal 1: server broken
node --unhandled-rejections=throw express-broken.mjs
# Terminal 2: gọi endpoint tốt và endpoint xấu
curl -s http://localhost:3001/orders/ok # {"id":"ok","total":100}
curl -s http://localhost:3001/orders/bad # request treo, sau đó server tự crash
# Quan sát Terminal 1: log fatal "unhandled_rejection" + process exit
Chạy express-fixed.mjs và fastify-server.mjs song song, lặp lại curl .../orders/bad:
node express-fixed.mjs &
node fastify-server.mjs &
curl -s -w "\n%{http_code}\n" http://localhost:3002/orders/bad
# -> {"error":"internal"} 500 -- error middleware chạy, process không crash
curl -s -w "\n%{http_code}\n" http://localhost:3003/orders/bad
# -> {"error":"internal"} 500 -- Fastify tự bắt qua setErrorHandler
Cuối cùng bật ESLint rule để chặn floating promise ở CI. Tạo .eslintrc.json:
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
Thêm một hàm fire-and-forget vào một file .ts (ví dụ audit.write(...) không await), chạy npx eslint . và xác nhận rule bắt được — đây là hàng rào cuối cùng ngăn failure mode 2/4 lọt vào main branch.
Top comments (0)