Giao diện
Xử lý lỗi & graceful shutdown
Một process Node.js phục vụ hàng nghìn request trên cùng một thread và cùng một vùng nhớ. Vì vậy câu hỏi "lỗi này xử lý thế nào" thường thành "process nên tiếp tục chạy hay nên chết". Trang này phân biệt hai loại lỗi, giải thích vì sao lỗi không ai bắt nên để process chết, cách tắt process đúng cách khi deploy (graceful shutdown), và cách tổ chức error class. Retry, timeout và circuit breaker cho lời gọi ra ngoài có trang chính ở Xử lý lỗi & idempotency.
Hai loại lỗi
| Lỗi vận hành (operational error) | Lỗi lập trình (programmer error) | |
|---|---|---|
| Là gì | Tình huống có thể xảy ra với code đúng | Bug trong code |
| Ví dụ | Mất kết nối database, timeout khi gọi API, file không tồn tại, input không hợp lệ, hết dung lượng đĩa | Đọc thuộc tính của undefined, gọi hàm với sai kiểu, quên await, logic sai |
| Xử lý | Dự đoán trước và xử lý ngay tại chỗ: retry, trả 4xx/5xx có nghĩa, dùng giá trị dự phòng | Không "xử lý" được lúc chạy, vì không biết trạng thái đã hỏng tới đâu. Ghi log, để process chết, sửa code |
Ranh giới quan trọng nằm ở câu hỏi: sau lỗi này, trạng thái của process có còn đáng tin không? Một request lỗi vì database timeout thì các request khác không bị ảnh hưởng, nên chỉ cần trả lỗi cho request đó. Một bug ném exception giữa chừng có thể đã để lại connection mượn mà chưa trả, lock chưa nhả, biến toàn cục sửa dở.
Lỗi không ai bắt: để process chết
Exception không có try/catch nào bắt (uncaught exception), hay promise bị reject mà không có ai xử lý (unhandled rejection), mặc định làm process chết với exit code 1 (Promise & async/await).
Có thể chặn bằng process.on('uncaughtException', handler), nhưng tài liệu Node gọi đây là cơ chế thô sơ, chỉ dùng như phương án cuối, và nói rõ không an toàn để tiếp tục chạy bình thường sau uncaughtException. Trên lab, một handler "mượn" một connection của pool rồi gặp bug trong callback trước khi trả lại. Mặc định, process chết ngay. Có handler nuốt lỗi thì process chạy tiếp, nhưng sau 3 request như vậy pool đã cạn sạch (0 trên 3 connection rảnh) và không bao giờ hồi phục: mọi request sau đó treo chờ connection. Một process chết rồi khởi động lại sạch sẽ tốt hơn nhiều so với một process sống mà hỏng âm thầm.
Cách làm đúng:
js
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaught exception, thoát')
process.exitCode = 1
shutdown() // cố đóng gọn trong vài giây, rồi thoát
})
process.on('unhandledRejection', (reason) => { throw reason }) // đưa về cùng một đường xử lýProcess chết thì phải có thứ khởi động lại nó: Kubernetes, systemd, PM2, Docker với restart: always. Nên chạy nhiều instance, để một instance chết không làm cả service ngừng (Scale Node.js). Nếu một bug làm process chết liên tục, đó là tín hiệu cần sửa code chứ không phải lý do để nuốt lỗi.
Lỗi vận hành trong từng request thì bắt ngay trong request đó. Framework thường có một chỗ chung (error middleware của Express, exception filter của NestJS) để biến lỗi thành response và ghi log (Framework).
Graceful shutdown
Mỗi lần deploy, scale xuống hay đổi máy, process cũ nhận tín hiệu SIGTERM. Kubernetes gửi SIGTERM cho container, chờ một khoảng (grace period, mặc định 30 giây), rồi gửi SIGKILL nếu process chưa thoát. Node mặc định thoát ngay khi nhận SIGTERM, nên mọi request đang chạy bị cắt ngang. Trên lab, 5 request đang chạy dở đều lỗi ECONNRESET. Với một service deploy vài lần mỗi ngày, đó là vài lần mỗi ngày người dùng gặp lỗi.
Graceful shutdown là tắt process theo thứ tự:
- Ngừng nhận việc mới: ngừng nhận connection mới, báo health check là chưa sẵn sàng.
- Chờ việc đang dở chạy xong, có giới hạn thời gian.
- Đóng tài nguyên: connection pool của database, Redis, consumer của hàng đợi (commit offset hoặc trả message về hàng đợi).
- Thoát, và ép thoát nếu quá thời gian.
Bẫy keep-alive
Bước 1 bằng server.close() nghe đơn giản nhưng có một cái bẫy. server.close() ngừng nhận connection mới, nhưng client và load balancer giữ connection keep-alive, và vẫn gửi request mới qua connection cũ. Từ Node 19, server.close() đóng luôn các connection đang rảnh tại lúc gọi, nhưng connection đang phục vụ một request thì trở thành rảnh sau đó và vẫn mở. Callback của server.close() chỉ chạy khi mọi connection đã đóng.
Trên lab, chỉ gọi server.close() thì 5 request đang dở vẫn thành công, nhưng process không bao giờ thoát: sau 8 giây vẫn chạy, vì các connection keep-alive còn mở tới hết keepAliveTimeout. Thêm closeIdleConnections() một lần lúc nhận SIGTERM cũng không đủ, vẫn phải chờ tới timeout ép thoát. Cách chạy đúng là đóng tiếp mỗi connection ngay khi nó trả lời xong:
js
let shuttingDown = false
server.on('request', (req, res) => {
if (shuttingDown) res.setHeader('Connection', 'close') // request đến trong lúc shutdown
res.on('finish', () => { if (shuttingDown) setImmediate(() => server.closeIdleConnections()) })
})
process.on('SIGTERM', () => {
shuttingDown = true
server.close(async () => {
await pool.end() // đóng connection database
process.exit(0)
})
server.closeIdleConnections()
setTimeout(() => process.exit(1), 10_000).unref() // ép thoát nếu quá 10 giây
})Với cách này, process trên lab thoát sau khoảng 600 ms, đúng lúc request dài nhất chạy xong, và cả 5 request đều thành công. Thư viện như http-terminator hay @godaddy/terminus đóng gói sẵn logic này. Timeout ép thoát phải ngắn hơn grace period của nền tảng, để process tự thoát gọn thay vì bị SIGKILL.
Load balancer cập nhật chậm hơn SIGTERM
Phần còn lại nằm ngoài process. Khi Kubernetes xoá một pod, nó đồng thời gửi SIGTERM và gỡ pod khỏi danh sách endpoint của Service. Việc gỡ endpoint phải lan tới kube-proxy, ingress, load balancer, nên mất vài giây. Trong khoảng đó, request mới vẫn được gửi tới process đã ngừng nhận connection, và bị từ chối.
Cách xử lý phổ biến là preStop hook cho container ngủ vài giây trước khi SIGTERM được gửi, để load balancer kịp ngừng gửi:
yaml
lifecycle:
preStop:
exec:
command: ["sleep", "5"]Thời gian preStop tính vào grace period, nên tổng thời gian ngủ cộng chờ request phải nhỏ hơn grace period. Readiness probe trả lỗi ngay khi bắt đầu shutdown cũng giúp các load balancer dựa vào health check (Scale Node.js).
Xử lý SIGTERM
40 / 60request lỗi
0,8 sprocess thoát sau khi pod bị xoá
request thành côngđến sau SIGTERM qua connection keep-alive cũbị từ chối hoặc bị cắt ngang
Process xử lý shutdown đúng, nhưng trong 2,0 s từ lúc ngừng nhận connection tới lúc load balancer ngừng gửi, 40 request mới bị từ chối. Đặt preStop sleep ít nhất bằng thời gian lan truyền (2 s) để SIGTERM chỉ tới khi không còn request mới.
20 request mỗi giây đến đều; grace period mặc định 30 giây của Kubernetes. Thời gian load balancer cập nhật là giả định, tuỳ hạ tầng.
Phía client cũng nên retry an toàn với request idempotent khi gặp ECONNRESET. Trên lab, một request đến đúng lúc server đóng connection keep-alive vẫn có thể nhận ECONNRESET, vì client vừa ghi vào một connection mà server đang đóng.
Error class và mã lỗi
Lỗi tự định nghĩa nên có tên, mã lỗi ổn định để code và client so sánh, và nguyên nhân gốc:
js
class AppError extends Error {
constructor(message, { code, status = 500, cause, details } = {}) {
super(message, { cause }) // cause: lỗi gốc, giữ lại stack trace của nó
this.name = this.constructor.name
this.code = code
this.status = status
this.details = details
}
}
class OrderNotFoundError extends AppError {
constructor(id, opts) { super(`Không tìm thấy đơn ${id}`, { code: 'ORDER_NOT_FOUND', status: 404, ...opts }) }
}
try { /* … */ } catch (e) { throw new OrderNotFoundError(42, { cause: e }) }Một số quy tắc:
- So sánh bằng
codehoặcinstanceof, không somessage, vì message có thể đổi hay được dịch. - Dùng
cause(Node 16.9+) thay vì nối message của lỗi gốc vào chuỗi. Logger như pino in được cả chuỗicause. - Không nuốt lỗi.
catch (e) {}rỗng haycatch (e) { console.log(e) }rồi chạy tiếp như không có gì là cách nhanh nhất để lỗi biến mất và dữ liệu sai âm thầm. Nếu thật sự bỏ qua được thì ghi rõ lý do trong code. - Không trả chi tiết lỗi nội bộ cho client. Stack trace, câu SQL, đường dẫn file chỉ ghi vào log. Client nhận
codevà một message chung, kèm một request id để tra log. - Chỉ
throwđối tượngError(hay lớp con), không throw chuỗi hay object thường, vì chúng không có stack trace.
Ghi nhớ
- Lỗi vận hành (timeout, mất kết nối, input sai) xử lý ngay trong request; lỗi lập trình (bug) thì ghi log và để process chết.
- Không tiếp tục chạy sau
uncaughtException: trạng thái có thể đã hỏng, như connection mượn mà không trả. Chạy nhiều instance và để nền tảng khởi động lại. - Graceful shutdown khi nhận SIGTERM: ngừng nhận việc mới, chờ việc đang dở, đóng pool và consumer, thoát; ép thoát trước grace period.
server.close()không đóng connection keep-alive trở nên rảnh sau lúc gọi: đóng tiếp từng connection khi nó trả lời xong, không thì process treo tới khi bị SIGKILL.- Load balancer cập nhật chậm hơn SIGTERM: dùng preStop sleep và readiness probe. Error class có
code,cause, không trả chi tiết nội bộ cho client.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-26):
| Nguồn | Dùng cho phần |
|---|---|
| Node.js docs, Process: Warning: Using 'uncaughtException' correctly | Cơ chế thô sơ, chỉ dùng như phương án cuối; không an toàn để tiếp tục chạy bình thường |
Node.js docs, server.close(), server.closeIdleConnections() | Từ Node 19, close() đóng các connection đang rảnh trước khi trả về; tài liệu nói không cần gọi thêm closeIdleConnections(), nhưng lab cho thấy connection trở nên rảnh sau lúc gọi vẫn mở |
| Kubernetes docs, Pod lifecycle: Termination of Pods | Gửi SIGTERM kèm grace period, mặc định 30 giây |
Kiểm bằng lab: process chết khi có lỗi trong callback, pool cạn khi nuốt lỗi, bốn cách xử lý SIGTERM với client giữ keep-alive, Error có cause. Từ kiến thức chung, chưa đối chiếu lần này: việc gỡ endpoint diễn ra song song với SIGTERM và preStop tính vào grace period; http-terminator, @godaddy/terminus; cause có từ Node 16.9.
Số đo chi tiết
Kết quả chạy lab/node-xu-ly-loi (Node 24.14, hai lượt)
Lỗi lập trình trong callback (handler mượn một trong 3 connection của pool giả lập rồi gặp bug):
| Kết quả | |
|---|---|
| Không có handler | Process chết, exit code 1 |
process.on('uncaughtException', log) | Process chạy tiếp; sau 3 request lỗi, pool còn 0/3 connection rảnh, không bao giờ trả lại |
SIGTERM khi đang có 5 request (mỗi request 800 ms, gửi SIGTERM sau 200 ms; client giữ 3 connection keep-alive rảnh từ trước):
| Cách xử lý | Request đang dở | Request mới qua keep-alive sau SIGTERM | Process thoát |
|---|---|---|---|
| Không xử lý | 0/5 thành công (ECONNRESET) | ECONNREFUSED | Ngay lập tức, bị SIGTERM giết |
Chỉ server.close() | 5/5 | Vẫn được phục vụ | Không thoát sau 8 giây |
close() + closeIdleConnections() lúc nhận SIGTERM + timeout 5 s | 5/5 | Vẫn được phục vụ | Sau 5 giây, do timeout ép thoát (exit code 1) |
| Như trên + đóng từng connection khi trả lời xong | 5/5 | ECONNRESET (connection vừa bị đóng) | Sau 606–611 ms, exit code 0 |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- node-xu-ly-loi, chỉ cần Node.js, khoảng 20 giây. Xem Lab.
lab/node-xu-ly-loi/server.mjs
js
// Server HTTP cho lab/node-xu-ly-loi, chạy trong process con. Đối số: chế độ xử lý SIGTERM.
// none không xử lý SIGTERM: Node mặc định thoát ngay
// close server.close() rồi thoát khi mọi request đang chạy xong
// graceful như close, cộng closeIdleConnections() và timeout ép thoát sau 5 giây
// drain như graceful, cộng header Connection: close cho mọi response trong lúc shutdown
// /work chờ 800 ms rồi trả lời (giống một request đang gọi database).
// /crash ném lỗi trong callback sau khi đã "mượn" một connection của pool giả lập 3 connection mà chưa trả.
import http from 'node:http'
const mode = process.argv[2]
let inFlight = 0
const pool = { free: 3 }
let shuttingDown = false
const server = http.createServer((req, res) => {
// Đang shutdown: bảo client đóng connection sau response này, để connection keep-alive không giữ server lại
if (shuttingDown && mode === 'drain') res.setHeader('Connection', 'close')
// Request đã bắt đầu trước SIGTERM: khi trả lời xong, connection của nó thành rảnh, đóng luôn
if (mode === 'drain') res.on('finish', () => { if (shuttingDown) setImmediate(() => server.closeIdleConnections()) })
if (req.url === '/work') {
inFlight++
setTimeout(() => { inFlight--; res.end('ok') }, 800)
} else if (req.url === '/crash') {
pool.free-- // mượn connection
setTimeout(() => {
throw new Error('bug: đọc thuộc tính của undefined') // lỗi lập trình, connection không bao giờ được trả
}, 5)
res.end('đã nhận')
} else if (req.url === '/pool') {
res.end(String(pool.free))
} else res.end('fast')
})
server.keepAliveTimeout = 30_000
if (mode === 'swallow') process.on('uncaughtException', (e) => console.error('[đã nuốt lỗi]', e.message))
if (mode === 'close' || mode === 'graceful' || mode === 'drain') {
process.on('SIGTERM', () => {
shuttingDown = true
const t0 = performance.now()
console.error(`[SIGTERM] còn ${inFlight} request đang chạy, ngừng nhận connection mới`)
server.close(() => {
console.error(`[SIGTERM] server.close xong sau ${Math.round(performance.now() - t0)} ms, đóng pool database rồi thoát`)
process.exit(0)
})
if (mode === 'graceful' || mode === 'drain') {
server.closeIdleConnections() // keep-alive đang rảnh không giữ server lại
setTimeout(() => { console.error('[SIGTERM] quá 5 giây, ép thoát'); process.exit(1) }, 5000).unref()
}
})
}
server.listen(0, '127.0.0.1', () => process.send(server.address().port))lab/node-xu-ly-loi/run.mjs
js
// Trang nodejs/xu-ly-loi. Chỉ cần Node.js, khoảng 15 giây.
// 1. Lỗi trong callback làm process chết; nuốt bằng uncaughtException thì chạy tiếp với trạng thái hỏng
// 2. SIGTERM trong lúc có request đang chạy, ba cách xử lý, với client dùng keep-alive
// 3. Error có cause
import http from 'node:http'
import { fork } from 'node:child_process'
const log = (label, v) => console.log(`${label.padEnd(54)} ${v}`)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function start(mode) {
const child = fork(new URL('./server.mjs', import.meta.url), [mode], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
let stderr = ''
child.stderr.on('data', (d) => (stderr += d))
const port = await new Promise((r) => child.once('message', r))
const exited = new Promise((r) => child.once('exit', (code, signal) => r({ code, signal })))
return { child, port, exited, stderr: () => stderr.trim() }
}
const agent = new http.Agent({ keepAlive: true })
const get = (port, path) => new Promise((resolve) => {
const t0 = performance.now()
http.get({ host: '127.0.0.1', port, path, agent }, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => resolve({ ok: true, body: b, ms: performance.now() - t0 })) })
.on('error', (e) => resolve({ ok: false, err: e.code || e.message, ms: performance.now() - t0 }))
})
console.log('\n=== 1. Lỗi lập trình trong callback ===')
for (const mode of ['default', 'swallow']) {
const s = await start(mode)
await get(s.port, '/crash')
await sleep(50)
const alive = s.child.exitCode === null
let detail = ''
if (alive) {
for (let i = 0; i < 2; i++) { await get(s.port, '/crash'); await sleep(20) }
detail = `vẫn chạy; sau 3 request lỗi pool còn ${(await get(s.port, '/pool')).body}/3 connection rảnh, mãi không trả lại`
s.child.kill()
} else detail = `process chết, exit code ${(await s.exited).code}`
log(mode === 'default' ? 'Mặc định (không có handler)' : "process.on('uncaughtException', log)", detail)
}
console.log('\n=== 2. SIGTERM khi đang có 5 request /work (mỗi request 800 ms) ===')
for (const mode of ['none', 'close', 'graceful', 'drain']) {
agent.destroy()
const s = await start(mode)
// mở sẵn 3 connection keep-alive rảnh, như client hay load balancer giữ connection
await Promise.all([get(s.port, '/'), get(s.port, '/'), get(s.port, '/')])
const reqs = Array.from({ length: 5 }, () => get(s.port, '/work'))
await sleep(200)
const t0 = performance.now()
s.child.kill('SIGTERM')
const res = await Promise.all(reqs)
const after = await get(s.port, '/')
const ex = await Promise.race([s.exited, sleep(8000).then(() => ({ code: 'vẫn chạy sau 8 s' }))])
const okN = res.filter((r) => r.ok).length
log(`Chế độ ${mode}`, `${okN}/5 request thành công${okN < 5 ? ` (lỗi ${[...new Set(res.filter((r) => !r.ok).map((r) => r.err))].join(', ')})` : ''}; request mới sau SIGTERM: ${after.ok ? 'vẫn được phục vụ' : after.err}; process thoát sau ${Math.round(performance.now() - t0)} ms, exit code ${ex.code ?? ex.signal}`)
if (s.stderr()) console.log(' ' + s.stderr().replace(/\n/g, '\n '))
if (s.child.exitCode === null) s.child.kill('SIGKILL')
}
agent.destroy()
console.log('\n=== 3. Error có cause ===')
class OrderNotFoundError extends Error {
constructor(id, options) { super(`Không tìm thấy đơn ${id}`, options); this.name = 'OrderNotFoundError'; this.code = 'ORDER_NOT_FOUND'; this.status = 404 }
}
try {
try { JSON.parse('{hỏng') } catch (e) { throw new OrderNotFoundError(42, { cause: e }) }
} catch (e) {
log('e.name, e.code, e.status', `${e.name}, ${e.code}, ${e.status}`)
log('e instanceof OrderNotFoundError, instanceof Error', `${e instanceof OrderNotFoundError}, ${e instanceof Error}`)
log('e.cause', `${e.cause.name}: ${e.cause.message}`)
}