Filtr z poprzedniej lekcji czeka gotowy, ale ma co przechwytywać? W serwisie stoi
throw new Error('nie znaleziono tributu'). Filtr dostaje obiekt, o którym wie tylko tyle, że jest błędem - żeby rozpoznać, o co chodziło, musiałby czytać treść komunikatu. A gdy ktoś poprawi literówkę w tym zdaniu, obsługa przestanie działać.Rzymski urzędnik nie opisywał kryzysu zdaniem. Wpisywał kod sprawy, wagę i czy da się zaradzić na miejscu - i dopiero po tych polach wiadomo było, kto ma się tym zająć. Tak samo działa dobrze zbudowany wyjątek.
Pierwsza decyzja bywa myląca, bo NestJS ma własną klasę
HttpException. Kusi, by z niej skorzystać - przecież już wie o kodach HTTP.To pułapka.
HttpException należy do warstwy transportowej, a Twoje wyjątki opisują dziedzinę: brakujący tribut, przekroczony limit legionu, nieopłacony żołd. Gdyby serwis rzucał HttpException, wiedziałby o istnieniu HTTP - a wtedy nie użyjesz go w zadaniu wsadowym ani w konsumencie kolejki, gdzie żadnego HTTP nie ma. Pamiętasz regułę z poprzedniej lekcji: to filtr tłumaczy wyjątki wewnętrzne na język HTTP. Serwis ma tylko powiedzieć, co się stało.Dlatego dziedziczymy po
- wbudowanej klasie JavaScriptu. Uwaga na dwie nazwy, które brzmią prawdopodobnie, ale nie istnieją: Error
i NestException
. Ta druga pochodzi z Javy i C#; w JavaScripcie jej nie ma.RuntimeException
Wspólny przodek wszystkich naszych wyjątków wygląda tak:
1export abstract class LegionaryException extends Error {
2 constructor(
3 message: string,
4 public readonly code: string,
5 public readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL',
6 public readonly recoverable: boolean = false,
7 public readonly context: Record<string, unknown> = {},
8 ) {
9 super(message);
10 this.name = this.constructor.name;
11
12 Error.captureStackTrace(this, this.constructor);
13 }
14
15 toJSON() {
16 return {
17 name: this.name,
18 code: this.code,
19 severity: this.severity,
20 recoverable: this.recoverable,
21 context: this.context,
22 };
23 }
24}Klasa jest abstrakcyjna, bo nikt nie powinien rzucać jej wprost - służy tylko za wspólny przodek. Dzięki temu w filtrze wystarczy
@Catch(LegionaryException), żeby złapać wszystkie wyjątki dziedzinowe naraz.Prześledźmy cztery pola, bo każde odpowiada na inne pytanie:
to stały identyfikator sprawy, na przykład code
'TRIBUTE_NOT_FOUND'. To po nim rozpoznajesz błąd w kodzie i w logach - nigdy po treści komunikatu, która bywa tłumaczona i poprawiana.
mówi, jak poważna jest sytuacja. Cztery poziomy od najmniej do najbardziej krytycznego: LOW to drobne problemy, MEDIUM - problemy wymagające uwagi, HIGH - poważne zagrożenie, CRITICAL - awaria krytyczna. Po tym polu system alarmowy decyduje, czy zapisać wpis w logu, czy obudzić kogoś telefonem.severity
odpowiada na pytanie, czy błąd da się naprawić automatycznie. Chwilowy brak połączenia z bazą jest naprawialny - warto ponowić. Brakujący tribut nie jest: ponawianie nic nie da, bo za sekundę też go nie będzie. To pole steruje mechanizmem ponowień, żeby nie próbował w nieskończoność rzeczy z góry przegranych.recoverable
niesie dane towarzyszące - identyfikator tributu, nazwę legionu, cokolwiek pomoże zrozumieć sytuację przy czytaniu logu. Typ context
Record<string, unknown> to zwykły obiekt o dowolnych kluczach.Metoda
toJSON() zwraca wyjątek w postaci gotowej do zapisania w logu. Zauważ, czego w niej nie ma: śladu stosu. To celowe - stos przydaje się przy debugowaniu, ale w logu zbieranym z produkcji zajmowałby dziewięć dziesiątych miejsca.Jedna linia zasługuje na osobne wyjaśnienie:
1Error.captureStackTrace(this, this.constructor);Bez niej ślad stosu zaczynałby się wewnątrz konstruktora wyjątku - czyli w miejscu, które Cię nie interesuje, bo napisałeś je raz i działa. Drugi argument mówi: pomiń ten konstruktor i wszystko powyżej, zacznij ślad od miejsca, w którym wyjątek naprawdę rzucono.
To drobiazg, który przy czytaniu logów oszczędza sporo mrużenia oczu. Sama linia niczego nie szyfruje, nie kasuje ani nie wysyła - jedynie ustawia początek śladu.
Mając przodka, poszczególne wyjątki są krótkie:
1export class TributeNotFoundException extends LegionaryException {
2 constructor(tributeId: string) {
3 super(
4 `Tribute ${tributeId} not found`,
5 'TRIBUTE_NOT_FOUND',
6 'MEDIUM',
7 false,
8 { tributeId },
9 );
10 }
11}Kolejność jest zawsze ta sama: deklaracja klasy,
extends LegionaryException, konstruktor z danymi potrzebnymi do opisania sprawy, a w nim super(...) wypełniające pola przodka.Zwróć uwagę, co zyskujesz w miejscu użycia. Zamiast
throw new Error('nie znaleziono tributu ' + id) piszesz throw new TributeNotFoundException(id) - krócej, a filtr rozpozna typ przez instanceof bez czytania komunikatu. Identyfikator ląduje w context, więc log powie, o który tribut chodziło.To polecam jako regułę, @name: jeden wyjątek na jedną sytuację dziedzinową, z kodem i wagą nadanymi raz, w konstruktorze. Wtedy w serwisie nie ma już decyzji do podjęcia - rzucasz właściwą klasę i idziesz dalej.
Kryzysy mają kody spraw, nie zdania:
Error - wbudowanej klasie JavaScriptu,HttpException, bo należy ona do warstwy transportowej, a serwis nie powinien wiedzieć o HTTP; NestException i RuntimeException w ogóle nie istnieją,@Catch(LegionaryException) łapie całą rodzinę,code to stały identyfikator sprawy - rozpoznawaj błędy po nim, nie po treści komunikatu,severity od najmniej do najbardziej krytycznego: LOW → MEDIUM → HIGH → CRITICAL,recoverable mówi, czy błąd da się naprawić automatycznie - steruje ponowieniami,context niesie dane pomocne przy czytaniu logu, toJSON() przygotowuje wpis bez śladu stosu,Error.captureStackTrace(this, this.constructor) ustawia początek śladu na miejscu rzucenia, pomijając konstruktor - niczego nie szyfruje ani nie kasuje,extends po bazowej, konstruktor, super(...) z kodem i wagą.W następnej lekcji zajmiemy się tym, co dzieje się z tymi wyjątkami dalej - jak zapisywać je w kronice niepowodzeń, żeby dało się z niej cokolwiek wyczytać. A na razie zapamiętaj: wyjątek to formularz sprawy z kodem, wagą i notatką - a nie zdanie, które ktoś kiedyś poprawi.