Kurs NestJS · Moduł 6: Obsługa błędów i monitoring

Exception Filters - zarządzanie kryzysami

6 min czytania
W tej lekcji7

Legionista pyta o legion, którego nie ma. Zamiast czytelnej odmowy dostaje ścianę tekstu: nazwę klasy błędu, ślad stosu, ścieżki plików z Twojego serwera. Kolejny endpoint zwraca w podobnej sytuacji zupełnie inny kształt odpowiedzi, bo ktoś obsłużył błąd ręcznie. Klient nie wie, czego się spodziewać, a Ty właśnie pokazałeś światu budowę swojego obozu.

Rzym miał na kryzysy osobny urząd. Gdy w prowincji coś zawiodło, meldunek nie szedł do Senatu w postaci, w jakiej przyszedł z pola - urzędnik przepisywał go na jednolity formularz. W NestJS tym urzędnikiem jest Exception Filter.

Czym jest, a czym nie jest

Exception Filter to klasa przechwytująca wyjątki i formatująca odpowiedź błędu. To jedyne jego zadanie i warto je odgraniczyć od sąsiadów, bo wszystkie cztery elementy stoją na drodze żądania:

  • Middleware działa najwcześniej, na surowym żądaniu, zanim NestJS wie, dokąd ono trafi.
  • Guard decyduje, czy wpuścić - zwraca true albo false.
  • Pipe waliduje i przekształca dane wejściowe.
  • Exception Filter wkracza jako ostatni, dopiero gdy coś poszło nie tak, i decyduje, co zobaczy klient.

Trzy pierwsze pracują na drodze do metody kontrolera. Filtr pracuje na drodze powrotnej, i to tylko wtedy, gdy w tej drodze padł wyjątek.

@Catch - wybór, czym się zajmujemy

Filtr zaczyna się od dekoratora mówiącego, jakie wyjątki obsługuje:

1@Catch(HttpException)
2export class LegionHttpExceptionFilter implements ExceptionFilter {
3  catch(exception: HttpException, host: ArgumentsHost) {
4    const ctx = host.switchToHttp();
5    const response = ctx.getResponse<Response>();
6    const request = ctx.getRequest<Request>();
7
8    const status = exception.getStatus();
9
10    response.status(status).json({
11      statusCode: status,
12      timestamp: new Date().toISOString(),
13      path: request.url,
14      message: exception.message,
15    });
16  }
17}

@Catch(HttpException) zawęża filtr do jednego typu wyjątku - ten obsłuży tylko wyjątki HTTP, a wszystko inne przepuści dalej. Sam dekorator bez argumentu, @Catch(), złapałby absolutnie wszystko; to przydaje się dla filtra ostatniej szansy, ale wtedy tracisz informację o typie.

Metoda catch przyjmuje dwa argumenty. Pierwszy to sam wyjątek. Drugi, host typu ArgumentsHost, jest ciekawszy: NestJS obsługuje nie tylko HTTP, ale też WebSockety i mikroserwisy, więc host trzyma kontekst w postaci niezależnej od protokołu. host.switchToHttp() pobiera z niego kontekst HTTP, dając dostęp do obiektów Request i Response. Nie przełącza żadnego protokołu - jedynie mówi "potraktuj ten kontekst jako HTTP".

Pięć kroków obsługi

Powyższy kod wykonuje zawsze tę samą sekwencję i warto ją znać jako całość:

  1. Przechwycenie wyjątku w metodzie catch().
  2. Pobranie kontekstu HTTP przez switchToHttp().
  3. Odczytanie statusu i danych wyjątku - exception.getStatus().
  4. Sformatowanie odpowiedzi JSON - jednolity kształt dla całej aplikacji.
  5. Wysłanie odpowiedzi do klienta przez response.status(...).json(...).

Zwróć uwagę, czego w tej odpowiedzi nie ma: śladu stosu ani nazw plików. To celowe - stos loguj po stronie serwera, klientowi wysyłaj tylko to, co pomoże mu poprawić żądanie. Dodane timestamp i path kosztują nic, a przy zgłoszeniu błędu od użytkownika oszczędzają godzinę szukania.

Kody HTTP - język odmowy

Pole statusCode niesie najważniejszą informację, więc dobierz je świadomie:

  • 400 Bad Request - żądanie jest źle zbudowane, klient musi je poprawić.
  • 401 Unauthorized - nie wiadomo, kim jesteś; zaloguj się.
  • 403 Forbidden - wiadomo, kim jesteś, i nie wolno Ci.
  • 404 Not Found - zasobu nie znaleziono.
  • 500 Internal Server Error - zawiódł serwer, nie klient.

Granica między 4xx a 5xx jest tu istotna: czwórki mówią „popraw żądanie", piątki mówią „to nasza wina". Zwracanie 500 przy błędnych danych wejściowych wysyła klienta na poszukiwanie usterki, której u niego nie ma.

Zasięg - gdzie postawić urzędnika

Ten sam filtr można umieścić na trzech poziomach, od najwęższego do najszerszego:

1// 1. Na metodzie kontrolera - najwęziej
2@Get(':id')
3@UseFilters(LegionHttpExceptionFilter)
4findOne(@Param('id') id: string) { }
5
6// 2. Na klasie kontrolera - wszystkie jego metody
7@Controller('legions')
8@UseFilters(LegionHttpExceptionFilter)
9export class LegionsController { }
10
11// 3. Globalnie - cała aplikacja
12app.useGlobalFilters(new LegionHttpExceptionFilter());

Reguła jest prosta: im niżej postawisz filtr, tym węższy jego zasięg. Filtr na metodzie obsłuży tylko ją, na klasie - wszystkie metody kontrolera, globalny - całą aplikację.

Zapamiętaj tę kolejność, bo wraca przy guardach, pipe'ach i interceptorach - w NestJS wszystkie cztery stosuje się tak samo. W praktyce jeden filtr globalny nadaje wspólny kształt odpowiedzi, a filtry węższe dokładają obsługę specyficzną dla wycinka aplikacji.

Jeden filtr, kilka typów wyjątków

@Catch przyjmuje wiele typów naraz - przydaje się, gdy pochodzą z jednego źródła i wymagają podobnej obsługi:

1@Catch(QueryFailedError, EntityNotFoundError)
2export class DatabaseExceptionFilter implements ExceptionFilter {
3  catch(exception: Error, host: ArgumentsHost) {
4    const response = host.switchToHttp().getResponse<Response>();
5
6    const status =
7      exception instanceof EntityNotFoundError
8        ? HttpStatus.NOT_FOUND
9        : HttpStatus.BAD_REQUEST;
10
11    response.status(status).json({
12      statusCode: status,
13      message:
14        status === HttpStatus.NOT_FOUND
15          ? 'Nie znaleziono zasobu'
16          : 'Nieprawidłowe zapytanie do bazy danych',
17    });
18  }
19}

Oba wyjątki pochodzą z TypeORM, ale znaczą co innego, więc wewnątrz catch rozróżniamy je przez instanceof. EntityNotFoundError to 404 - klient poprosił o coś, czego nie ma. QueryFailedError to 400, bo zwykle wynika z danych, które klient przysłał.

I tu widać drugą wartość filtra, poza jednolitym kształtem: tłumaczy wyjątki wewnętrzne na język HTTP. Klient nie musi wiedzieć, że używasz TypeORM - dostaje kod i komunikat, które coś dla niego znaczą. Trzymaj się tej granicy: nazwy klas wyjątków z Twojego kodu nie powinny nigdy wyjść na zewnątrz.

Podsumowanie

Urząd kryzysowy działa, meldunki mają jeden formularz:

  • Exception Filter to klasa przechwytująca wyjątki i formatująca odpowiedź błędu - nie Middleware, nie Guard, nie Pipe,
  • trzy tamte pracują na drodze do kontrolera, filtr - na powrotnej i tylko po wyjątku,
  • @Catch(TypWyjatku) zawęża filtr do wskazanego typu; @Catch() bez argumentu łapie wszystko,
  • host.switchToHttp() pobiera kontekst HTTP z ArgumentsHost, dając dostęp do Request i Response - niczego nie przełącza,
  • pięć kroków: przechwyć, pobierz kontekst, odczytaj status, sformatuj JSON, wyślij,
  • do klienta nie wysyłaj śladu stosu; timestamp i path w odpowiedzi oszczędzą Ci później czasu,
  • 404 to „nie znaleziono zasobu"; 4xx znaczy „popraw żądanie", 5xx - „to nasza wina",
  • zasięg od najwęższego: @UseFilters na metodzie, @UseFilters na klasie, app.useGlobalFilters() globalnie,
  • @Catch przyjmuje wiele typów; wewnątrz rozróżniasz je przez instanceof i mapujesz na właściwe kody HTTP,
  • filtr tłumaczy wyjątki wewnętrzne na język HTTP - nazwy Twoich klas nie powinny wychodzić na zewnątrz.

W następnej lekcji nauczysz się tworzyć własne wyjątki, żeby mieć co przechwytywać - z czytelną hierarchią zamiast garści throw new Error. A na razie zapamiętaj: filtr to urzędnik przepisujący meldunek na jednolity formularz - klient dostaje kod i komunikat, a budowa obozu zostaje w obozie.

Kod do tej lekcji: src/filters/exception-filters.ts
1// Exception Filters - Zarzadzanie Kryzysami w Imperium
2// Przechwytywanie i formatowanie bledow
3import {
4  ExceptionFilter,
5  Catch,
6  ArgumentsHost,
7  HttpException,
8  HttpStatus,
9  Logger,
10} from '@nestjs/common';
11import { Request, Response } from 'express';
12
13// ===========================================
14// 1. Podstawowy HttpException Filter
15// ===========================================
16
17@Catch(HttpException)
18export class LegionHttpExceptionFilter implements ExceptionFilter {
19  private logger = new Logger('LegionExceptionFilter');
20
21  catch(exception: HttpException, host: ArgumentsHost) {
22    const ctx = host.switchToHttp();
23    const response = ctx.getResponse<Response>();
24    const request = ctx.getRequest<Request>();
25    const status = exception.getStatus();
26    const exceptionResponse = exception.getResponse();
27
28    // Formatowanie odpowiedzi bledow - styl Imperium
29    const errorResponse = {
30      statusCode: status,
31      timestamp: new Date().toISOString(),
32      path: request.url,
33      method: request.method,
34      message: typeof exceptionResponse === 'string'
35        ? exceptionResponse
36        : (exceptionResponse as any).message,
37      error: HttpStatus[status] || 'Unknown Error',
38    };
39
40    this.logger.error(
41      'Kryzys [' + status + '] ' + request.method + ' ' + request.url
42      + ': ' + errorResponse.message
43    );
44
45    response.status(status).json(errorResponse);
46  }
47}
48
49// ===========================================
50// 2. Globalny filter - lapie WSZYSTKIE wyjatki
51// ===========================================
52
53@Catch()
54export class AllExceptionsFilter implements ExceptionFilter {
55  private logger = new Logger('AllExceptions');
56
57  catch(exception: unknown, host: ArgumentsHost) {
58    const ctx = host.switchToHttp();
59    const response = ctx.getResponse<Response>();
60    const request = ctx.getRequest<Request>();
61
62    const status = exception instanceof HttpException
63      ? exception.getStatus()
64      : HttpStatus.INTERNAL_SERVER_ERROR;
65
66    const message = exception instanceof Error
67      ? exception.message
68      : 'Nieznany blad w systemie Imperium';
69
70    this.logger.error('KRYTYCZNY BLAD: ' + message);
71
72    response.status(status).json({
73      statusCode: status,
74      timestamp: new Date().toISOString(),
75      path: request.url,
76      message: message,
77    });
78  }
79}
80
81// ===========================================
82// 3. Rejestracja filtrow
83// ===========================================
84
85// Globalnie w main.ts:
86//   app.useGlobalFilters(new AllExceptionsFilter());
87//
88// Na kontrolerze:
89//   @UseFilters(new LegionHttpExceptionFilter())
90//
91// Na metodzie:
92//   @UseFilters(LegionHttpExceptionFilter)
93
94console.log('=== Exception Filters ===');
95console.log('@Catch(HttpException) - lapie konkretny typ');
96console.log('@Catch() - lapie WSZYSTKIE wyjatki');
97console.log('host.switchToHttp() - kontekst HTTP');
98console.log('Rejestracja: global, controller, method');
99

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Czym jest Exception Filter w NestJS?

  2. 2. Co robi dekorator @Catch(HttpException) na klasie filtra?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij klasę LegionHttpExceptionFilter z @Catch(HttpException), która implementuje ExceptionFilter z metodą catch(exception, host), pobiera response i request z ArgumentsHost i zwraca JSON z statusCode, timestamp, path i message

  • Klikanie w kolejności

    Ułóż elementy obsługi wyjątku w Exception Filter w poprawnej kolejności

  • Układanie w pionie

    Uporządkuj poziomy aplikowania Exception Filter od najwęższego do najszerszego zasięgu

  • Edytor kodu

    Uzupełnij DatabaseExceptionFilter z @Catch(QueryFailedError, EntityNotFoundError), który w catch() rozróżnia typ wyjątku i zwraca odpowiedni kod HTTP (400 dla QueryFailed, 404 dla EntityNotFound)

Przydatne artykuły