JavaRush /Курси /Spring REST & MVC /Що не можна віддавати клієнту

Що не можна віддавати клієнту

Spring REST & MVC
Рівень 19 , Лекція 3
Відкрита

1. Бажання повернути виняток і наслідки

Коли ви тільки починаєте писати API, найприродніша реакція на збій — показати клієнту те, що «і так уже є»: виняток, його повідомлення і, якщо пощастить, стек викликів. Здається, що так швидше: фронтендер одразу побачить, де помилка, і перестане ставити запитання. На жаль, це як лікувати головний біль гільйотиною: проблема справді зникає… разом із пацієнтом.

На практиці «повернути виняток як є» ламає одразу три речі. По-перше, ви починаєте виносити внутрішню структуру сервера назовні, і це не просто некрасиво — іноді це небезпечно. По-друге, ви робите публічний контракт нестабільним: змінили клас, оновили бібліотеку, змінився текст винятку — і клієнт раптово «зламався», бо, не дай Боже, парсив повідомлення як рядок. По-третє, ви віддаєте людині (і коду клієнта) шум замість сенсу: клієнту важливо зрозуміти що сталося із запитом, а не прочитати мініроман «Як JVM сумувала у трьох томах».

Контракт помилки і внутрішня діагностика

Щоб перестати плутатися, корисно подумки розділити світ на двох адресатів однієї й тієї самої неприємності. Перший адресат — API-клієнт (браузер, мобільний застосунок, інший сервіс). Йому потрібна компактна, стабільна й зрозуміла інформація: який статус, що пішло не так, що можна виправити. Другий адресат — ви та ваша команда. Вам потрібна діагностика: стек викликів, тип винятку, контекст, щоб виправляти баги.

Ці адресати не просто різні — вони виконують різні завдання, і тому їм не можна віддавати однакові дані.

Непогана схема, щоб тримати це в голові:

flowchart TD
    %% Дві гілки: одна для внутрішньої діагностики, друга — для публічної відповіді клієнту
    A[HTTP-запит] --> B[Контролер / сервіс]
    B -->|Помилка| C[Виняток у коді]
    C --> D[Внутрішня діагностика: логи, стек викликів]
    C --> E[Публічна відповідь: статус і зрозумілий опис]

Зверніть увагу: ми не обговорюємо як саме технічно робити єдиний перехоплювач помилок на рівні всього застосунку — це буде наступним кроком. Зараз нам важливо інше: навіть якщо ви тимчасово обробляєте помилку «вручну» в одному місці, дані для гілки D і дані для гілки E мають відрізнятися.

2. Стек викликів і місце у відповіді API

Стек викликів — це докладний список методів, які виконувалися, поки все не впало. Для розробника це золота інформація: видно, де саме сталася помилка, які класи брали участь, які бібліотеки є в ланцюжку. Але для клієнта стек викликів майже завжди — це сміття: він довгий, багаторядковий, містить внутрішні назви класів і пакетів, а іноді ще й шляхи до файлів, номери рядків і шматки технічних повідомлень.

Ось типовий антиприклад у стилі «ну зате інформативно»:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DemoController {

    @ExceptionHandler(Exception.class)
    ResponseEntity<String> handle(Exception ex) {
        // Погано: ми віддаємо клієнту внутрішні деталі сервера (тип винятку/повідомлення).
        // Клієнту це не допомагає, зате розкриває реалізацію.
        return ResponseEntity.status(500).body(ex.toString());
    }
}

Навіть якщо тут повернути ex.toString(), ви вже розкриваєте тип винятку. А якщо піти далі й додати ex.getStackTrace() (або взагалі серіалізувати весь Exception), ви отримаєте у відповіді здоровенну «простиню». У найкращому разі це просто некрасиво. У гіршому — ви даруєте зловмиснику карту внутрішніх технологій і підказки, куди «тикати» далі.

3. Внутрішні імена класів і пакетів

Навіть якщо ви не віддаєте повний стек викликів, багато хто ведеться на «нешкідливу» ідею: повернути клієнту ex.getClass().getName() або хоча б ex.getClass().getSimpleName(). Здається, що це чесно: клієнт же має знати, що зламалося. Але насправді це знову змішування двох світів: клас винятку — це деталь реалізації, а публічний контракт має описувати помилку мовою API.

Уявіть, що клієнт отримав ось таке:

{
  "error": "com.example.tasktracker.domain.exception.TaskNotFoundException"
}

Для API-клієнта це не додає сенсу. Він усе одно не знає ваш код і не зобов’язаний знати ваші пакети. Зате тепер ви не можете спокійно перейменувати пакет, винести винятки в інший модуль, змінити ієрархію винятків — формально ви змінили зовнішній контракт (навіть якщо не хотіли). І так, це виглядає дивно: ніби ресторан замість «суп закінчився» каже Kitchen.StockService.NoMoreSoupException.

Тому правило просте: імена Java-класів — це внутрішня діагностика, їм місце в логах, але не в JSON-відповіді клієнту.

4. Сирі повідомлення винятків: ex.getMessage()

Тепер найпідступніший пункт: ex.getMessage(). Він здається «людяним», тому що це рядок. Але саме через цю «простоту» його найчастіше й починають бездумно віддавати назовні. Проблема в тому, що повідомлення винятку практично ніколи не є стабільним, орієнтованим на клієнта формулюванням.

По-перше, повідомлення бувають суто технічними і розрахованими на розробника. NullPointerException може взагалі не мати повідомлення або мати повідомлення, що залежить від версії JDK. DateTimeParseException може видати текст, де половина — це деталі парсингу. HttpMessageNotReadableException (який часто виникає при зламаному JSON) може містити «внутрішню кухню» Jackson. Якщо показувати клієнту це без змін, вийде щось на кшталт: Cannot deserialize value of type ... from String .... Це не мова вашого API. Це мова бібліотеки.

По-друге, повідомлення нестабільні. Вони змінюються між версіями бібліотек і фреймворку. Сьогодні ви оновили Spring Boot або Jackson — і текст став іншим. Якщо десь на клієнті (або в документації) хтось спирався на конкретне формулювання, ви отримали «прихований breaking change». Найсумніше — такі баги дуже неприємно розслідувати: сервер-то працює, статус правильний, а фронт «чомусь не розпізнає помилку».

По-третє, у повідомленні винятку іноді опиняється шматок користувацького введення. Це може бути і нормально, і корисно, але без контролю ви легко отримаєте інʼєкцію сміття в логи або «брудний» текст у відповіді. Клієнт може випадково або спеціально надіслати рядок, який виглядає як HTML, як SQL, як завгодно. Повернути його назад у відповіді — погана ідея, особливо якщо далі хтось відобразить його в UI без екранування.

Якщо хочеться короткої формули: ex.getMessage() — це діагностична підказка, а не публічний контракт.

5. Зрозумілі помилки мовою API

Якщо ми не віддаємо клієнту стек викликів, імена класів і сирі повідомлення, то що лишається? Лишається найкорисніше: зрозумілий опис проблеми в термінах вашого API. У контексті Task Tracker API клієнт оперує такими поняттями: «я створюю завдання», «я отримую завдання за id», «я намагаюся змінити статус», «я прикріплюю файл». Отже, і помилки мають бути описані цією мовою.

На мінімальному рівні вам зазвичай достатньо трьох речей. Клієнту потрібен статус (він і так є в HTTP), короткий заголовок («що сталося») і деталізація («що саме не так»). Навіть якщо ви поки не використовуєте стандартний формат помилок (до нього ми скоро дійдемо), важливо вміти формулювати контрольований текст, який:

1) не залежить від внутрішніх класів,

2) не «стрибає» від версії бібліотек,

3) не тягне назовні зайві деталі.

Одразу важливе застереження: цей record — не формат, який варто закріплювати як основний для всього API. Нам тут потрібен лише тимчасовий контейнер, щоб відокремити контрольований публічний текст від внутрішньої діагностики.

Приклад найпростішої «публічної» моделі:

// Публічна модель помилки: лише те, що ми свідомо готові показувати клієнту.
public record PublicError(String title, String detail) {
}

І приклад того, як можна описати сценарій «не знайдено» мовою API:

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

ResponseEntity<PublicError> notFound(String taskId) {
    // Добре: формуємо текст як частину контракту API, а не беремо його з винятку.
    PublicError body = new PublicError(
            "Завдання не знайдено",
            // У деталь можна акуратно включати введені користувачем дані, якщо ви контролюєте формат.
            "Завдання з id '%s' не знайдено".formatted(taskId)
    );

    // Добре: повертаємо семантично правильний HTTP-статус.
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}

Зверніть увагу: тут ми не використовуємо ex.getMessage(). Ми будуємо текст як частину контракту. Так, це трохи більше роботи. Зате це робота, яка робить API дорослим.

Цього контейнера достатньо, щоб відчути принцип. Але щойно таких відповідей стає багато, свій record швидко перетворюється на ще один саморобний міністандарт. Отже, тут потрібен не черговий PublicError, а один спільний формат error body для всього API.

6. Деталі — в логах

Якщо клієнту не можна віддавати внутрішню діагностику, виникає логічне запитання: «А де ж її тримати, щоб ми самі могли виправляти помилки?» Відповідь проста і нудна (а отже — правильна): у логах. Логи — це місце, де доречні і стек викликів, і внутрішні класи, і будь-які деталі реалізації.

Для початку вам достатньо базового підходу: коли стається технічна помилка, логуємо її разом із винятком, а назовні віддаємо контрольований опис. У сервісному шарі це виглядає приблизно так:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TaskService {

    private static final Logger log = LoggerFactory.getLogger(TaskService.class);

    public void doSomethingRisky() {
        try {
            riskyOperation();
        } catch (RuntimeException ex) {
            // У логах можна (і треба) залишити стек викликів: це внутрішня діагностика.
            log.error("Неочікувана технічна помилка в TaskService", ex);

            // Тут ми не «перетворюємо помилку на null» і не підміняємо її.
            // Нехай вищий шар (наприклад, обробник винятків) вирішить, що віддати клієнту.
            throw ex;
        }
    }

    private void riskyOperation() {
        // Тут могла бути робота з БД, зовнішнім сервісом, файлом тощо.
    }
}

Тут важлива сама ідея. Ми не «ховаємо» помилку, не вдаємо, що все добре, і не перетворюємо збій на загадковий null. Ми чесно фіксуємо діагностику там, де їй місце — у логах, — і не тягнемо її в публічний API.

І ще одна важлива думка: логувати теж треба з головою. Не треба логувати повністю тіло запиту на кожен виклик, особливо якщо там можуть бути персональні дані. Не треба логувати величезні об’єкти. Логи — це не смітник, це інструмент діагностики. Але це вже тонке налаштування інженерної культури; сьогодні нам важливий фундамент: діагностика залишається всередині сервера.

7. Мініприклад у Task Tracker API

Щоб пов’язати це з нашим проєктом, візьмемо сценарій GET /api/v1/tasks/{taskId}. Уявімо, що сервіс кидає TaskNotFoundException, якщо завдання немає. Найгірший спосіб обробити це — упіймати виняток і повернути назовні все підряд.

Антиприклад:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

public class TaskController {

    @GetMapping("/api/v1/tasks/{taskId}")
    public ResponseEntity<?> getById(@PathVariable String taskId) {
        try {
            Object task = findTask(taskId);
            return ResponseEntity.ok(task);
        } catch (Exception ex) {
            // Погано: віддаємо назовні "тип + повідомлення" з винятку.
            // Це нестабільно і може витікати внутрішніми деталями (БД, таблиці, технології).
            return ResponseEntity.status(500).body(ex.toString());
        }
    }

    private Object findTask(String taskId) {
        // Приклад технічної деталі, яка взагалі не має потрапляти у відповідь API.
        throw new RuntimeException("База даних недоступна, таблицю TASKS не знайдено");
    }
}

Проблем тут кілька, але для нашої лекції ключова — ви віддаєте назовні ex.toString(), тобто «тип + повідомлення». А повідомлення може містити що завгодно, включно з внутрішніми деталями. Плюс ви ще й повертаєте 500 взагалі на все, навіть якщо проблема не в сервері, а в тому, що завдання немає.

Тепер зробимо краще, але тримаємо в голові обмеження: PublicError нижче — усе ще тимчасовий контейнер. Сенс прикладу не в тому, щоб зафіксувати свій формат помилки, а в тому, щоб не тягнути ex.toString() назовні. Ми формуємо контрольоване тіло й віддаємо зрозумілий статус. Діагностику, якщо вона потрібна, логуємо.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

public class TaskController {

    private static final Logger log = LoggerFactory.getLogger(TaskController.class);

    public ResponseEntity<?> getById(String taskId) {
        try {
            Object task = findTask(taskId);
            return ResponseEntity.ok(task);
        } catch (TaskNotFoundException ex) {
            // Добре: доменна або бізнес-помилка -> зрозумілий статус і зрозумілий текст для клієнта.
            // Ми не використовуємо ex.getMessage() і не світимо імʼя класу винятку.
            PublicError body = new PublicError(
                    "Task not found",
                    "Task with id '%s' was not found".formatted(taskId)
            );
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
        } catch (Exception ex) {
            // Добре: технічна помилка -> подробиці в логах.
            // {} — плейсхолдер для taskId, ex передаємо окремо, щоб отримати stack trace в логах.
            log.error("Технічна помилка під час отримання завдання {}", taskId, ex);

            // Назовні віддаємо контрольований текст без внутрішніх деталей.
            PublicError body = new PublicError(
                    "Internal error",
                    "Під час обробки запиту сталася неочікувана помилка"
            );
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
        }
    }

    private Object findTask(String taskId) {
        // Для прикладу: доменна помилка "не знайшли завдання" представлена окремим винятком.
        throw new TaskNotFoundException(taskId);
    }
}

Архітектурно це перехідний варіант, зате на ньому добре видно головне. Клієнт отримує зрозуміле повідомлення мовою API, а сервер зберігає деталі для себе. І щойно такий PublicError починає повторюватися по контролерах, стає зрозуміло: потрібен один стандартний формат error body, а не свій record на кожен випадок.

8. Типові помилки під час відповідей про помилки

Коли ви почнете «чистити» відповіді про помилки, перші проблеми зазвичай з’являються не через брак знань Spring, а через людські звички. Дуже хочеться «щоб клієнт швидше зрозумів», «щоб фронтенду не доводилося відволікатися», «щоб тести було простіше писати». Це зрозумілі бажання, але вони часто призводять до того, що API перетворюється на звалище внутрішньої інформації.

Помилка №1: повертати stack trace “тільки на dev”, а потім забути вимкнути.
Таке трапляється частіше, ніж хочеться визнати. Спочатку ви робите «зручний режим налагодження», потім у вас з’являється другий профіль, третій, потім staging, потім “ой, а чому у клієнта в проді у відповіді шлях /home/jenkins/workspace/...?”. Якщо ви будуєте API як контракт, краще відразу звикати: stack trace живе в логах, а не у відповіді.

Помилка №2: вважати, що імʼя винятку — це «код помилки».
Назва класу здається хорошим ідентифікатором, але це ідентифікатор для Java-коду, а не для зовнішнього контракту. Вона змінюється під час рефакторингу і несе внутрішні деталі. Клієнтський «код помилки» (якщо він вам потрібен) має бути окремою сутністю, не залежною від пакетів і класів.

Помилка №3: віддавати ex.getMessage() так, ніби це текст для користувача.
Повідомлення винятків рідко формулюються “по-людськи” і ще рідше залишаються стабільними. Плюс вони можуть змінюватися між версіями бібліотек. Навіть якщо конкретне повідомлення сьогодні виглядає пристойно, завтра ви оновите залежність — і отримаєте нове формулювання, яке ламає клієнту UX або тести.

Помилка №4: змішувати публічний сенс і внутрішню діагностику в одному полі.
Часто видно відповіді на кшталт: “Task not found. NullPointerException at TaskRepository.findById(TaskRepository.java:42)”. Це спроба одночасно догодити всім, але зазвичай виходить погано. Клієнту не потрібен номер рядка в репозиторії, а розробнику не потрібна «казочка» в стилі “task not found” без контексту. Розділяйте: публічний сенс — у відповідь, внутрішня діагностика — в логи.

Помилка №5: робити текст помилки занадто загальним, щоб «нічого не витекло».
Протилежна крайність — віддавати на все Error або Something went wrong. Формально ви нічого не «злили», але контракт став безкорисним. Клієнту потрібні хоча б зрозумілі категорії мовою API: Task not found, Invalid input, Operation is not allowed. Без цього клієнт не може нормально реагувати, а користувачу важко виправити запит.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ