1. Помилки до сервісної логіки
Коли ви тільки починаєте писати API, здається, що всі помилки народжуються «всередині сервісу»: щось не знайшли — кинули TaskNotFoundException, щось заборонили — кинули TaskArchivedException. Але Spring MVC — не курʼєр, який просто заносить JSON у метод вашого контролера. Спершу він намагається прочитати тіло, перетворити параметри, перевірити дані — і на будь-якому з цих кроків може впасти ще до вашого коду.
Щоб у вас у голові не виникало містики, корисно тримати просту картину: частина помилок трапляється на етапі «вхід → Java-об’єкти», а не на етапі «Java-об’єкти → бізнес-логіка».
flowchart TD A[HTTP-запит] --> B[DispatcherServlet] B --> C[HandlerMapping: знайшли метод контролера] C --> D[Обробники аргументів] D --> D1["@RequestParam / @PathVariable — зв’язування"] D --> D2["@RequestBody через HttpMessageConverter (Jackson)"] D2 --> E["@Valid / валідація"] E --> F[метод контролера] F --> G[сервісний шар] D1 --> X[Виняток: невідповідність типу / відсутній параметр] D2 --> Y[Виняток: некоректний JSON / відсутнє тіло] E --> Z[Виняток: MethodArgumentNotValidException] X --> H[Глобальна обробка помилок] Y --> H Z --> H G --> H
Ось чому в новачків буває «магічний» баг: ви ставите breakpoint у сервісі, надсилаєте запит, а breakpoint не спрацьовує — тому що до сервісу запит не дожив. Він героїчно помер на етапі читання JSON або конвертації query-параметра.
Для орієнтиру тримайте невелику таблицю. Це не енциклопедія, а «карта місцевості», щоб розуміти, про що взагалі йдеться:
| Сценарій | Де падає | Приклад винятку | Що зазвичай повертають |
|---|---|---|---|
| JSON зламаний або тіло не читається | перетворення повідомлення (@RequestBody) | HttpMessageNotReadableException | 400 Bad Request |
| @Valid не пройшов | валідація | MethodArgumentNotValidException | 400 Bad Request |
| Не передали обов’язковий query param | зв’язування параметрів | MissingServletRequestParameterException | 400 Bad Request |
| status=WRONG_ENUM | перетворення типів | MethodArgumentTypeMismatchException | 400 Bad Request |
| Content-Type: text/plain, а очікуємо JSON | узгодження медіатипу | HttpMediaTypeNotSupportedException | 415 Unsupported Media Type |
І тепер головний практичний висновок: ці помилки теж мають потрапляти в єдиний контракт помилок, інакше клієнт отримуватиме то «красивий ProblemDetail», то «дивний текст за замовчуванням від Spring», то «HTML-сторінку на пів екрана» (так, таке ще буває, якщо зовсім не налаштувати обробку).
2. ResponseEntityExceptionHandler
Коли ви бачите довгу назву ResponseEntityExceptionHandler, легко подумати, що це щось для тих, хто читає вихідний код Spring на ніч. Насправді це дуже практичний базовий клас, який уже знає, як перехоплювати багато типових MVC-винятків, і дає вам зручні «точки входу», щоб замінити поведінку за замовчуванням на вашу.
Принцип тут побутово простий: уявіть, що Spring MVC — це дуже суворий перевіряльник на вході до клубу. Він зупиняє відвідувача ще біля дверей: «Ваш паспорт не читається!» або «Ви не заповнили обов’язкове поле!». ResponseEntityExceptionHandler — це та сама людина поруч, яка переводить ці крики в нормальну, передбачувану форму відповіді для клієнта. У нашому випадку — у ProblemDetail з правильним статусом і акуратним текстом.
Чому це зручно саме для рівня Junior:
Вам не потрібно пам’ятати десятки окремих класів exception і писати для кожного окремий @ExceptionHandler. Натомість ви успадковуєте ResponseEntityExceptionHandler і перевизначаєте лише ті методи, які вам важливі, наприклад обробку некоректного JSON і валідації. Так ви одночасно зберігаєте контроль і не тонете в коді.
3. GlobalExceptionHandler на базі ResponseEntityExceptionHandler
Одного обробника доменних помилок замало: шар обробки помилок має однаково переживати і TaskNotFoundException, і некоректний JSON, і невідповідність типів ще до входу в метод контролера. Тому час зібрати один GlobalExceptionHandler, де доменні @ExceptionHandler і перевизначення на рівні фреймворку працюють разом.
Нижче вже не окремий навчальний приклад, а той самий обробник, який має залишитися в проєкті: доменні правила — у @ExceptionHandler, помилки MVC-конвеєра — через ResponseEntityExceptionHandler.
package com.example.tasktracker.api.controller.advice;
import com.example.tasktracker.domain.exception.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
// В одному advice тримаємо і доменні handlers, і framework-level overrides
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
ProblemDetail body = problem(
HttpStatus.NOT_FOUND,
"Задачу не знайдено",
ex.getMessage(),
ex.getCode().name()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
private ProblemDetail problem(HttpStatusCode status, String title, String detail) {
// Збираємо базовий ProblemDetail в одному стилі для всього web-layer
ProblemDetail body = ProblemDetail.forStatusAndDetail(status, detail);
body.setTitle(title);
return body;
}
private ProblemDetail problem(HttpStatusCode status, String title, String detail, String code) {
// Доменні помилки використовують ту саму форму відповіді, просто додають machine-readable code
ProblemDetail body = problem(status, title, detail);
body.setProperty("code", code);
return body;
}
}
Тут один і той самий обробник уже вміє дві речі: доменні винятки використовують overload із code, а помилки на рівні фреймворку збиратимуть такий самий ProblemDetail через базовий допоміжний метод. Тобто формат відповіді залишається один, навіть якщо джерело помилки різне.
4. Некоректний JSON: HttpMessageNotReadableException
Ситуація «зламаний JSON» трапляється дуже часто: зайва кома, втрачена лапка, рядок замість числа або взагалі запит без тіла, хоча воно обов’язкове. І тут важливо не переплутати: це ще не валідація і не бізнес-помилка. Це чиста проблема читання: «Ми навіть не змогли нормально розібрати ваш запит». Саме тому помилка виникає раніше за сервіс і майже завжди має бути 400 Bad Request.
Найнебезпечніше, що можна зробити, — повністю віддати назовні ex.getMessage(). Там може бути полотно рівня «Unexpected character ('}' (code 125)): was expecting double-quote to start field name…», а інколи — ще й внутрішні деталі. Клієнту це не допомагає, зате перетворює ваше API на «вікі з внутрішньої кухні Jackson».
Додаймо перевизначення handleHttpMessageNotReadable. Воно спрацьовує для HttpMessageNotReadableException — класичного випадку некоректного JSON.
package com.example.tasktracker.api.controller.advice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.context.request.WebRequest;
// Вставте цей override всередину GlobalExceptionHandler extends ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// Клієнт надіслав тіло, яке не вдалося розібрати (синтаксис/формат/тип) — це 400
ProblemDetail body = problem(
HttpStatus.BAD_REQUEST,
"Тіло запиту некоректне",
"Некоректний JSON"
);
// Повертаємо відповідь через внутрішній механізм Spring, щоб не порушувати стандартний механізм обробки
return handleExceptionInternal(ex, body, headers, status, request);
}
Тут важливий момент: ми не змінюємо статус, який Spring уже обрав (параметр status), але формуємо тіло ProblemDetail з нормальними title і detail. Можна використати і status всередині problem(status, ...), але я часто залишаю HttpStatus.BAD_REQUEST явно, щоб студенту було простіше читати очима: «ага, некоректний JSON = 400».
Щоб відчути це на практиці, уявіть такий запит до нашої кінцевої точки створення задачі (вона в нас уже є з @RequestBody):
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Write tests",
}
Тут зайва кома перед }. Ваш контролер навіть не запуститься. HttpMessageConverter спробує прочитати JSON, Jackson кине виняток, Spring загорне його в HttpMessageNotReadableException, і ми потрапимо в handleHttpMessageNotReadable.
5. Помилки @Valid: MethodArgumentNotValidException
Помилки валідації — більш «людські»: JSON може бути ідеальним за синтаксисом, але дані всередині не відповідатимуть контракту. Наприклад, title порожній або занадто короткий, тегів забагато, assigneeName завдовжки 500 символів (так, користувачі можуть так робити, якщо їм нудно). Важливий нюанс: валідувати ми починаємо лише після того, як змогли прочитати JSON і зібрати DTO.
Тобто послідовність така: «прочитали JSON → зібрали DTO → перевірили @Valid → або йдемо в сервіс, або падаємо». І саме це падіння якраз часто виражається через MethodArgumentNotValidException.
Тут важливо не переплутати дві задачі. Ми не скасовуємо деталі на рівні полів і не говоримо, що валідація має звестися до одного рядка. На цьому етапі нам потрібен саме гачок у MVC-конвеєрі: MethodArgumentNotValidException має потрапити в той самий GlobalExceptionHandler, а вже на цю відповідь можна наростити масив fieldErrors та інші подробиці.
package com.example.tasktracker.api.controller.advice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.context.request.WebRequest;
// Вставте цей override всередину GlobalExceptionHandler extends ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// Кількість помилок потрібна хоча б для короткого summary (не розкриваючи внутрішню структуру)
int errors = ex.getBindingResult().getErrorCount();
// Це «чесний» 400: запит зрозумілий, але не відповідає контракту валідації
ProblemDetail body = problem(
HttpStatus.BAD_REQUEST,
"Некоректне введення",
"Перевірка не пройдена (%d error(s))".formatted(errors)
);
return handleExceptionInternal(ex, body, headers, status, request);
}
Так, це відповідь «не надто балакуча». Але в неї вже є головне: стабільна форма (ProblemDetail) і зрозумілий статус. А ще вона не змушує клієнта парсити випадкові рядки. Клієнт бачить 400 і розуміє: «я надіслав дані, які не відповідають контракту».
Приклад запиту, який синтаксично нормальний, але невалідний:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "",
"description": "Something"
}
title має @NotBlank, це гарантований MethodArgumentNotValidException.
6. Помилки параметрів запиту
Відсутні параметри: MissingServletRequestParameterException
Іноді проблема не в JSON і не в @Valid, а в тому, що клієнт просто забув передати обов’язковий query-параметр. Наприклад, ви оголосили @RequestParam String format без required=false і без defaultValue, а клієнт прийшов без нього. Spring у такому випадку не «додумає» сам, що ви мали на увазі. Він чесно скаже: «параметр обов’язковий, але його немає».
Навіть якщо у вашому поточному Task Tracker API більшість query-параметрів для списків будуть optional (фільтри, page, size тощо), такі винятки все одно корисно вміти обробляти. У реальному проєкті обов’язково знайдеться хоча б одна кінцева точка з обов’язковим параметром, і тоді це спрацює.
Перевизначимо обробник:
package com.example.tasktracker.api.controller.advice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.context.request.WebRequest;
// Вставте цей override всередину GlobalExceptionHandler extends ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleMissingServletRequestParameter(
MissingServletRequestParameterException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// Клієнту корисно знати точну назву параметра, якого бракує
ProblemDetail body = problem(
HttpStatus.BAD_REQUEST,
"Відсутній параметр запиту",
"Параметр запиту '%s' є обов’язковим".formatted(ex.getParameterName())
);
return handleExceptionInternal(ex, body, headers, status, request);
}
Зверніть увагу, тут ми використовуємо ex.getParameterName(). Це нормальна «зовнішня» інформація: клієнту справді корисно знати, який параметр він забув. Ми при цьому не розкриваємо stack trace і не ворожимо, чому саме це сталося.
Невідповідний тип параметра: MethodArgumentTypeMismatchException
Одна з найпідступніших категорій — невідповідність типів. Ви в контролері чесно написали TaskStatus status, очікуючи TODO або DONE, а клієнт надіслав status=SUPER_DONE. І тут важлива різниця з валідацією: це не «значення не відповідає обмеженням», а «значення взагалі не вдалося перетворити в потрібний тип». Spring навіть не зміг створити аргумент методу — отже, і @Valid не стартує.
Найчастіше ви побачите MethodArgumentTypeMismatchException (він розширює TypeMismatchException). У ResponseEntityExceptionHandler є загальний метод handleTypeMismatch, у якому ми можемо акуратно зібрати ProblemDetail.
package com.example.tasktracker.api.controller.advice;
import org.springframework.beans.TypeMismatchException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
// Вставте цей override всередину GlobalExceptionHandler extends ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleTypeMismatch(
TypeMismatchException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// Загальний fallback: тип не збігся, але без зайвих деталей реалізації
String detail = "Параметр запиту має некоректний формат";
// Найчастіший кейс: не вдалося сконвертувати конкретний аргумент методу контролера
if (ex instanceof MethodArgumentTypeMismatchException matme) {
// Імʼя параметра та надіслане значення — корисна «зовнішня» інформація для клієнта
detail = "Параметр '%s' має некоректне значення '%s'"
.formatted(matme.getName(), matme.getValue());
}
ProblemDetail body = problem(HttpStatus.BAD_REQUEST, "Некоректний параметр", detail);
return handleExceptionInternal(ex, body, headers, status, request);
}
Це якраз той випадок, коли API стає «людяним» без зайвої поезії. Клієнт отримує зрозумілий сигнал: параметр некоректний. Він бачить, який саме, і яке значення було отримано. Якщо він надіслав page=abc — зрозуміє, що потрібне число. Якщо status=SUPER_DONE — зрозуміє, що такого значення enum не знає.
Приклад запиту, який часто ламає новачків:
GET http://localhost:8080/api/v1/tasks?status=SUPER_DONE
Якщо status у вас біндиться в TaskStatus, то контролер не викличеться. Помилка станеться раніше. І це якраз «правильна» ситуація для 400, а не для 500: сервер живий, просто вхідний контракт порушено.
7. Непідтримуваний Content-Type
Ще одна дуже типова проблема — клієнт надсилає тіло запиту, але з неправильним Content-Type. Наприклад, text/plain, application/xml або взагалі не вказує Content-Type, хоча відправляє JSON. Якщо ви в контролері дисципліновано зафіксували consumes = application/json, Spring має повернути чесний 415 Unsupported Media Type. Це важлива семантика: клієнту не можна вдавати, що ми все зрозуміли, якщо формат не той.
ResponseEntityExceptionHandler дає для цього перевизначення handleHttpMediaTypeNotSupported.
package com.example.tasktracker.api.controller.advice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.context.request.WebRequest;
// Вставте цей override всередину GlobalExceptionHandler extends ResponseEntityExceptionHandler
@Override
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
HttpMediaTypeNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
// Content-Type може бути відсутнім — обробляємо це явно, щоб не отримати NPE
String contentType = (ex.getContentType() != null) ? ex.getContentType().toString() : "unknown";
// 415: сервер живий, але формат тіла запиту не відповідає очікуваному
ProblemDetail body = problem(
HttpStatus.UNSUPPORTED_MEDIA_TYPE,
"Тип медіа не підтримується",
"Content-Type '%s' не підтримується".formatted(contentType)
);
return handleExceptionInternal(ex, body, headers, status, request);
}
Приклад запиту, який це спровокує, коли кінцева точка чекає JSON:
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
hello
Це має бути 415, і це нормально. Така відповідь дозволяє клієнту швидко виправити помилку: змінити Content-Type і надіслати коректний JSON.
8. Типові помилки під час обробки помилок MVC
Помилка №1: намагатися ловити некоректний JSON у сервісі.
Сервісний метод не викликається, якщо Spring не зміг прочитати JSON. Тому жодні try/catch навколо taskService.create(...) не допоможуть: виняток виник раніше. Рішення — ловити HttpMessageNotReadableException на рівні глобального обробника, як ми зробили через перевизначення.
Помилка №2: повертати 500 на помилки binding/validation.
500 означає «сервер зламався». А status=SUPER_DONE — це не поломка сервера, це клієнт надіслав непридатне значення. Якщо ви повернете 500, клієнт подумає, що у вас інцидент у проді, а вам прийдуть хибні алерти (і кава стане гіркою).
Помилка №3: віддавати назовні ex.getMessage() для помилок фреймворку.
У HttpMessageNotReadableException та інших технічних винятків повідомлення часто занадто докладні, нестабільні й можуть містити внутрішні деталі. Клієнту потрібен стабільний контракт, а не цитати з внутрішнього монологу Jackson. Краще повертати контрольовані title/detail, а подробиці залишати в логах.
Помилка №4: робити обробку помилок фреймворку окремим зоопарком @ExceptionHandler, ігноруючи ResponseEntityExceptionHandler.
Технічно так можна, але ви швидко отримаєте багато однотипних методів, і у вас почнуться проблеми з повнотою: щось забули обробити, а потім — із консистентністю. ResponseEntityExceptionHandler уже дає правильні «гачки» для типових випадків — користуйтеся ними.
Помилка №5: не використовувати handleExceptionInternal(...) і повертати ResponseEntity.status(...).body(...) «вручну» в override.
Так теж працює, але handleExceptionInternal акуратно враховує деякі деталі внутрішнього механізму Spring. На рівні навчання це проста звичка: «ми в override → ми повертаємо через handleExceptionInternal».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ