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

Custom Exceptions - specjalne sytuacje

5 min czytania
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; NestException i RuntimeException w 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ę,
  • 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,
  • konkretny wyjątek: klasa, 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.

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');
112

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. Po jakiej klasie powinna dziedziczyć bazowa klasa Custom Exception?

  2. 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'

Przydatne artykuły