CodeGym /Kursy /Spring REST & MVC /Obsługa wyjątków w Spring

Obsługa wyjątków w Spring

Spring REST & MVC
Poziom 20, Lekcja 1
Dostępny

1. @ExceptionHandler wewnątrz kontrolera

Lokalny try/catch pokazał już swój problem: error contract zaczyna żyć po miejscach, a happy-path tonie w obsłudze błędów. Teraz potrzebny jest następny krok – zrozumieć, jakim mechanizmem Spring w ogóle przechwytuje wyjątek zamiast ręcznego catch, i jak najpierw pokazać to na jednym kontrolerze, a potem podnieść na cały web-layer.

Wyjątek w serwisie nie oznacza tu „aplikacja runęła". To sygnał: scenariusz nie może być kontynuowany w obecnych warunkach. Spring MVC umie przechwycić taki sygnał i zamienić go w zarządzaną odpowiedź HTTP – jeśli ustawimy regułę.

Najprościej zacząć od wariantu lokalnego. @ExceptionHandler to metoda, którą Spring wywołuje, gdy podczas przetwarzania żądania wylatuje wyjątek określonego typu. Lokalny @ExceptionHandler można umieścić bezpośrednio wewnątrz @RestController i będzie stosowany do wszystkich endpointów tego kontrolera. To dobry krok edukacyjny: widzisz mechanikę w czystej postaci i od razu rozumiesz, że try/catch wewnątrz endpointu nie jest już potrzebny.

Na tym etapie nieważne jest, jak ładnie uformatujesz ProblemDetail. Ważne, że pojawia się jedna reguła: „taki wyjątek zamieniamy w taką odpowiedź HTTP". To właśnie jest początek prawdziwego „error contract", nawet jeśli na razie jest jeszcze minimalistyczny.

Kontroler bez try/catch

Najpierw po prostu zostawmy kontroler cienki, a obsługę błędu wynieśmy do osobnej metody tej samej klasy. Tak mechanika @ExceptionHandler jest widoczna bez zbędnych zakłóceń.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
// Bazowy URL dla wszystkich endpointów kontrolera
@RequestMapping("/api/v1/tasks")
class TaskController {

    private final TaskService taskService;

    // Wstrzykujemy serwis przez konstruktor (łatwiej testować i wyraźniejsze zależności)
    TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping("/{taskId}")
    // Kontroler opisuje trasę: przyjmuje wejście -> wywołuje serwis -> zwraca DTO
    // Błędów (np. TaskNotFoundException) nie łapiemy tu: obsłuży je warstwa exception-handling
    TaskDetailsResponse getById(@PathVariable String taskId) {
        return taskService.getById(taskId);
    }
}

Zwróć uwagę na ważną „pedagogiczną magię": jej tu nie ma. Kontroler po prostu mówi „daj mi zadanie po id". Jeśli zadanie nie istnieje – to wyjątek, i dalej włącza się warstwa obsługi błędów.

2. Lokalny @ExceptionHandler w kontrolerze

Teraz dodajmy do tego samego kontrolera metodę, która „tłumaczy" wyjątek na odpowiedź. Lokalnie wygląda to bardzo prosto: łapiemy TaskNotFoundException, składamy ProblemDetail, zwracamy 404.

Ten wariant nie jest ostateczną architekturą (szybko natrafimy na duplikowanie), ale do zrozumienia mechaniki jest idealny: widzisz, jak Spring „sam" podstawia handler zamiast try/catch, i to przestaje wyglądać jak czarna magia.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

// Spring wywoła tę metodę automatycznie, jeśli podczas przetwarzania żądania wylatuje TaskNotFoundException
@ExceptionHandler(TaskNotFoundException.class)
ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
    // Formujemy ciało odpowiedzi według standardu Problem Details
    ProblemDetail problem =
            ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());

    // Krótki nagłówek dla klienta (wygodny dla UI/logowania po stronie klienta)
    problem.setTitle("Task not found");

    // Jawnie zwracamy status HTTP i ciało odpowiedzi
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

Tak, tu zwracamy ResponseEntity<ProblemDetail>. To maksymalnie uczciwe: status widać gołym okiem, ciało też. Można zwrócić też po prostu ProblemDetail, ale na początku wygodniej jest trzymać status jawnie w ResponseEntity, żebyś nie miał odczucia „Spring gdzieś tam sam zgadnął".

3. Jak działa @ExceptionHandler w ogólnym zarysie

Ważne jest rozumienie tego mechanizmu na poziomie „co się dzieje w ogólnym zarysie", inaczej potem będziesz leczyć objawy zamiast przyczyny. Gdy Spring MVC wykonuje metodę kontrolera, robi to wewnątrz swojego request pipeline. Jeśli po drodze wylatuje wyjątek, Spring nie przestaje istnieć (jak czasem myśli nowicjusz), lecz próbuje znaleźć sposób na zamianę wyjątku w odpowiedź HTTP.

Schematycznie można to przedstawić tak:

flowchart TD
    A[HTTP request] --> B[DispatcherServlet]
    B --> C[Metoda kontrolera]
    C -->|ok| D[Odpowiedź DTO]
    C -->|throws Exception| E["Szukanie @ExceptionHandler"]
    E --> F[ProblemDetail + status]
    F --> G[HTTP response]
    D --> G

W tym miejscu wystarczy zapamiętać dwie rzeczy. Pierwsza: @ExceptionHandler to nie „jakiś oddzielny kod, który sam wywołujesz", lecz reguła, którą Spring stosuje automatycznie. Druga: jeśli reguły nie ma, Spring obsłuży błąd „jak umie domyślnie", a to prawie zawsze nie to, czego chcesz w API production-like.

4. Granice lokalnego @ExceptionHandler

Lokalny handler wewnątrz kontrolera to dobry edukacyjny rowerek z boczymi kółkami: nauczysz się na nim kręcić pedałami. Ale jeśli spróbujesz na nim pojechać w góry (tzn. rozwijać API), zaczną się problemy: te same błędy pojawiają się w różnych kontrolerach i albo kopiujesz handlery, albo zaczynasz budować „wspólny bazowy kontroler" (a to już osobny zwierzyniec antypatternów).

W Task Tracker API nieuchronnie będziemy mieć kilka kontrolerów. Nawet jeśli teraz zaimplementowałeś tylko TaskController, koncepcyjnie mamy jeszcze CommentController, AttachmentController, może TagController. Z punktu widzenia klienta API zupełnie normalne jest oczekiwanie, że „not found" dla zadania i „not found" dla komentarza wyglądają tak samo pod względem formy. Ale jeśli zostawisz handlery lokalnie, forma odpowiedzi łatwo się rozjedzie: gdzieś title będzie „Task not found", gdzieś „Not Found", gdzieś zapomnisz ustawić detail, i klient nagle zacznie żyć w świecie niespodzianek.

Żeby to poczuć w praktyce, porównaj dwie sytuacje: w jednym kontrolerze napisałeś problem.setTitle("Task not found"), a w innym – problem.setTitle("Not Found"). Niby drobnostka. Ale dla klienta to już różne odpowiedzi, czyli różne gałęzie obsługi, różne testy, różne bugi. I oto twoje API jest „niby jednolite", ale w praktyce ma kilka różnych języków błędów.

5. @RestControllerAdvice dla całego web-layer

Lokalny @ExceptionHandler jest przydatny, gdy raz trzeba zobaczyć mechanikę bez ręcznego try/catch. Ale gdy te same błędy zaczną pojawiać się w kilku kontrolerach, reguła musi wspiąć się na poziom całego web-layer. Na razie wystarczy jeden global advice zamiast zestawu lokalnych handlerów. Na razie bez dziedziczenia po ResponseEntityExceptionHandler, bez wspólnego helpera i bez machine-readable code: tu ważna jest sama mechanika.

Jeśli potrzebujesz jeszcze jednej analogii: lokalny @ExceptionHandler to gdy każdy sklepik sam decyduje, co robić ze skargami klientów. @RestControllerAdvice – to gdy masz jeden dział obsługi, i klient zawsze dostaje odpowiedź w jednym stylu, niezależnie od tego, do którego oddziału trafił.

Pierwszy szkic GlobalExceptionHandler

Zróbmy minimalny globalny handler. Na razie nie „projektujemy" super-idealnego error contract, nie dodajemy skomplikowanych pól i nie urządzamy festiwalu architektury. Nasz cel – pokazać mechanizm: wyjątek określonego typu stabilnie zamienia się w ProblemDetail z właściwym statusem HTTP.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
// Jeden wspólny handler wyjątków dla REST web-layer
class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    // Zamieniamy domenowy wyjątek w poprawną odpowiedź HTTP
    ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
        // detail bierzemy z wiadomości wyjątku (dla „oczekiwanych" błędów domenowych to zazwyczaj OK)
        ProblemDetail problem =
                ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());

        // title – krótka czytelna dla człowieka etykieta
        problem.setTitle("Task not found");

        // Jawnie ustawiamy 404, żeby to czytało się jak „tabela odpowiedników"
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }
}

Po tym twój TaskController pozostaje bez handlerów błędów w ogóle. Rzuca wyjątek – i „dział obsługi" (advice) go przechwytuje.

6. @ControllerAdvice i @RestControllerAdvice

Technicznie można napisać i @ControllerAdvice, a @RestControllerAdvice – to ten sam mechanizm plus @ResponseBody. Ale mamy JSON-first REST API, dlatego dalej w projekcie trzymamy @RestControllerAdvice jako główny wariant zapisu. Tak nie będziemy mieć dwóch równorzędnych wariantów tej samej warstwy obsługi błędów.

Jeśli ktoś pisze @ControllerAdvice i osobno zwraca body, to nie jest inna droga architektoniczna, lecz po prostu inna forma tej samej idei. Dla naszego projektu wygodniej od razu trzymać jeden zapis i nie rozsmarowywać podstawowego rozwiązania na dwa style.

7. Wybór handlera w Spring

Gdy tylko dodasz kilka metod @ExceptionHandler, pojawia się nieuchronne pytanie: „A jeśli pasuje kilka – kto wygra?" Tu dobra wiadomość: Spring stara się zachowywać przewidywalnie. Preferuje najbardziej konkretny (najspecyficzniejszy) handler. To znaczy, że jeśli masz handler dla TaskNotFoundException i ogólny handler dla RuntimeException, to przy TaskNotFoundException wygra pierwszy.

Tę logikę warto pamiętać, bo inaczej możesz przypadkowo „przechwycić" konkretne błędy ogólnym handlerem i stracić dokładność statusów.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    // Najbardziej specyficzny handler: zadziała dla TaskNotFoundException
    ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()));
    }

    @ExceptionHandler(RuntimeException.class)
    // „Siatka bezpieczeństwa": żeby nieoczekiwany błąd też był w kontrolowanym formacie
    ResponseEntity<ProblemDetail> handleRuntime(RuntimeException ex) {
        // Ważne: dla nieoczekiwanych błędów nie zawsze bezpieczne jest oddawanie na zewnątrz ex.getMessage()
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error"));
    }
}

Zwróć uwagę na subtelny punkt: ogólny handler na RuntimeException to „siatka bezpieczeństwa", a nie „główna ścieżka". Jest potrzebny, żeby nieoczekiwane błędy też miały kontrolowany format odpowiedzi. Ale jeśli zaczniesz wrzucać do niego wszystko (albo, co gorsza, zrobisz jeden handler na Exception i na tym zakończysz), szybko stracisz semantyczną dokładność API.

8. Weryfikacja reguł przez .http

Gdy robisz zmiany infrastrukturalne (a globalny handler błędów to właśnie infrastruktura web-layer), bardzo przydatne jest od razu sprawdzenie zachowania ręcznie prostym żądaniem. Nie dlatego, że „nie ufamy Springowi", lecz dlatego że musisz widzieć wynik oczami klienta. Inaczej możesz napisać piękny handler… który w ogóle się nie wywołuje (tak, to też się zdarza, szczególnie jeśli pomyliłeś pakiet/skanowanie/adnotację).

Poniżej prosty scenariusz .http: prosimy o nieistniejące zadanie.

### Task not found -> ProblemDetail
# Żądanie celowo na nieistniejące id: chcemy zobaczyć 404 + ProblemDetail
GET http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000
Accept: application/problem+json

Jeśli wszystko jest podpięte poprawnie, zobaczysz odpowiedź mniej więcej takiego rodzaju (konkretne pola mogą się różnić, ale idea powinna się zgadzać):

HTTP/1.1 404
Content-Type: application/problem+json
{
  "type": "about:blank",
  "title": "Task not found",
  "status": 404,
  "detail": "Task '00000000-0000-0000-0000-000000000000' not found",
  "instance": "/api/v1/tasks/00000000-0000-0000-0000-000000000000"
}

Tu ważne nie to, że type równa się about:blank (to normalny domyślny), i nie to, jak artystycznie napisałeś title. Ważne, że dostałeś przewidywalny status 404 i przewidywalne ciało w formacie Problem Details, a nie stronę HTML, nie stack trace i nie „zwykły string".

9. Typowe błędy przy obsłudze wyjątków

Gdy po raz pierwszy wdrażasz @ControllerAdvice, bardzo łatwo sprawić, że projekt stanie się „formalnie poprawny", ale niewygodny. Ważne jest pamiętanie: cel nie polega na naklejeniu jak największej liczby adnotacji, lecz na tym, żeby API pozostawało przewidywalne dla klienta, a kod – czytelny dla programisty (szczególnie dla ciebie za dwa tygodnie, gdy zapomnisz, co „genialnie" wymyśliłeś dziś).

Błąd nr 1: zostawić jeden handler na Exception i uważać, że praca zrobiona.
Technicznie „działa": łapiesz wszystko i zawsze zwracasz jakiś ProblemDetail. Ale w efekcie klient przestaje rozumieć, co się stało. 404 zamienia się w 500, konflikt reguł biznesowych zamienia się w „Unexpected error", i API traci semantyczną dokładność. Ogólny handler jest potrzebny jako siatka bezpieczeństwa, ale główne scenariusze powinny mieć własne konkretne mapowania.

Błąd nr 2: pisać logikę biznesową wewnątrz @ExceptionHandler.
Czasem pojawia się pokusa: „Skoro już złapaliśmy wyjątek, to poprawmy tu coś". Na przykład „jeśli zadanie nie znalezione – to je stwórzmy". To zamienia handler błędów w ukryte miejsce zmiany stanu, a klient dostaje nieprzewidywalne zachowanie. Handler powinien tłumaczyć błąd na odpowiedź HTTP, a nie zmieniać świat wokół.

Błąd nr 3: trzymać część reguł w kontrolerach, a część w @ControllerAdvice bez powodu.
Zdarza się, gdy projekt rośnie kawałkami: wczoraj zrobiłeś lokalny @ExceptionHandler, dziś dodałeś globalny, ale starego nie usunąłeś. W efekcie część wyjątków obsługiwana jest jednym sposobem, część innym, i znowu dostajesz rozłazenie kontraktu. W projekcie edukacyjnym lepiej wybrać jedną główną ścieżkę i trzymać się jej konsekwentnie.

Błąd nr 4: zwracać „surowe" wiadomości wyjątków dla nieoczekiwanych błędów.
Dla domenowego wyjątku wiadomość często można pokazywać klientowi (jeśli formułujesz ją „po ludzku"). Ale dla nieprzewidzianych błędów (np. NullPointerException, IllegalStateException) nie wolno oddawać na zewnątrz ex.getMessage(): tam mogą być wewnętrzne informacje, czasem nawet fragmenty danych. Dla nieoczekiwanych błędów lepiej zwracać neutralny detail w stylu „Unexpected error" i diagnozować przez logi.

Błąd nr 5: zamieniać @ControllerAdvice w składowisko z setką metod bez struktury.
Gdy wyjątków jest dużo, advice może rosnąć. To normalne. Nienormalne – gdy staje się nieczytelny. Nawet jeśli na razie nie robisz „idealnej architektury", trzymaj w głowie prostą dyscyplinę: handlery powinny czytać się jak tabela odpowiedników „wyjątek → status → ProblemDetail", bez zbędnych efektów ubocznych i bez copy-paste rozciągniętego na trzy ekrany.

1
Zadanie
Spring REST & MVC, poziom 20, lekcja 1
Niedostępne
Lokalny `@ExceptionHandler` wewnatrz kontrolera
Lokalny `@ExceptionHandler` wewnatrz kontrolera
1
Zadanie
Spring REST & MVC, poziom 20, lekcja 1
Niedostępne
Globalny `@RestControllerAdvice` dla dwoch endpointow
Globalny `@RestControllerAdvice` dla dwoch endpointow
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION