1. Błąd API jako część kontraktu
Po granicy walidacji szybko wypływa nowy ból: scenariuszy negatywnych jest już kilka, a lokalne write...Error(...) zaczynają się rozłazić po handlerach. Na jednym endpoincie jeszcze ujdzie, na całym API – już nie. Czyli pora uznać błąd za część kontraktu i zebrać dla niego jeden kanoniczny schemat: status i body ustala się centralnie, a nie żyją w postaci rozsypanych specjalnych gałęzi. Tym właśnie się teraz zajmiemy.
Gdy piszesz lokalne HTTP API, bardzo łatwo wpaść w podejście „jeśli coś poszło nie tak – napiszemy tekstem w odpowiedzi i żyjemy dalej”. Na pierwszy rzut oka wydaje się normalne: widzisz przecież błąd w Postmanie, więc wszystko działa. Ale już po paru dniach sam znienawidzisz swoje własne API, bo różne endpointy zaczną zwracać różne formaty błędów, różne pola i różny styl komunikatów – a każdy klient (nawet twój własny Postman) zamieni się w archeologiczną wyprawę po rozszyfrowaniu „co chciał powiedzieć serwer”.
Z punktu widzenia klienta błąd to nie przypadek, tylko przewidywalny scenariusz. Klient musi zrozumieć przynajmniej trzy rzeczy: co się stało (kategoria problemu), dlaczego się stało (krótkie wyjaśnienie) i co można zrobić (szczegóły, jeśli są). I tu pojawia się myśl: robimy jeden kontrakt JSON dla błędów, tak samo obowiązkowy jak kontrakt udanych odpowiedzi. Wtedy każdy endpoint w każdej negatywnej sytuacji odpowiada tak samo co do formy: klient zawsze może wyciągnąć errorCode, przeczytać message, a w razie potrzeby – pokazać details.
Ważny niuans: wspólny error response nie oznacza „zamieniamy wszystko w 500”. Przeciwnie – uwidacznia różnicę między 400 (złe żądanie), 404 (zasób nie znaleziony), 409 (konflikt), 500 (błąd wewnętrzny). Nie mieszamy tych statusów w kaszę, tylko ładnie układamy je w jedną formę.
2. Minimalny ErrorResponse: 3 pola
Wspólny format błędów powinien być na tyle prosty, żebyś naprawdę używał go wszędzie, a nie „w teorii”. W naszym kursie ustalamy minimalną formę: errorCode, message, details. To nie „idealny światowy standard”, tylko działające minimum, które świetnie pasuje do edukacyjnego API bez frameworków i bez osobnej infrastruktury. Już daje przewidywalność, a nie zamienia się w biurokrację.
Zacznijmy od samego DTO. Najlepiej – record w pakiecie common.error, bo to naprawdę „wspólny” model dla całego API.
package com.example.readlater.common.error;
import java.util.List;
// Wspólne DTO dla błędów: ta sama struktura odpowiedzi na każdym endpoincie.
public record ErrorResponse(
// Maszynowy kod błędu: potrzebny klientom do rozgałęzień logiki (UI/SDK).
String errorCode,
// Komunikat dla człowieka: można pokazywać użytkownikowi (bez technicznych szczegółów).
String message,
// Lista uściśleń: szczególnie przydatna dla walidacji (kilka błędów naraz).
List<String> details
) {
}
Sens pól jest taki.
errorCode – to krótki „maszynowy” kod. Jest potrzebny nie tylko ludziom, ale i programom klienckim: na przykład UI może po VALIDATION_ERROR podświetlić pola, a po NOT_FOUND pokazać „obiekt nie znaleziony”. Ważne: errorCode nie zastępuje statusu HTTP, tylko go uzupełnia.
message – krótki komunikat dla człowieka, bez nadmiaru techniki. Można go pokazywać użytkownikowi (w naszym edukacyjnym API to wręcz główny sens).
details – lista uściśleń. Jest szczególnie przydatna dla walidacji: tu wkładamy „co konkretnie jest nie tak”. Dla 404 albo 500 details często będzie pustą listą.
Żeby poczuć, jak to wygląda, warto raz „zobaczyć” docelowe odpowiedzi JSON.
Przykład błędu walidacji (400):
{
"errorCode": "VALIDATION_ERROR",
"message": "Żądanie nie przeszło walidacji",
"details": [
"Pole title jest obowiązkowe",
"Pole status musi być jednym z: PLANNED, IN_PROGRESS, FINISHED"
]
}
Przykład „nie znaleziono” (404):
{
"errorCode": "NOT_FOUND",
"message": "Element listy do przeczytania nie znaleziony",
"details": []
}
Przykład konfliktu (409), na przykład po unikalności externalId:
{
"errorCode": "CONFLICT",
"message": "externalId jest już używany przez inny element",
"details": [
"externalId=OL12345M"
]
}
Przykład błędu wewnętrznego (500):
{
"errorCode": "INTERNAL_ERROR",
"message": "Błąd wewnętrzny",
"details": []
}
Zwróć uwagę na pewną „cichą dyscyplinę”: nie wstawiamy w odpowiedzi stack trace’u, nie drukujemy wewnętrznych nazw klas i nie oddajemy na zewnątrz „przypadkowych tekstów wyjątków”. Klient nie musi wiedzieć, że masz NullPointerException (choć oczywiście czasem bardzo chce się przyznać). Klientowi zależy na przewidywalnym kontrakcie, a tobie – żeby zostawić diagnostykę w logach.
3. Kategorie problemów i statusy 400/404/409/500
Teraz musimy się umówić, jakie „typy problemów” w ogóle bywają w naszym API i jak zamieniają się w statusy HTTP. Tu ważne, żeby nie komplikować życia: nasz projekt jest edukacyjny i bez BD, ale i tak chcemy uczciwie odzwierciedlać prawdziwą semantykę HTTP. Dlatego używamy bazowego zestawu: 400, 404, 409, 500. Na tym zestawie świetnie ćwiczy się myślenie „błąd żądania vs błąd serwera” i „złe dane vs brakujący zasób vs konflikt danych”.
Warto ustalić odpowiedniość w formie tabeli. Działa to prawie jak ściąga dla mózgu: zobaczyłeś problem – od razu przypomniałeś sobie, jaki status i jaki errorCode.
| Sytuacja (sens) | Przykład z ReadLater API | Status HTTP | errorCode |
|---|---|---|---|
| Klient przysłał złe dane (request nie przechodzi walidacji) | pusty title, nieznany status, nieprawidłowy JSON | 400 | VALIDATION_ERROR / MALFORMED_JSON |
| Zasób nie znaleziony | GET /api/v1/reading-list/999, a takiego id nie ma | 404 | NOT_FOUND |
| Konflikt z bieżącymi danymi | externalId musi być unikalny, ale jest już zajęty | 409 | CONFLICT |
| Wewnętrzna awaria serwera | nieoczekiwany wyjątek, bug, błąd serializacji | 500 | INTERNAL_ERROR |
Jest tu ważna myśl: ten sam status HTTP 400 może oznaczać różne rzeczy. Na przykład „JSON się nie rozparsował” i „JSON się rozparsował, ale title jest pusty” – to różne problemy, ale obie należą do „złego żądania”. Dlatego odróżniamy je przez errorCode: MALFORMED_JSON i VALIDATION_ERROR. Sprawia to, że błędy są i HTTP-poprawne, i wygodne dla klienta.
I jeszcze jedna reguła, która brzmi banalnie, ale jest ciągle łamana: 500 to nie „każdy nieprzyjemny przypadek”, tylko właśnie nieoczekiwany problem po stronie serwera. Jeśli żądanie nie przeszło walidacji, to nie jest 500, nawet jeśli ci bardzo żal.
4. Wyjątki jako „język problemów” wewnątrz kodu
Na tym etapie pojawia się zadanie: wewnątrz kodu Java musimy jakoś rozróżniać typy problemów, żeby potem prawidłowo wybrać status i ciało odpowiedzi. Najbardziej zrozumiała droga dla projektu edukacyjnego to założenie kilku własnych wyjątków. Nie „dla ozdoby”, tylko żeby handler mógł powiedzieć: „aha, to konflikt, a to nie znaleziono”.
Zacznijmy od zestawu minimalnych wyjątków. Wygodnie trzymać je w com.example.readlater.common.error, żeby były dostępne i dla odczytu, i dla zapisu, i dla innych kawałków aplikacji.
package com.example.readlater.common.error;
import java.util.List;
// Wyjątek dla błędów walidacji: trzyma listę wszystkich problemów, a nie jeden.
public class ValidationException extends RuntimeException {
private final List<String> details;
public ValidationException(List<String> details) {
// Komunikatu na zewnątrz i tak nie oddajemy, więc trzymamy właśnie details.
this.details = details;
}
// Wyciągamy szczegóły, żeby złożyć ErrorResponse.details.
public List<String> details() {
return details;
}
}
I dwa „proste” wyjątki bez szczegółów:
package com.example.readlater.common.error;
// 404: zasób nie znaleziony (żądanie poprawne, ale danych nie ma).
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
// message przydaje się do logów/diagnostyki, ale nie musi trafiać do klienta.
super(message);
}
}
package com.example.readlater.common.error;
// 409: konflikt z bieżącymi danymi (np. naruszona reguła biznesowa unikalności).
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
// message przydaje się do logów/diagnostyki, ale nie musi trafiać do klienta.
super(message);
}
}
A gdzie one pojawiają się w naszym projekcie?
Walidacja (ValidationException) pojawia się na granicy API, kiedy sprawdziliśmy DTO i znaleźliśmy problemy. W poprzednim wykładzie zwracaliśmy List<String> details. Teraz wygodnie zrobić tak: jeśli lista nie jest pusta – rzucamy ValidationException, a dalej wspólny mechanizm błędów zrobi resztę.
Na przykład w handlerze:
List<String> details = validator.validateForCreate(request);
if (!details.isEmpty()) {
throw new ValidationException(details);
}
NotFoundException najczęściej rzuca warstwa service, gdy po poprawnym id zasób nie został znaleziony. To ważne: poprawny id, ale brakujący zasób – to nie 400, tylko 404.
Przykład (kawałek serwisu):
ReadingListItem item = repository.findById(id)
.orElseThrow(() -> new NotFoundException("Item not found: id=" + id));
ConflictException rzucamy w regule biznesowej w stylu „externalId musi być unikalny”. To nie błąd formatu żądania, bo format jest normalny. To właśnie konflikt z bieżącym stanem serwisu.
Przykład (warunkowy fragment serwisu):
if (repository.existsByExternalId(request.externalId(), id)) {
throw new ConflictException("externalId already used");
}
W ten sposób uzyskujemy ważny efekt: kod rozróżnia sens problemów jeszcze zanim zaczniemy pisać odpowiedź HTTP. A to oznacza, że przejście do 400/404/409/500 można zrobić centralnie i bez kopiuj-wklej.
5. ErrorMapper: status i ErrorResponse
Jeśli nie masz centralnego mechanizmu error mapping, będziesz pisał to samo w każdym endpoincie: catch, wybór statusu, składanie JSON-a, zapis do response. Trzeciego dnia zamienia się to w nudny copy-paste, piątego – w rozsynchronizowanie kontraktów, a dziesiątego – w „nikt nie wie, gdzie jaki format błędu”. Dlatego robimy małą, ale centralną klasę: ErrorMapper.
Nie powinna być „mini-frameworkiem”. To dosłownie „tabelka odpowiedniości w kodzie”. Zwykle starcza na jeden plik.
package com.example.readlater.common.error;
public class ErrorMapper {
public int statusFor(Exception ex) {
// Najpierw — oczekiwane (kontraktowe) błędy klienta/domeny.
if (ex instanceof ValidationException) return 400;
if (ex instanceof NotFoundException) return 404;
if (ex instanceof ConflictException) return 409;
// Resztę uznajemy za nieoczekiwany błąd serwera.
return 500;
}
}
A teraz ciało błędu. Dla walidacji bierzemy details, dla pozostałych – oddajemy pustą listę. Dla message lepiej używać stabilnych fraz, a nie tekstów wyjątków (inaczej komunikaty będą „skakać” i w którymś momencie przypadkiem wycieknie na zewnątrz zbędna informacja).
package com.example.readlater.common.error;
import java.util.List;
public class ErrorMapper {
public ErrorResponse bodyFor(Exception ex) {
// Walidacja: oddajemy listę wszystkich problemów, żeby klient poprawił wszystko za jednym razem.
if (ex instanceof ValidationException vex) {
return new ErrorResponse("VALIDATION_ERROR", "Żądanie nie przeszło walidacji", vex.details());
}
// 404/409: szczegóły zostawiamy w logach, klientowi — stabilny kontrakt.
if (ex instanceof NotFoundException) {
return new ErrorResponse("NOT_FOUND", "Zasób nie znaleziony", List.of());
}
if (ex instanceof ConflictException) {
return new ErrorResponse("CONFLICT", "Konflikt danych", List.of());
}
// 500: maksymalnie "nudna" odpowiedź bez wewnętrznych szczegółów.
return new ErrorResponse("INTERNAL_ERROR", "Błąd wewnętrzny", List.of());
}
}
Tak, jest tu mała „bezczelność”: NotFoundException trzyma message, ale nie pokazujemy go klientowi. To celowe. W logach możesz zobaczyć dokładny id, a klientowi zwykle wystarczy „zasób nie znaleziony”. W projekcie edukacyjnym jest to szczególnie przydatne: trenujemy granicę „co dla klienta” i „co do logów”.
Teraz pozostało zrobić ostatnie połączenie: z wyjątku dostać status + body i zapisać JSON. Czyli potrzebujemy „standardowego wyjścia” w warstwie handlera.
6. Obsługa błędów w handlerze: jeden try/catch
Gdy pracujemy na HttpServer, nie mamy globalnego exception handlera „z pudełka”. Dlatego najbardziej realistyczna edukacyjna droga to zrobić wspólny try/catch na poziomie handlerów (albo przynajmniej w głównym handle()), który złapie wyjątki i zamieni je w ErrorResponse. To znów nie framework, tylko normalny inżynierski nawyk: z góry ustalasz, gdzie kończy się „zwykła logika” i zaczyna „wyjście awaryjne”.
Wyobraź sobie, że mamy już narzędzie JsonWriter (albo metodę writeJson) z poprzednich dni, która pisze dowolny obiekt do JSON-a przez ObjectMapper. W handlerze po prostu używamy ErrorMapper.
Operacje HttpExchange często mają checked exceptions, więc zwykły Runnable tu szybko zaczyna przeszkadzać. Wygodniej od razu trzymać mały functional interface pod akcję handlera.
import com.example.readlater.common.error.ErrorMapper;
import com.example.readlater.common.error.ErrorResponse;
import com.sun.net.httpserver.HttpExchange;
import org.slf4j.Logger;
import java.io.IOException;
@FunctionalInterface
interface ExchangeAction {
void run() throws Exception;
}
class ReadingListHttpHandler {
private final ErrorMapper errorMapper;
private final JsonWriter jsonWriter;
private final Logger logger;
void handleSafely(HttpExchange exchange, ExchangeAction action) throws IOException {
try {
// Wewnątrz action wykonuje się "zwykła" logika endpointu.
action.run();
} catch (Exception ex) {
// Szczegóły — do logów (tu trafi stack trace).
logger.error("Request failed", ex);
// A klientowi — kontrakt: status + ErrorResponse.
int status = errorMapper.statusFor(ex);
ErrorResponse body = errorMapper.bodyFor(ex);
// Wspólny sposób serializacji do JSON dla wszystkich błędów.
jsonWriter.writeJson(exchange, status, body);
}
}
}
Jest tu bardzo ważny drobiazg: logujemy wyjątek zawsze jako zdarzenie techniczne, ale klientowi oddajemy kontrakt. Tak dostajesz obie strony życia backendu: klient nie widzi wnętrzności, ale ty nie zostajesz w ciemności.
Teraz w handle() można zrobić mniej więcej tak:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public void handle(HttpExchange exchange) throws IOException {
handleSafely(exchange, () -> {
// Wyciągamy kontekst żądania i routujemy.
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
route(method, path, exchange);
});
}
A w route(...) możesz już spokojnie rzucać NotFoundException, ValidationException, ConflictException, nie zastanawiając się za każdym razem „a jaki status wystawić”. To właśnie cel: oddzielić kod aplikacyjny od rutynowego przekształcania błędów w HTTP.
Od tego miejsca API ma już jeden działający baseline: endpointy rzucają sensowne wyjątki, a handler przekłada je na status + ErrorResponse dokładnie jeden raz.
7. Dwa rodzaje 400: MALFORMED_JSON i walidacja DTO
Z ValidationException wszystko jasne: DTO się rozparsował, ale sens jest zły. A co robić z sytuacją, kiedy JSON w ogóle się nie rozparsował? JSON, który się nie rozparsował, to inny typ problemu. I on też musi żyć we wspólnym error contract, inaczej jeden endpoint zwróci błąd-JSON, a inny – „Plain text: invalid json”, i znów wrócisz do chaosu.
Najprościej: łapiemy wyjątek Jacksona przy czytaniu body i rzucamy ValidationException? Można, ale wtedy tracisz różnicę, a klient nie zrozumie, co konkretnie się stało. Dlatego lepiej wprowadzić osobny errorCode dla malformed JSON.
Najprostszy wariant – nie tworzyć osobnego wyjątku, tylko bezpośrednio w catchu zamieniać w ValidationException ze szczegółami „Nieprawidłowy JSON”. Ale mnie bardziej podoba się trochę bardziej jawne podejście: założyć MalformedJsonException (i tak będzie mały).
package com.example.readlater.common.error;
// 400: ciało żądania nie jest poprawnym JSON-em (do walidacji DTO nawet nie dotarliśmy).
public class MalformedJsonException extends RuntimeException {
public MalformedJsonException() {
super("Malformed JSON");
}
}
I rozszerzyć ErrorMapper (pokażę tylko kluczową część, żeby nie rozmazywać kodu):
public int statusFor(Exception ex) {
// Nieprawidłowy JSON i błędy walidacji DTO — różne przyczyny, ale ten sam status (400).
if (ex instanceof MalformedJsonException) return 400;
if (ex instanceof ValidationException) return 400;
if (ex instanceof NotFoundException) return 404;
if (ex instanceof ConflictException) return 409;
return 500;
}
public ErrorResponse bodyFor(Exception ex) {
// Dla parsowania JSON-a zwracamy osobny errorCode, żeby klient rozumiał przyczynę.
if (ex instanceof MalformedJsonException) {
return new ErrorResponse("MALFORMED_JSON", "Nieprawidłowy JSON", List.of());
}
// pozostałe gałęzie jak wcześniej...
return new ErrorResponse("INTERNAL_ERROR", "Błąd wewnętrzny", List.of());
}
Teraz czytanie body wygląda schludnie: najpierw próbujemy rozparsować, a jeśli Jackson padnie – nie wklejamy tekstu w odpowiedź, tylko rzucamy zrozumiały wyjątek, który dalej obsłuży wspólny mechanizm.
import com.fasterxml.jackson.core.JsonProcessingException;
CreateReadingItemRequest readCreateRequest(String body) {
try {
// Próbujemy rozparsować JSON do DTO.
return objectMapper.readValue(body, CreateReadingItemRequest.class);
} catch (JsonProcessingException ex) {
// Nie oddajemy na zewnątrz tekstu wyjątku Jacksona: mapujemy w nasz kontraktowy typ problemu.
throw new MalformedJsonException();
}
}
Sens tego małego kroku jest ogromny: teraz każdy endpoint, który czyta JSON, będzie tak samo obsługiwał „zepsuty JSON”. I nie będziesz biegał po projekcie, szukając „gdzie my przypadkiem odpowiedzieliśmy plain textem”.
To już ten baseline, który warto trzymać wspólny dla wszystkich JSON-owych endpointów.
8. 500: odpowiedź klientowi i logi osobno
Gdy twój kod pada nieoczekiwanie, bardzo chce się zrobić „jak w filmie”: zwrócić klientowi stack trace, bo tak „łatwiej debugować”. Ale to dokładnie ten moment, w którym nawyki backendowe powinny wygrać. Klient – to nie twój debugger. Klient powinien dostawać stabilny i bezpieczny komunikat, a diagnostyczne szczegóły powinny być w logach. Nawet w projekcie edukacyjnym jest to ważne, bo wyrabia właściwy odruch.
Dlatego dla 500 robimy maksymalnie nudną odpowiedź: errorCode=INTERNAL_ERROR, message=Błąd wewnętrzny, details=[]. Tyle. Żadnych java.lang.NullPointerException: .... Jeśli musisz debugować – otwieraj logi, podłączyliśmy je już w module o SLF4J + Logback.
Przykład, jak to wygląda w handleSafely (już pisaliśmy, ale teraz patrzymy oczami osoby operacyjnej):
catch (Exception ex) {
logger.error("Request failed", ex); // stack trace tutaj, a nie w response
int status = errorMapper.statusFor(ex);
ErrorResponse body = errorMapper.bodyFor(ex);
jsonWriter.writeJson(exchange, status, body);
}
Subtelność: nie wszystkie błędy muszą być logiem error. Walidacja to normalna sytuacja życia API, można ją logować na warn (albo nawet w ogóle nie logować, jeśli to szum). Ale nieoczekiwane wyjątki – tak, to error, bo to twój bug albo twoja awaria.
I jeszcze jeden praktyczny moment: nie loguj całego request body „na wszelki wypadek”. W naszym projekcie payload jest mały, ale nawyk „logujemy wszystko z rzędu” w realnym świecie szybko zamienia się w wycieki danych i gigantyczne logi. Lepiej logować krótki kontekst (metoda, ścieżka, status, typ błędu), a nie całą zawartość żądania.
9. Typowe błędy: wspólny error response
Błąd nr 1: różne endpointy zwracają różne formaty błędów.
Bardzo często zaczyna się pięknie: jeden endpoint zwraca ErrorResponse, a potem gdzieś po drodze pojawia się „tymczasowe” exchange.sendResponseHeaders(400, ...) z plain textem, i tak już zostaje. Po tygodniu kolekcja Postmana zaczyna wyglądać jak „zgadnij format odpowiedzi”. Leczy się to dyscypliną: każda negatywna odpowiedź – przez wspólny ErrorResponse i jeden mechanizm zapisu JSON-a.
Błąd nr 2: do response body wyjeżdża stack trace albo techniczny komunikat wyjątku.
Najpierw wydaje się wygodne. Potem nagle uświadamiasz sobie, że klient widzi wewnętrzne szczegóły, nazwy klas, kawałki ścieżek, a czasem nawet dane żądania. Poprawne podejście jest proste: techniczne szczegóły – w logger.error("...", ex), klientowi – krótki stabilny komunikat i errorCode, bez nadmiaru „uduchowienia”.
Błąd nr 3: 500 używany jako „uniwersalny status domyślny”.
Jeśli łapiesz każdy wyjątek i zawsze odpowiadasz 500, tracisz precyzję sensu API. Klient nie rozumie, czy to on zawinił (złe żądanie), czy serwer jest zepsuty. W naszym projekcie szczególnie widać to przy walidacji: pusty title – to 400, a nie 500. 404 i 409 też powinny żyć swoim życiem, inaczej nie ćwiczysz HTTP-owego myślenia, dla którego w ogóle pisaliśmy ten serwer.
Błąd nr 4: errorCode rozmnaża się niekontrolowanie i zamienia w „słownik na 200 linii”.
Czasem chce się zrobić errorCode super-szczegółowy: osobny kod na każde kichnięcie. W małym API prowadzi to do tego, że kody stają się niestabilne i tracą sens. Lepiej trzymać krótki, ustalony zestaw w stylu MALFORMED_JSON, VALIDATION_ERROR, NOT_FOUND, CONFLICT, INTERNAL_ERROR, a szczegóły wkładać do details.
Błąd nr 5: validation errors „padają na pierwszym polu” i nie zwracają pełnej listy problemów.
Jeśli sprawdzasz pola i od razu rzucasz wyjątek przy pierwszym błędzie, klient będzie naprawiał żądanie „po jednym błędzie naraz”, jak w słabym questy. Walidacja na granicy API powinna zbierać wszystkie problemy naraz, żeby klient dostał pełną listę i poprawił wszystko za jednym podejściem. Właśnie po to mamy details: List<String>.
GO TO FULL VERSION