Skip to content

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 đúngBug 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òngKhô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ự:

  1. 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.
  2. Chờ việc đang dở chạy xong, có giới hạn thời gian.
  3. Đó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).
  4. 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á
xoá podLB ngừng gửiSIGTERM-1 s0 s1 s2 s3 s4 s5 s6 s
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 code hoặc instanceof, không so message, 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ỗi cause.
  • Không nuốt lỗi. catch (e) {} rỗng hay catch (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 code và một message chung, kèm một request id để tra log.
  • Chỉ throw đối tượng Error (hay lớp con), không throw chuỗi hay object thường, vì chúng không có stack trace.

Ghi nhớ

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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ồnDùng cho phần
Node.js docs, Process: Warning: Using 'uncaughtException' correctlyCơ 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 PodsGử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ó handlerProcess 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 SIGTERMProcess thoát
Không xử lý0/5 thành công (ECONNRESET)ECONNREFUSEDNgay lập tức, bị SIGTERM giết
Chỉ server.close()5/5Vẫn được phục vụKhông thoát sau 8 giây
close() + closeIdleConnections() lúc nhận SIGTERM + timeout 5 s5/5Vẫ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 xong5/5ECONNRESET (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}`)
}