1. Узгодження статусів помилок
Єдине тіло помилки саме по собі ще не відповідає на головне запитання клієнта: чому в одному випадку 400, а в іншому 404 або 409. Якщо дивитися на бекенд очима новачка, дуже хочеться думати так: «Спершу зроблю успішний сценарій, а помилки потім… ну, потім». На практиці саме це «потім» перетворюється на найдорожчу частину проєкту: клієнт (Postman, фронтенд або інший сервіс) уже звикає до однієї поведінки, а сервер раптово починає повертати то 400, то 404, то 500, а іноді — просто рядок «Oops!». Тому ми зараз, поки сервер ще не піднято, фіксуємо зміст помилок як частину контракту.
У нашому ReadLater Starter це особливо важливо, тому що локальний API перевірятиметься вручну через Postman. Коли ви тестуєте негативні сценарії, вам потрібно розуміти, що саме ви бачите: помилку в запиті, відсутність ресурсу, конфлікт даних чи справжню аварію. Якщо у нас немає єдиного змісту статусів, налагодження перетворюється на ворожіння на кавовій гущі: «А це в мене запит поганий чи застосунок зламався?» Добрий бекенд не змушує клієнта грати у «вгадай мелодію» — він чесно й однаково пояснює, що пішло не так.
HTTP-статус і ErrorResponse
Уявіть, що HTTP-статус — це табличка на дверях, а ErrorResponse — це записка всередині кімнати. Табличка каже дуже великими літерами: «Вхід заборонено» (403), «Не знайдено» (404), «Некоректний запит» (400). Але табличка не зобов’язана пояснювати деталі вашого домену: чому саме запит некоректний, який ресурс не знайдено і що саме конфліктує. Для цього й існує тіло відповіді — наш ErrorResponse.
Дуже важливо не намагатися змусити один шар робити роботу іншого. Якщо ви завжди віддаєте 400, а в errorCode пишете щось на кшталт READING_ITEM_NOT_FOUND, ви плутаєте клієнтів: статус каже «ви неправильно написали запит», а тіло — «ресурс відсутній». І навпаки, якщо ви віддаєте 404, але в message пишете «Некоректний JSON», ви змушуєте клієнта думати, що він запитав неіснуючий шлях, хоча насправді просто надіслав зламаний body.
Наша домовленість виглядає так: HTTP-статус обирається за класом проблеми, а errorCode — за конкретною причиною в межах проєкту. message пишеться для людини — коротко й без технічної істерики, а details — для зрозумілих уточнень (наприклад, «id має бути числом», «externalId=OL12345M»). І все це має бути послідовним: один і той самий тип ситуації завжди дає один і той самий статус і один і той самий errorCode.
2. Дерево рішень для статусів 400–500
Коли ви починаєте реалізовувати API, рука сама тягнеться до «універсальної» відповіді: «Якщо не вдалося — повернемо 500». Але це як лікувати всі хвороби одним діагнозом «щось пішло не так»: начебто чесно, але користі нуль. Тому зручно тримати в голові просту схему вибору статусу, щоб не плутатися й не сперечатися із самим собою через тиждень.
flowchart TD
A[Під час оброблення запиту виникла проблема] --> B{Запит некоректний?}
B -->|Так| S400["400 Bad Request
INVALID_REQUEST"]
B -->|Ні| C{"Шлях/ресурс відсутній?"}
C -->|Так| S404["404 Not Found
..._NOT_FOUND"]
C -->|Ні| D{Метод не підтримується для шляху?}
D -->|Так| S405["405 Method Not Allowed
METHOD_NOT_ALLOWED"]
D -->|Ні| E{Конфлікт із поточним станом?}
E -->|Так| S409["409 Conflict
..._CONFLICT"]
E -->|Ні| S500["500 Internal Server Error
INTERNAL_ERROR"]
Зверніть увагу на важливий нюанс: гілка 404 може означати як «не знайдено конкретний доменний ресурс» (наприклад, елемента reading list із таким id немає), так і «не знайдено шлях в API». Обидва випадки формально є not found, але errorCode варто розрізняти, щоб клієнт міг зрозуміти, чого саме бракує: об’єкта даних чи маршруту. У навчальному проєкті можна почати з мінімального набору кодів і розширювати його за потреби, але сам принцип краще засвоїти одразу.
3. Статуси в сценаріях ReadLater
400 Bad Request: проблема в запиті клієнта
400 здається найпростішим статусом: «клієнт винен». Але саме він найчастіше перетворюється на смітник, куди летить усе підряд. Щоб 400 залишався корисним, тримаємо одну думку: запит не проходить перевірку коректності, навіть якщо ми ще не дісталися бізнес-логіки. Це може бути неправильний формат id, невалідний enum-статус, пропущене обов’язкове поле або зламаний JSON.
Для ReadLater типові 400-ситуації легко уявити навіть без сервера. Якщо клієнт пише id=abc там, де ми очікуємо число, це не «ресурс не знайдено», це «ви надіслали нісенітницю». Якщо клієнт пише "status": "DONE", а в нас у enum такого значення немає, це теж 400. І важлива методична деталь: 400 — це не покарання, а підказка. Ми маємо допомогти клієнту зрозуміти, що саме виправити.
Приклад тіла помилки для невалідного id:
{
"errorCode": "INVALID_REQUEST",
"message": "Запит містить некоректні дані",
"details": ["id має бути числом"]
}
У нашому проєкті зручно домовитися, що для 400 ми майже завжди використовуємо один errorCode (INVALID_REQUEST), а конкретика живе в details. Так клієнту не потрібно підтримувати 20 різних кодів на кшталт INVALID_ID, INVALID_STATUS, INVALID_JSON… а людині при цьому все одно зрозуміло, що не так.
Невеликий фрагмент коду, який показує ідею «стабільний код + конкретна деталь»:
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// Для будь-якої 400-ситуації тримаємо один стабільний код,
// а конкретну причину виносимо в details.
ErrorResponse err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"Запит містить некоректні дані",
List.of("id має бути числом")
);
Тут немає жодної магії: стабільним залишається лише код, а конкретика живе в details. Цього вже достатньо, щоб негативний сценарій у Postman читався без ворожіння.
404 Not Found: запит коректний, але потрібного ресурсу немає
404 — це статус, який часто плутають із 400. Логіка проста: якщо запит коректний за формою, але об’єкт даних у поточному стані застосунку відсутній, це 404. Тобто клієнт зробив нормальний запит на кшталт «дай елемент з id=42», а ми чесно відповідаємо: «такого немає».
У reading list API це траплятиметься постійно, і це нормально. Порожня база, точніше in-memory сховище, — не помилка сервера. Відсутність елемента — теж не помилка сервера. Це просто факт: ресурс не знайдено. І тут нам важливо не «компенсувати» це 500 або, про всяк випадок, 400. Клієнт зробив усе правильно, просто об’єкт не існує.
Приклад відповіді:
{
"errorCode": "READING_ITEM_NOT_FOUND",
"message": "Елемент списку читання не знайдено",
"details": ["id=42"]
}
Зверніть увагу: details не зобов’язані бути красивими, вони зобов’язані бути корисними. «id=42» — уже достатньо, тому що людина, дивлячись на лог або на відповідь Postman, швидко зіставить запит і проблему.
Якщо ви захочете бути ще акуратнішими, можна розрізняти «маршрут не знайдено» і «доменний ресурс не знайдено» різними errorCode, наприклад ROUTE_NOT_FOUND і READING_ITEM_NOT_FOUND. Це не обов’язково робити просто сьогодні, але корисно тримати в голові, щоб пізніше не довелося ламати контракт.
405 Method Not Allowed: шлях є, але метод до нього не підходить
405 — це один із найчастіше пропущених статусів у новачків. Причина проста: простіше повернути 404 на все, що не підходить, і не думати. Але 405 робить API значно чеснішим і передбачуванішим. Він каже: «шлях існує, сервер його впізнає, але конкретний HTTP-метод тут заборонено».
Найпростіший приклад: припустімо, /health існує, але POST /health ми не підтримуємо. Це не «не знайдено». Це «неправильний метод». І так, для 405 в HTTP прийнято надсилати заголовок Allow зі списком допустимих методів. Сьогодні ми не реалізуємо заголовки (це вже механіка web-layer), але контрактно ми вже можемо передбачити, що в details з’явиться підказка з допустимими методами.
Приклад тіла помилки:
{
"errorCode": "METHOD_NOT_ALLOWED",
"message": "Метод не підтримується для цього шляху",
"details": ["method=POST", "allowed=GET"]
}
Зверніть увагу, чому це корисно навіть у навчальному проєкті. Коли ви тестуєте API в Postman, ви іноді випадково обираєте не той метод — буває, ви не перший. Якщо сервер чесно віддає 405 і каже «allowed=GET», ви миттєво розумієте, що сталося, і не починаєте копати не в той бік. Це економить вам час і знижує ймовірність «полагодити» API костилями на кшталт: «Ну давайте дозволимо POST, нехай він робить те саме, що GET». Ні, не треба. Нехай API каже правду.
409 Conflict: запит коректний, але конфліктує з поточним станом
409 звучить страшно («конфлікт!»), але насправді це дуже життєва ситуація. Сенс 409 у тому, що клієнт надіслав валідний запит, але виконати його в поточному стані застосунку не можна, тому що буде порушено прикладне правило. У нашому проєкті таким правилом буде унікальність externalId, якщо він заданий: не можна мати два елементи reading list, які посилаються на одну й ту саму зовнішню книгу.
Чому це не 400? Тому що запит сам по собі коректний. Поля нормальні, JSON нормальний, статус нормальний. Проблема не у формі даних, а в тому, що застосунок уже перебуває в стані, де цю дію виконати неможливо. І чому це не 500? Тому що це очікувана гілка логіки, а не аварія. Сервер не «зламався», він просто відмовився виконувати операцію, бо буде порушено правило.
Приклад відповіді:
{
"errorCode": "EXTERNAL_ID_CONFLICT",
"message": "Не можна зберегти два елементи з одним externalId",
"details": ["externalId=OL12345M"]
}
details тут особливо корисні: клієнт бачить, яке значення стало причиною конфлікту, і може виправити запит. А ми, як сервер, виглядаємо не як вередлива підліткова система («ні, бо ні»), а як система зі зрозумілими правилами.
500 Internal Server Error: неочікувана аварія
500 — це не «все, що мені лінь класифікувати». 500 — це «ми не очікували такого сценарію, це внутрішня проблема застосунку». Тобто це падіння на null, баг у коді, помилка перетворення, яку не було враховано, і будь-які інші ситуації, які не є нормальними гілками бізнес-логіки.
Найважливіше правило для 500: клієнту не потрібно знати подробиці вашої внутрішньої кухні. Не треба відправляти назовні NullPointerException, stack trace або шлях до файлів на вашій машині. По-перше, це марно: клієнт усе одно не виправить ваш код. По-друге, це шкідливо: ви розкриваєте внутрішність застосунку. Клієнту потрібен стабільний контракт: статус 500 і нейтральне повідомлення.
Приклад:
{
"errorCode": "INTERNAL_ERROR",
"message": "Внутрішня помилка застосунку",
"details": []
}
Якщо вам дуже хочеться додати деталей, додавайте не «технічний жах», а щось контрольоване: наприклад, внутрішній ідентифікатор помилки або коротке «unexpected error». Але в навчальному проєкті краще триматися простоти: порожні details і нормальна діагностика в логах.
4. Зведена таблиця помилок ReadLater
Щоб усе сказане вище не залишилося філософією, корисно зафіксувати мінімальний «словничок» помилок, який буде однаковим для всіх кінцевих точок локального API. Це не означає, що він ніколи не розшириться, але це означає, що ви не будете щоразу вигадувати новий errorCode під настрій.
| Ситуація (людською мовою) | HTTP-статус | errorCode | message (приклад) | details (приклад) |
|---|---|---|---|---|
| id не число (/reading-list/abc) | 400 | INVALID_REQUEST | Запит містить некоректні дані | ["id має бути числом"] |
| JSON пошкоджено / не розбирається | 400 | INVALID_REQUEST | Запит містить некоректні дані | ["пошкоджений JSON"] |
| status невідомий | 400 | INVALID_REQUEST | Запит містить некоректні дані | ["status має бути одним із: PLANNED, IN_PROGRESS, FINISHED"] |
| Елемент з id не знайдено | 404 | READING_ITEM_NOT_FOUND | Елемент списку читання не знайдено | ["id=42"] |
| Шлях існує, але метод не підтримується | 405 | METHOD_NOT_ALLOWED | Метод не підтримується для цього шляху | ["method=POST", "allowed=GET"] |
| externalId уже зайнятий іншим елементом | 409 | EXTERNAL_ID_CONFLICT | Не можна зберегти два елементи з одним externalId | ["externalId=OL12345M"] |
| Неочікувана помилка в коді | 500 | INTERNAL_ERROR | Внутрішня помилка застосунку | [] |
У цієї таблиці є проста мета: коли ви в майбутньому побачите в Postman 409 і EXTERNAL_ID_CONFLICT, ви не будете думати: «А чому не 400?». Тому що ви заздалегідь домовилися, що 409 — це конфлікт стану, а externalId — частина стану. І аналогічно, коли ви побачите 404 і READING_ITEM_NOT_FOUND, ви не почнете писати обробку невалідного запиту там, де запит валідний.
5. errorCode не розкидаємо рядками по проєкту
Коли початківець пише errorCode, він часто робить це прямо рядком у кожному місці: "INVALID_REQUEST", "INVALID-REQUEST", "invalid_request"… а потім дивується, чому клієнт не «впізнає» помилку. Це нормальна проблема. Найдешевший спосіб її уникнути — тримати стабільну лексику в одному enum і брати name() під час створення ErrorResponse.
Це не змінює JSON-контракт: назовні все одно виходить рядок. Зате всередині Java-коду ви перестаєте ловити друкарські помилки на кшталт INVALID-REQUEST замість INVALID_REQUEST.
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// У Java тримаємо коди в одному місці, а в JSON усе одно йде рядок із name()
ErrorResponse conflict = new ErrorResponse(
ErrorCode.EXTERNAL_ID_CONFLICT.name(),
"Не можна зберегти два елементи з одним externalId",
List.of("externalId=OL12345M")
);
Так у проєкту залишається один канонічний набір кодів, а не розсип рядків по всьому коду.
6. Типові помилки під час узгодження помилок
Помилка №1: «усі помилки — це 400, бо клієнт щось зробив не так».
Таке рішення здається швидким, доки ви не починаєте тестувати. У якийсь момент ви розумієте, що 400 означає все: і неіснуючий id, і зламаний JSON, і конфлікт externalId, і навіть внутрішній баг. Клієнт перестає розуміти, що сталося, а ви втрачаєте головний бонус HTTP: статуси як швидку мову діагностики.
Помилка №2: плутати 404 і 400 на id.
Коли id не число, це 400 («некоректний запит»). Коли id — число, але такого ресурсу немає, це 404 («ресурс не знайдено»). Помилка тут зазвичай психологічна: «ну раз не знайшли — значить 404». Ні: якщо id=abc, ви навіть не змогли сформувати нормальний запит до даних, ви не «шукали ресурс», ви не зрозуміли, що це за id.
Помилка №3: повертати 404 замість 405, щоб «не морочитися».
405 вимагає думати, які методи допустимі для шляху, і багатьом ліньки. Але саме 405 робить API чесним: «шлях існує, але метод неправильний». Це особливо допомагає під час ручного тестування і знижує ймовірність «приклеїти» зайві методи до endpoint просто тому, що так менше помилок.
Помилка №4: віддавати 500 для очікуваного конфлікту (externalId).
Якщо externalId має бути унікальним, конфлікт — очікувана гілка. Це не аварія. 500 тут змушує клієнта думати, що сервер зламався, хоча сервер просто захищає інваріант. Для таких ситуацій і існує 409 Conflict: «запит нормальний, але виконати його не можна через поточний стан».
Помилка №5: намагатися зробити 500 «дуже інформативним» і надсилати назовні виняток.
Новачку здається логічним: «Ну а як клієнт зрозуміє, що сталося, якщо не показати stack trace?» Спойлер: клієнт не має цього розуміти. Клієнт має зрозуміти, що сталася внутрішня помилка, і що можна зробити (зазвичай — повторити пізніше або звернутися до розробників). Технічні деталі мають жити в логах і налагодженні, а не в HTTP-відповіді.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ