Exception handling: error propagation và bẫy nuốt lỗi
Xử lý lỗi không phải là rải try/catch khắp nơi — mà là để lỗi lan tới đúng chỗ có đủ ngữ cảnh để xử lý, và không bao giờ nuốt mất nó. Trong JavaScript, một exception ném ra sẽ trôi ngược call stack cho tới catch gần nhất; nếu không ai bắt, nó thành uncaught exception (đồng bộ) hoặc unhandled rejection (async). Thiết kế error handling tốt là quyết định bắt ở đâu và bắt cái gì, với một global handler làm lưới an toàn cuối cùng. Bắt quá sớm hoặc quá rộng — catch rồi im lặng — là cách chắc chắn nhất để biến một bug thành một bí ẩn.
Cơ chế hoạt động
Exception lan theo call stack: nó dừng việc thực thi hàm hiện tại và nhảy lên hàm gọi, tiếp tục cho tới khi gặp try/catch hoặc thoát khỏi stack. finally luôn chạy dù có lỗi hay không — dùng để dọn tài nguyên (đóng connection, release lock).
async function processOrder(id) {
const conn = await pool.acquire()
try {
return await chargeAndShip(conn, id) // lỗi ở đây lan lên caller
} finally {
conn.release() // luôn chạy, kể cả khi throw
}
}
Điểm cốt lõi: hàm này không bắt lỗi nghiệp vụ — nó chỉ đảm bảo dọn conn rồi để lỗi lan lên nơi biết cách phản hồi (trả HTTP 500, retry, hay báo người dùng). Bắt ở đây mà không có cách xử lý đúng nghĩa là che lỗi.
Vấn đề gặp trong production
Failure mode: nuốt exception. Lỗi tệ nhất trong error handling là catch rồi không làm gì, hoặc chỉ log qua loa rồi tiếp tục như không có chuyện gì:
try {
await saveToDb(record)
} catch (e) {
console.log('lỗi') // BUG: nuốt lỗi, mất stack, code chạy tiếp với dữ liệu chưa lưu
}
return { ok: true } // trả thành công dù đã fail
Đoạn này báo thành công trong khi dữ liệu không được lưu — một loại bug âm thầm cực kỳ đắt vì nó phá tính đúng đắn mà không để lại dấu vết. Quy tắc: chỉ catch khi thật sự xử lý được lỗi (retry, fallback có nghĩa, chuyển thành lỗi nghiệp vụ rõ ràng); nếu không, để nó lan lên. Khi cần thêm ngữ cảnh, ném lại kèm nguyên nhân gốc thay vì nuốt:
catch (cause) {
throw new Error(`saveToDb thất bại cho record ${record.id}`, { cause })
}
Failure mode: bắt quá rộng che bug. Một try bọc cả khối lớn rồi catch chung sẽ nuốt cả những lỗi lập trình (gọi nhầm hàm, undefined is not a function) lẫn lỗi nghiệp vụ, làm cả hai trông như nhau. Bọc hẹp quanh đúng thao tác có thể lỗi, để lỗi lập trình lan lên global handler và lộ ra.
Lưới an toàn cuối: global handler. Mọi service phải có handler cho lỗi lọt lưới, để log đầy đủ rồi thoát có kiểm soát thay vì chết câm:
process.on('uncaughtException', (err) => { logger.fatal(err); shutdownGracefully() })
process.on('unhandledRejection', (err) => { logger.fatal(err); shutdownGracefully() })
// Express: error middleware (err, req, res, next) đặt cuối cùng
Trong Express, một error middleware đặt sau cùng tập trung việc chuyển exception thành response nhất quán — không lặp try/catch trong từng route.
Cách debug và monitor
Một service "thỉnh thoảng làm sai mà không có log lỗi" gần như luôn đang nuốt exception ở đâu đó — tìm các catch chỉ console.log hoặc catch {} rỗng. Giữ stack trace bằng { cause } khi ném lại; log phải in cả error.stack và error.cause, không chỉ error.message. Theo dõi tỷ lệ uncaught/unhandled qua công cụ error tracking (Sentry và tương tự) — một đỉnh đột biến chỉ ra một lớp lỗi mới. Bật rule lint cấm catch rỗng. Với uncaughtException, sau khi log nên thoát process (state đã có thể hỏng) và để process manager khởi động lại, thay vì cố chạy tiếp.
Tradeoff
Bắt lỗi sớm và cục bộ cho phép xử lý tinh (retry đúng chỗ, fallback có nghĩa) nhưng dễ dẫn tới che bug nếu bắt mà không xử lý thật. Để lỗi lan lên global handler giữ cho luồng lỗi đơn giản và không mất dấu, nhưng mất khả năng phục hồi tại chỗ. Quy tắc thực tế: chỉ catch khi có hành động xử lý thực sự; mọi trường hợp khác để lan lên; luôn có global handler làm lưới cuối; và khi thêm ngữ cảnh thì ném lại kèm { cause } chứ không nuốt. finally để dọn tài nguyên, tách khỏi việc quyết định xử lý lỗi.
Câu hỏi phỏng vấn
Error propagation hoạt động thế nào, và vì sao "bắt quá nhiều" lại nguy hiểm?
Một exception ném ra sẽ lan ngược call stack, dừng từng hàm và nhảy lên caller, cho tới khi gặp try/catch gần nhất; nếu không ai bắt thì thành uncaught exception (đồng bộ) hoặc unhandled rejection (async) và rơi vào global handler. "Bắt quá nhiều" nguy hiểm vì khi catch mà không thật sự xử lý — chỉ log qua loa rồi chạy tiếp — code mất stack trace, có thể báo thành công dù đã fail, và che cả lỗi lập trình lẫn lỗi nghiệp vụ thành một, biến bug thành bí ẩn không dấu vết. Điểm ăn điểm: chỉ catch khi xử lý được thật (retry/fallback/chuyển thành lỗi nghiệp vụ), còn lại để lan lên; dùng { cause } khi ném lại để giữ nguyên nhân gốc; có global handler (uncaughtException/unhandledRejection, error middleware) làm lưới an toàn và finally để dọn tài nguyên.
Hands-on
Lấy một luồng xử lý thật có nhiều bước (ví dụ nhận đơn → trừ kho → tính tiền → ghi DB) và cố tình bọc toàn bộ trong một try/catch chỉ console.log rồi trả { ok: true }; tạo lỗi ở bước ghi DB và quan sát hệ thống báo thành công dù dữ liệu sai. Refactor: bỏ chỗ nuốt lỗi, để lỗi lan lên một error middleware tập trung chuyển thành response nhất quán, dùng finally để release connection, và ném lại kèm { cause } ở các tầng cần thêm ngữ cảnh. Cuối cùng đăng ký uncaughtException/unhandledRejection, ép một lỗi lọt lưới, và xác nhận nó được log đầy đủ kèm stack rồi process thoát có kiểm soát.
Top comments (0)