Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке фільтри винятків у NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Фільтри винятків (exception filters)** у NestJS перехоплюють необроблені винятки і формують HTTP-відповідь до того, як вона дійде до клієнта. Реалізуй `ExceptionFilter`, вкажи типи через `@Catch()` і реєструй глобально через `APP_FILTER`. ```typescript @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const status = exception.getStatus(); ctx.getResponse().status(status).json({ statusCode: status, path: ctx.getRequest().url, }); } } ``` **Ключове:** `@Catch()` без аргументів ловить все; `@Catch(HttpException)` - тільки NestJS HTTP-винятки.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Фільтри винятків (exception filters)** у NestJS - це класи, які перехоплюють необроблені винятки до того, як відповідь покине сервер, і дають повний контроль над статус-кодом, тілом відповіді та побічними ефектами на кшталт логування. ## Теорія ### Коротко - Уяви поштовий сортувальник: він ловить "проблемні листи" (кинуті помилки) і вирішує, що відправити натомість, а не відпускає їх безладно - Вбудований глобальний фільтр обробляє `HttpException` автоматично; кастомні фільтри дозволяють цілитись на конкретні типи, додавати логування або змінювати структуру відповіді - `@Catch(HttpException)` охоплює тільки NestJS HTTP-винятки; `@Catch()` без аргументів перехоплює все, включно з сирим `Error` - Глобальна реєстрація через `APP_FILTER` у модулі підтримує DI; `app.useGlobalFilters()` у `main.ts` - без DI - Фільтри спрацьовують після того, як виняток кинутий, але до відправки відповіді ### Швидкий приклад ```typescript // http-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch(HttpException) // охоплює HttpException та всі підкласи export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception.getStatus(); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception.getResponse(), }); } } ``` Будь-який `HttpException` тепер повертає однакову JSON-структуру з часом та шляхом запиту. Стандартний обробник NestJS для цих типів більше не спрацьовує. ### Ключова різниця NestJS має вбудований глобальний обробник, який форматує базову JSON-відповідь для підкласів `HttpException`. Кастомні фільтри перевизначають або розширюють цю поведінку: реалізуєш інтерфейс `ExceptionFilter` і через `@Catch()` вказуєш, які типи обробляти. Головна перевага - одне місце для логування, виклику Sentry або додавання власних полів без змін у кожному контролері. Це краще за `try/catch` блоки розкидані по всіх хендлерах. ### Коли використовувати - Єдиний формат помилок для всього додатку: глобальний фільтр через `APP_FILTER` - Поведінка для конкретного маршруту (наприклад, помилки `/admin` йдуть у Slack): `@UseFilters()` на контролері або методі - Тільки певні типи винятків потребують особливої обробки: `@Catch(BadRequestException)` або `@Catch(ValidationError)` - Побічні ефекти на кшталт Sentry чи Prometheus без зміни бізнес-логіки: додай їх усередину `catch()` - Страхувальна сітка для необроблених помилок: `@Catch()` без аргументів відображає все у 500 ### Як NestJS обробляє фільтри всередині Коли виняток "спливає" з контролера, NestJS перевіряє метадані декораторів `@Catch()`, зареєстровані при старті. Перший фільтр, чий аргумент типу збігається з кинутим винятком, запускає `catch()`. Усередині `ArgumentsHost` надає доступ до об'єктів `Request` і `Response` від Express. Якщо жоден кастомний фільтр не підходить, стандартний `BaseExceptionFilter` обробляє екземпляри `HttpException`. Порядок при кількох глобальних фільтрах є зворотнім до порядку реєстрації. Останній зареєстрований провайдер `APP_FILTER` спрацьовує першим для відповідного типу. Я з'ясував це на власному досвіді: фільтр-логер не виконувався до відправки відповіді, і виявилось, що широкі фільтри треба реєструвати останніми. ### Типові помилки 1. **Асинхронний `catch()`.** NestJS очікує синхронних фільтрів. Асинхронний `catch()` з `await` може залишити відповідь у підвішеному стані. ```typescript // Неправильно - відповідь зависає async catch(exception: HttpException, host: ArgumentsHost) { await sendToSlack(exception.message); // відповідь не відправляється } // Правильно - fire and forget для асинхронних ефектів catch(exception: HttpException, host: ArgumentsHost) { setImmediate(() => sendToSlack(exception.message)); // відповідь записуємо синхронно нижче } ``` 2. **Відсутність `multi: true` при ланцюжку глобальних фільтрів.** Без нього один провайдер `APP_FILTER` може замінити вбудований обробник, залишивши деякі типи помилок без запасного варіанта. ```typescript // Неправильно { provide: APP_FILTER, useClass: MyFilter } // Правильно { provide: APP_FILTER, useClass: MyFilter, multi: true } ``` 3. **`@Catch()` без аргументів скрізь.** Він перехоплює і `TypeError`, і `ReferenceError`, маскуючи реальні баги розробника як загальні 500-ті. ```typescript // Ризиковано - TypeError зникає у 500 @Catch() // Безпечніше для більшості фільтрів @Catch(HttpException) ``` 4. **Очікування стекування `@UseFilters()` на рівні контролера і методу.** Фільтр рівня методу перевизначає фільтр рівня контролера для цього endpoint. Вони не стекуються. ### Де зустрічається в реальних проектах - NestJS + Prisma: перехоплює `PrismaClientKnownRequestError` з кодом `P2002` і повертає `409 Conflict` з назвою поля-дубліката - Sentry через `nestjs-sentry`: фільтр викликає `Sentry.captureException()` перед відправкою відповіді - Валідація з `class-validator`: перехоплює `BadRequestException`, витягує помилки по полях, повертає структурований масив `errors` - GraphQL API: `@Catch(ApolloError)` формує відповіді з помилками для GQL окремо від REST - Мікросервісний gateway: логування і трейсинг у gateway; специфічні для домену деталі помилок у сервісах ### Питання для співбесіди **Q:** Як застосувати фільтр тільки до одного методу? **A:** Додай `@UseFilters(MyFilter)` безпосередньо на метод. Він перевизначить будь-який фільтр на рівні контролера або глобальний для цього конкретного endpoint. **Q:** У чому різниця між `@Catch(HttpException)` і `@Catch()`? **A:** `@Catch(HttpException)` перехоплює тільки NestJS HTTP-винятки, дозволяючи сирим `Error` проходити до вбудованого обробника. `@Catch()` без аргументів ловить все, включно з `TypeError` і `ReferenceError`. **Q:** Чи може фільтр отримати доступ до даних, встановлених guard або interceptor? **A:** Ні. Фільтри спрацьовують у фазі помилки після того, як ланцюжок виконання вже завершився. Через `ArgumentsHost` доступні тільки дані запиту. Контекст guard або interceptor на цьому етапі недоступний. **Q:** Як працює порядок фільтрів при кількох глобальних реєстраціях? **A:** NestJS інвертує порядок реєстрації під час виконання. Останній зареєстрований провайдер `APP_FILTER` спрацьовує першим для відповідного типу. Широкі фільтри реєструй останніми, інакше вони перекриють типізовані. **Q:** У мікросервісному сетапі з gateway, де розміщувати фільтри? **A:** Специфічні для домену фільтри (наприклад, `NotFoundException` з деталями сутності) - у конкретних сервісах. Наскрізні фільтри для логування, алертингу або трейсингу - у gateway. Так сервіси залишаються незалежними, а gateway відповідає за observability. ## Приклади ### Базовий: глобальний фільтр для всіх винятків Гарна відправна точка для продакшн-додатків. Перехоплює будь-яке кинуте значення і повертає однакову JSON-структуру з логуванням. ```typescript // all-exceptions.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger(AllExceptionsFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; this.logger.error(`${request.method} ${request.url}`, String(exception)); response.status(status).json({ statusCode: status, message, timestamp: new Date().toISOString(), path: request.url, }); } } // app.module.ts @Module({ providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }], }) export class AppModule {} ``` Кожен необроблений виняток тепер повертає `{ statusCode, message, timestamp, path }`. Реєстрація через `APP_FILTER` замість `useGlobalFilters()` зберігає роботу dependency injection всередині фільтра. ### Середній: фільтр валідації для signup endpoint Реальний сценарій з auth-флоу. `class-validator` кидає `BadRequestException` з масивом помилок по полях усередині тіла відповіді. Цей фільтр витягує їх у зручну структуру. ```typescript // validation.filter.ts import { Catch, ArgumentsHost, BadRequestException } from '@nestjs/common'; import { Response, Request } from 'express'; @Catch(BadRequestException) export class ValidationFilter implements ExceptionFilter { catch(exception: BadRequestException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const body = exception.getResponse() as any; response.status(400).json({ statusCode: 400, message: 'Validation failed', errors: body.message, // масив: ['email must be an email'] path: request.url, }); } } // auth.controller.ts @Controller('auth') @UseFilters(ValidationFilter) export class AuthController { @Post('signup') signup(@Body() dto: CreateUserDto) { return this.authService.signup(dto); } } ``` `POST /auth/signup` з невалідним email тепер повертає `{ statusCode: 400, message: "Validation failed", errors: ["email must be an email"] }` замість стандартного формату NestJS. ### Просунутий: маппінг помилок Prisma Перехоплення порушень обмежень на рівні бази даних і перетворення їх у правильні HTTP-відповіді. Зустрічається в будь-якому проекті NestJS + Prisma. ```typescript // prisma-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Response, Request } from 'express'; @Catch(Prisma.PrismaClientKnownRequestError) export class PrismaExceptionFilter implements ExceptionFilter { catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); // P2002 = порушення обмеження унікальності const status = exception.code === 'P2002' ? HttpStatus.CONFLICT : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception.code === 'P2002' ? `Duplicate value on: ${(exception.meta?.target as string[])?.join(', ')}` : 'Database error'; response.status(status).json({ statusCode: status, message, timestamp: new Date().toISOString(), path: request.url, }); } } ``` `POST /users` з дублікатом email тепер не повертає сирий стек-трейс Prisma або загальний 500. Клієнт отримує `{ statusCode: 409, message: "Duplicate value on: email" }`. Реєструй цей фільтр глобально або на рівні модуля залежно від того, де використовується Prisma.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.