Exception filter trong NestJS: một format lỗi, không lộ SQL, biết lỗi nào không tới filter Trung cấp
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”API đơn hàng và đăng ký người dùng trả lỗi theo ba kiểu. App mobile chỉ parse được kiểu đầu tiên. Output thật trên NestJS 11.2.6:
GET /orders/42 -> 404 {"message":"Order 42 not found","error":"Not Found","statusCode":404}POST /users (sai email) -> 400 {"message":["email must be an email", "..."],"error":"Bad Request","statusCode":400}POST /v0/users (trùng) -> 500 {"statusCode":500,"message":"duplicate key value violates unique constraint \"users_email_key\""}message lúc là chuỗi, lúc là mảng. Email trùng (Postgres SQLSTATE 23505) thành 500, và filter tự viết của route /v0 còn đưa nguyên câu lỗi SQL, lộ tên constraint.
Câu hỏi: đặt một chỗ nào để mọi lỗi HTTP có cùng format, email trùng thành 409, lỗi lạ không lộ chi tiết, và lỗi nào sẽ không bao giờ đi qua chỗ đó?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Mọi route cần cùng format lỗi | Filter @Catch() global qua APP_FILTER |
Filter cần DI (logger, config) |
| Controller cũ giữ body mặc định | Filter controller extends BaseExceptionFilter, gọi super.catch() |
Chỉ bắt loại lỗi cần giữ |
Lỗi DB có nghĩa nghiệp vụ (23505) |
Map ở filter global hoặc repository | Theo code + constraint, không theo message |
| Lỗi từ middleware | Chỉ filter global xử lý | Filter route/controller không được xét |
Lỗi trong listener async, setTimeout, consumer |
Tự try/catch + log trong chính callback đó |
Filter không thấy những lỗi này |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Exception filter là lớp có hàm catch(exception, host) nhận lỗi chưa được xử lý và tự ghi response. Nest luôn có sẵn một lớp xử lý mặc định (built-in exception layer).
| Lỗi ném ra | Lớp mặc định trả | Ghi chú |
|---|---|---|
HttpException hoặc lớp con (NotFoundException, ConflictException…) |
getStatus() và getResponse() |
Truyền chuỗi thì body là {message, error, statusCode} |
Object có statusCode + message (kiểu thư viện http-errors) |
Hai giá trị đó | Hỗ trợ một phần |
Mọi thứ khác (TypeError, lỗi pg) |
500 {"statusCode":500,"message":"Internal server error"} |
Có log qua Logger('ExceptionsHandler') |
Mặc định không lộ SQL; lộ là do filter tự viết trả exception.message. HttpException nhận option cause (new ConflictException('...', { cause: err })). Theo tài liệu, cause không được serialize vào response, chỉ dùng để log.
Ba khái niệm còn lại:
@Catch(A, B): filter chỉ nhận lỗi là instance củaAhoặcB.@Catch()rỗng nghĩa là bắt mọi lỗi.ArgumentsHost: wrapper đối số của handler.host.switchToHttp()trảgetRequest(),getResponse(); với microservice hay WebSocket dùngswitchToRpc(),switchToWs().- Phạm vi: method, class (
@UseFilters()) hoặc global.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Mỗi route có một ExceptionsHandler giữ danh sách filter theo thứ tự route, controller, global. Khi có lỗi, nó chọn filter đầu tiên có @Catch khớp; không filter nào khớp thì dùng xử lý mặc định. Hình đi theo lỗi 23505 của POST /users:
DatabaseError 23505Handler gọi INSERT, Postgres trả lỗi code 23505. Exception đi lên ExceptionsHandler của route.
| Bước | Ai nhận lỗi | Kết quả |
|---|---|---|
| 1 | ExceptionsHandler của route |
Bắt đầu tìm filter |
| 2 | Filter route, controller | Bỏ qua nếu không có hoặc @Catch(HttpException) không khớp |
| 3 | ProblemDetailsFilter global |
Khớp, trả 409; chỉ một filter xử lý mỗi lỗi |
| 4 | Không filter nào khớp | Mặc định: 500 và log |
| 5 | Lỗi ở middleware | Route chưa chọn, chỉ filter global |
| 6 | Listener async sau khi trả 201 |
Ngoài request, rơi ra process |
Với nhiều filter global, tài liệu khuyên khai báo filter bắt-tất-cả trước filter cụ thể, vì danh sách được duyệt ngược.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”app.useGlobalFilters(new X()): tạo bên ngoài module nên không có DI; tài liệu ghi nó không gắn filter cho gateway hay hybrid app.{ provide: APP_FILTER, useClass: X }trong bất kỳ module nào: filter global, được inject dependency. Filterextends BaseExceptionFiltercũng được injectHttpAdapterHosttự động.- Dùng
BaseExceptionFiltervớiuseGlobalFiltersthì phải truyền adapter:new X(app.get(HttpAdapterHost).httpAdapter). Ở method/controller, truyền class (@UseFilters(X)), khôngnew. - Filter tự ghi response nên tự đặt
Content-Type; mặc định làapplication/json; charset=utf-8. - Phạm vi: HTTP,
@nestjs/platform-express11.2.6.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Filter rút gọn từ demo, demo in log bằng console thay Logger (ý nghĩa các trường Problem Details xem bài RFC 9457):
@Catch() // bắt mọi lỗiexport class ProblemDetailsFilter implements ExceptionFilter { private readonly logger = new Logger('Problem'); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const req = ctx.getRequest<Request & { id: string }>(); const problem = toProblem(exception); // HttpException, 23505, còn lại 500 if (problem.status >= 500) { this.logger.error({ requestId: req.id, err: exception }); // chi tiết chỉ ở log } ctx.getResponse<Response>().status(problem.status) .type('application/problem+json') .json({ ...problem, instance: `urn:uuid:${req.id}` }); }}toProblem quyết định status và type theo thứ tự, nhánh cuối là lưới an toàn:
Đang tải diagram…
req.id do một middleware đặt (lấy x-request-id nếu hợp lệ, không thì randomUUID()); muốn đọc id ở service thì dùng AsyncLocalStorage. Chạy với Postgres thật (lỗi 23505 thật từ unique constraint, schema demo_nestjs_exceptions, xoá khi xong):
npx --prefix examples ts-node examples/src/nestjs-exception-filters/errors-demo.tsOutput thật (rút gọn instance, id cố định bằng header x-request-id)
POST /users (trùng email) [filter] global ProblemDetailsFilter (DatabaseError) -> 409 content-type: application/problem+json; charset=utf-8 {"type":"https://api.example.org/problems/email-taken","title":"Email already registered","status":409,"detail":"This email is already registered.","instance":"urn:uuid:3f6c2a8e-..."}POST /users (sai email, tên 1 ký tự) -> 422 content-type: application/problem+json; charset=utf-8 {"type":"https://api.example.org/problems/validation-error","title":"Request body is not valid","status":422,"errors":[{"pointer":"#/email","detail":"email must be an email"},{"pointer":"#/name",...}],...}GET /boom (TypeError trong handler) [log] ERROR requestId=3f6c2a8e-... TypeError: Cannot read properties of undefined (reading 'total') -> 500 content-type: application/problem+json; charset=utf-8 {"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Unexpected error. Quote the instance...",...}GET /legacy/stats (TenantMiddleware ném 400, controller có LegacyFilter) [filter] global ProblemDetailsFilter (HttpException) -> 400 {"type":"about:blank","title":"Bad Request","status":400,"detail":"Missing x-tenant header",...}GET /legacy/orders/42 [filter] controller LegacyFilter -> super.catch() -> 404 content-type: application/json; charset=utf-8 {"message":"Order 42 not found","error":"Not Found","statusCode":404}GET /legacy/boom (TypeError, LegacyFilter chỉ @Catch(HttpException)) [filter] global ProblemDetailsFilter (TypeError) -> 500 content-type: application/problem+json; charset=utf-8POST /users-with-audit [process] unhandledRejection: audit log: connection refused -> 201 content-type: application/json; charset=utf-8 {"id":5} [process] uncaughtException: welcome email: SMTP timeoutĐọc output:
- Ba kiểu lỗi giờ cùng
application/problem+json. LỗipglàDatabaseError; filter nhận diện bằngcode. /legacy/stats: controller cóLegacyFilternhưng lỗi ném từ middleware vẫn tới filter global. Đã kiểm tra, khớp tài liệu./legacy/boom:@Catch(HttpException)không khớpTypeError, lỗi rơi xuống global./users-with-audittrả201: listenerasyncreject vàsetTimeoutném lỗi sau khi handler xong. Không filter nào chạy; không cóprocess.on(...)thì Node 24 dừng process.
Lỗi không đi qua filter
Phần tiêu đề “Lỗi không đi qua filter”Filter chỉ thấy lỗi nằm trên chuỗi await (hoặc Observable) của request. Những chỗ sau nằm ngoài:
| Nguồn lỗi | Vì sao không tới filter | Cần làm |
|---|---|---|
Promise không await (void this.audit()) |
Handler đã trả, promise reject sau | await, hoặc .catch() có log |
Listener async của event emitter |
emit() không chờ promise của listener |
try/catch trong listener |
setTimeout, setInterval, cron |
Callback chạy ở tick khác, không có request | Bọc callback, log kèm id job |
| Consumer Kafka/RabbitMQ, job queue | Không có HTTP request; filter HTTP không áp dụng | Xử lý lỗi theo retry/DLQ của consumer |
Listener đồng bộ ném lỗi thì khác: emit() ném ngay trong handler nên lỗi vẫn tới filter. Chi tiết unhandledRejection xem lỗi async trong Node.js.
Trade-offs
Phần tiêu đề “Trade-offs”Một filter global
- Một chỗ quyết định format, status và việc che chi tiết lỗi.
- Client phân nhánh theo
type, không parsemessage. - Log
5xxthống nhất, có request id.
Cái giá
- Filter biết cả lỗi DB lẫn HTTP, dễ phình to.
- Đổi format là breaking change cho client đang parse body mặc định.
- Không phủ lỗi ngoài request.
Checklist production
Phần tiêu đề “Checklist production”- Đăng ký filter
@Catch()global quaAPP_FILTERđể có DI cho logger và config. - Mọi response lỗi đặt
Content-Type: application/problem+json; test bằng HTTP thật cả status, header, body. - Map lỗi DB theo
code+constraint; lỗi không nhận ra thì500vớidetailchung. - Log
5xxkèm request id và stack; trả request id quainstancehoặc header. - Không đưa
exception.messagecủa lỗi không phảiHttpExceptionvào body. - Bọc
try/catchtrong listener, timer, consumer; đăng kýunhandledRejectionchỉ để log rồi dừng có kiểm soát (graceful shutdown).
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi hay gặp nhất là filter “bắt tất cả” trả luôn message gốc:
Trả exception.message cho mọi lỗi
Hay gặp
res.status(500).json({ statusCode: 500, message: e.message }); // lộ tên constraint, SQLNên viết
logger.error({ requestId, err: e }); // chi tiết ở logres.status(500).type('application/problem+json') .json({ type: 'about:blank', title: 'Internal Server Error', status: 500, instance });Vì sao
Message của lỗi DB hay thư viện viết cho người vận hành, không phải cho client. Xem thêm secure coding.
Lỗi thứ hai liên quan thứ tự:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
useGlobalFilters(new X()) có DI |
Dependency undefined |
Dùng APP_FILTER |
cause hiện trong response |
Client không thấy | Log cause |
Filter bắt lỗi trong setTimeout |
Process crash, response vẫn 201 |
try/catch tại callback |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Controller có
@UseFilters(A)với@Catch(HttpException), global có@Catch()B. Handler némTypeError: ai trả response? - Vì sao lỗi ném trong middleware không tới filter controller?
- Không có filter tuỳ biến, lỗi
23505trả body gì, và log ở đâu? - Handler gọi
events.emit()với listener đồng bộ ném lỗi: response là gì? Nếu listener làasync?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Exception filters — NestJS: built-in layer,
APP_FILTER,BaseExceptionFilter, middleware. - Execution context — NestJS:
ArgumentsHost,switchToHttp. - Source
packages/core/exceptions:ExceptionsHandlerchọn filter khớp đầu tiên. - PostgreSQL Error Codes:
23505 unique_violation. - Lỗi API với RFC 9457: ý nghĩa từng trường Problem Details.