Bỏ qua để đến nội dung
Knowledge Hub

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

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ỗ đó?

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

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ủa A hoặc B. @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ùng switchToRpc(), switchToWs().
  • Phạm vi: method, class (@UseFilters()) hoặc global.

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:

Lỗi 23505 đi qua filter route, controller rồi global; lỗi middleware chỉ tới global; lỗi listener async không tới filter nào
Lỗi 23505 đi qua filter route, controller rồi global; lỗi middleware chỉ tới global; lỗi listener async không tới filter nàoBước 1/6: Handler gọi INSERT, Postgres trả lỗi code 23505. Exception đi lên ExceptionsHandler của route.Filter, xét từ thấplên caoHandlerPOST /usersthrowMiddlewareTenantMiddlewareListener asynchoặc setTimeoutFilter route@UseFilters trên methodFilter controller@UseFilters trên classFilter globalProblemDetailsFilterBuilt-in handlerBaseExceptionFilterHTTP responsestatus + bodyprocessunhandledRejectionDatabaseError 23505
Handler → Filter routeDatabaseError 23505
Bước 1/6

Handler 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.

  • 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. Filter extends BaseExceptionFilter cũng được inject HttpAdapterHost tự động.
  • Dùng BaseExceptionFilter với useGlobalFilters thì phải truyền adapter: new X(app.get(HttpAdapterHost).httpAdapter). Ở method/controller, truyền class (@UseFilters(X)), không new.
  • 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-express 11.2.6.

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ỗi
export 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):

Terminal window
npx --prefix examples ts-node examples/src/nestjs-exception-filters/errors-demo.ts
Output 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-8
POST /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:

  1. Ba kiểu lỗi giờ cùng application/problem+json. Lỗi pg là DatabaseError; filter nhận diện bằng code.
  2. /legacy/stats: controller có LegacyFilter nhưng lỗi ném từ middleware vẫn tới filter global. Đã kiểm tra, khớp tài liệu.
  3. /legacy/boom: @Catch(HttpException) không khớp TypeError, lỗi rơi xuống global.
  4. /users-with-audit trả 201: listener async reject và setTimeout né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.

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.

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 parse message.
  • Log 5xx thố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.
  1. Đăng ký filter @Catch() global qua APP_FILTER để có DI cho logger và config.
  2. Mọi response lỗi đặt Content-Type: application/problem+json; test bằng HTTP thật cả status, header, body.
  3. Map lỗi DB theo code + constraint; lỗi không nhận ra thì 500 với detail chung.
  4. Log 5xx kèm request id và stack; trả request id qua instance hoặc header.
  5. Không đưa exception.message của lỗi không phải HttpException vào body.
  6. Bọc try/catch trong listener, timer, consumer; đăng ký unhandledRejection chỉ để log rồi dừng có kiểm soát (graceful shutdown).

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, SQL

Nên viết

logger.error({ requestId, err: e }); // chi tiết ở log
res.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
  1. Controller có @UseFilters(A) với @Catch(HttpException), global có @Catch() B. Handler ném TypeError: ai trả response?
  2. Vì sao lỗi ném trong middleware không tới filter controller?
  3. Không có filter tuỳ biến, lỗi 23505 trả body gì, và log ở đâu?
  4. Handler gọi events.emit() với listener đồng bộ ném lỗi: response là gì? Nếu listener là async?