Kurs NestJS · Moduł 6: Obsługa błędów i monitoring
Custom Exceptions - specjalne sytuacje
W tej lekcji5
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.
Po czym dziedziczyć
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 Error - wbudowanej klasie JavaScriptu. Uwaga na dwie nazwy, które brzmią prawdopodobnie, ale nie istnieją: NestException i RuntimeException. Ta druga pochodzi z Javy i C#; w JavaScripcie jej nie ma.
Klasa bazowa
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:
code to stały identyfikator sprawy, na przykład '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.
severity 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.
recoverable 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.
context niesie dane towarzyszące - identyfikator tributu, nazwę legionu, cokolwiek pomoże zrozumieć sytuację przy czytaniu logu. Typ 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.
captureStackTrace - czysty ślad
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.
Konkretny wyjątek
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łę: 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.
Podsumowanie
Kryzysy mają kody spraw, nie zdania:
- wyjątki dziedzinowe dziedziczą po
Error- wbudowanej klasie JavaScriptu, - nie po
HttpException, bo należy ona do warstwy transportowej, a serwis nie powinien wiedzieć o HTTP;NestExceptioniRuntimeExceptionw ogóle nie istnieją, - to filtr tłumaczy wyjątek na kod HTTP - wyjątek mówi tylko, co się stało,
- klasa bazowa jest abstrakcyjna, dzięki czemu
@Catch(LegionaryException)łapie całą rodzinę, codeto stały identyfikator sprawy - rozpoznawaj błędy po nim, nie po treści komunikatu,severityod najmniej do najbardziej krytycznego: LOW → MEDIUM → HIGH → CRITICAL,recoverablemówi, czy błąd da się naprawić automatycznie - steruje ponowieniami,contextniesie 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,- konkretny wyjątek: klasa,
extendspo 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.
Kod do tej lekcji: src/exceptions/custom-exceptions.ts
1// Custom Exceptions - Specjalne Sytuacje Rzymskie
2// Wlasna hierarchia wyjatkow dla Imperium
3import { HttpException, HttpStatus } from '@nestjs/common';
4
5// ===========================================
6// 1. Bazowy wyjatek Imperium
7// ===========================================
8
9export abstract class LegionaryException extends HttpException {
10 public readonly code: string;
11 public readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
12 public readonly recoverable: boolean;
13
14 constructor(
15 code: string,
16 message: string,
17 status: HttpStatus,
18 severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' = 'MEDIUM',
19 recoverable: boolean = true,
20 ) {
21 super(
22 {
23 code,
24 message,
25 severity,
26 recoverable,
27 timestamp: new Date().toISOString(),
28 },
29 status,
30 );
31 this.code = code;
32 this.severity = severity;
33 this.recoverable = recoverable;
34 }
35}
36
37// ===========================================
38// 2. Konkretne wyjatki
39// ===========================================
40
41// Legionariusz nie znaleziony
42export class LegionaryNotFoundException extends LegionaryException {
43 constructor(legionaryId: string) {
44 super(
45 'LEGIONARY_NOT_FOUND',
46 'Legionariusz ' + legionaryId + ' nie istnieje w rejestrze!',
47 HttpStatus.NOT_FOUND,
48 'LOW',
49 false,
50 );
51 }
52}
53
54// Brak uprawnien
55export class InsufficientRankException extends LegionaryException {
56 constructor(requiredRank: string, currentRank: string) {
57 super(
58 'INSUFFICIENT_RANK',
59 'Wymagana ranga: ' + requiredRank + ', aktualna: ' + currentRank,
60 HttpStatus.FORBIDDEN,
61 'MEDIUM',
62 false,
63 );
64 }
65}
66
67// Tribute wyczerpane
68export class TributeDepletedException extends LegionaryException {
69 constructor(province: string) {
70 super(
71 'TRIBUTE_DEPLETED',
72 'Prowincja ' + province + ' nie ma wiecej tributow!',
73 HttpStatus.CONFLICT,
74 'HIGH',
75 true,
76 );
77 }
78}
79
80// Blad bitwy
81export class BattleException extends LegionaryException {
82 constructor(message: string) {
83 super(
84 'BATTLE_ERROR',
85 message,
86 HttpStatus.INTERNAL_SERVER_ERROR,
87 'CRITICAL',
88 false,
89 );
90 }
91}
92
93// ===========================================
94// 3. Przyklad uzycia
95// ===========================================
96
97function findLegionary(id: string) {
98 const legionaries = ['LEG-001', 'LEG-002', 'LEG-003'];
99
100 if (!legionaries.includes(id)) {
101 throw new LegionaryNotFoundException(id);
102 }
103
104 return { id, name: 'Marcus Aurelius', rank: 'centurion' };
105}
106
107console.log('=== Custom Exceptions ===');
108console.log('LegionaryException - bazowa klasa wyjatkow');
109console.log('Pola: code, severity, recoverable, timestamp');
110console.log('Kazdy wyjatek ma konkretny HttpStatus');
111console.log('Hierarchia: base -> specific exceptions');
112Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Po jakiej klasie powinna dziedziczyć bazowa klasa Custom Exception?
2. Które pole Custom Exception informuje czy błąd można naprawić automatycznie?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Uzupełnij abstract class LegionaryException extends Error z polami: code (string), severity ('LOW'|'MEDIUM'|'HIGH'|'CRITICAL'), recoverable (boolean), context (Record) i metodą toJSON()
- Układanie w pionie
Uporządkuj poziomy severity wyjątków od najmniej do najbardziej krytycznego
- Klikanie w kolejności
Ułóż elementy definicji konkretnego Custom Exception w poprawnej kolejności
- Edytor kodu
Uzupełnij klasę TributeNotFoundException, która dziedziczy po LegionaryException i w constructor(tributeId) wywołuje super z message, code: 'TRIBUTE_NOT_FOUND' i severity: 'MEDIUM'