1. Роль timestamp і requestId
Іноді здається, що добра відповідь з помилкою — це коли в ній умістилося «взагалі все»: і час, і ідентифікатори, і імʼя сервера, і імʼя кота, який завалив прод. Проблема в тому, що error contract — це публічна домовленість. Ми можемо розширювати ProblemDetail через додаткові поля, але маємо робити це дуже дисципліновано. Інакше клієнт опиниться у світі, де сьогодні поле є, завтра його немає, а післязавтра воно називається інакше.
timestamp і requestId — це якраз ті поля, які зазвичай дають реальну користь за невелику ціну. timestamp допомагає зрозуміти, коли сформувалася помилка, і зіставити її з логами та метриками навіть у простому проєкті. requestId допомагає пов’язати конкретну відповідь, яку побачив клієнт, із конкретним записом у логах сервісу. І ось тут важливий момент: їхня цінність не в тому, що вони «красиво виглядають у JSON», а в тому, що роблять підтримку та діагностику набагато менш схожими на археологію.
Нам потрібно втримати дві ідеї водночас. Перша — базового контракту вже достатньо: type/title/status/detail/instance + code розв’язують більшість проблем. Друга — якщо ми додаємо timestamp і requestId, то додаємо їх усюди однаково, щоб не отримати ефект «частина помилок нормальна, а частина — ніби з іншого проєкту».
Тобто йдеться не про новий формат помилок, а про два перехресні поля, які доповнюють уже зібраний контракт.
Невелика опорна табличка — не як догма, а як орієнтир:
| Поле | Звідки | Обов’язкове? | Навіщо |
|---|---|---|---|
| type | Problem Details | так | класифікація проблеми (тип) |
| title | Problem Details | так | коротка стабільна назва |
| status | Problem Details | так | статус у тілі відповіді |
| detail | Problem Details | так | конкретика поточного випадку |
| instance | Problem Details | так | «адреса» запиту (зазвичай шлях) |
| code | наш застосунок | так (за правилами проєкту) | стабільний код сценарію |
| fieldErrors | наш застосунок | ні | деталі за полями, якщо вони є |
| timestamp | наш застосунок | ні | коли сформовано відповідь |
| requestId | наш застосунок | ні | кореляція відповіді з логами |
2. Поле timestamp
Смішно, але поле timestamp часто перетворюється майже на релігійну суперечку рівня «що краще — два пробіли чи чотири». На практиці все простіше: якщо ви вирішили додавати timestamp, то головна мета — дати машиночитну та стабільну мітку часу, щоб її можна було порівнювати, логувати, сортувати й шукати, не вгадуючи формат. І тут «людська краса» зазвичай програє інженерній передбачуваності.
Перше правило — не використовуйте локальні формати й «красиві рядки». Рядок на кшталт "21.03.2026 12:34:56" виглядає затишно, але відразу породжує запитання: який часовий пояс? Яка локаль? Де секунди, а де хвилини? Чому крапки, а не двокрапки? Тому для API майже завжди обирають ISO-8601. У Java найприродніший тип для цього — Instant (UTC timestamp), і його toString уже дає ISO-рядок.
Якщо ми додаємо timestamp як додаткове поле в ProblemDetail, це виглядає буквально в один рядок:
import java.time.Instant;
import org.springframework.http.ProblemDetail;
// Додаємо timestamp як додаткове поле в ProblemDetail.
// Зберігаємо саме рядок у форматі ISO-8601, щоб формат не залежав від налаштувань серіалізації.
pd.setProperty("timestamp", Instant.now().toString());
Зверніть увагу на приємну дрібницю: ми кладемо рядок, а не Date і не «кастомний об’єкт часу». Так, Jackson уміє серіалізувати Instant, але для навчального проєкту та стабільності формату простіше й прозоріше зберігати ISO-рядок явно. Це менше сюрпризів, особливо якщо хтось потім почне змінювати глобальні налаштування серіалізації часу, а таке іноді трапляється — не зі злого умислу, а просто тому, що «здалося зручнішим».
Ще один корисний нюанс: timestamp — це час формування error response, а не «момент, коли у світі сталася проблема». Якщо у вас зламалася файлова система, сама проблема могла виникнути раніше, але timestamp відповідає на запитання: «коли сервер сформував цю відповідь». У нашому Task Tracker API це цілком чесна й достатня семантика.
Якщо ви раптом ловите себе на думці «а давайте туди ще покладемо serverTimeZone, serverName, environment, nodeId…», зупиніться та запитайте себе: це точно потрібно клієнту? Чи це зручніше тримати в логах? У більшості звичайних REST API це радше внутрішня діагностична інформація, яку клієнт не зобов’язаний бачити.
3. Поле requestId
Якщо timestamp відповідає на запитання «коли», то requestId відповідає на запитання «де саме це сталося в наших логах». Уявіть звичайну ситуацію: клієнт або фронтенд надіслав вам баг-репорт — «іноді під час створення задачі отримуємо 500». Це приблизно як сказати лікарю: «іноді мені зле». Співчуваємо, але лікувати складно. А якщо до повідомлення додається requestId, то це вже схоже на «ось номер мого талончика, подивіться за ним у журналі».
Важливо розуміти, що requestId — це не «ID користувача», не «ID задачі» і взагалі не бізнес-ідентифікатор. Це технічний ідентифікатор конкретного HTTP-запиту. Він має бути достатньо унікальним, щоб ви могли швидко знайти відповідні рядки в логах. На практиці це часто UUID.
Звідки його взяти? Є два популярні підходи, і обидва можна поєднати:
- Ми можемо приймати requestId від клієнта (наприклад, із заголовка X-Request-Id), якщо клієнт сам уміє проставляти кореляційні ідентифікатори. Це зручно, коли запит проходить через кілька систем, і в усіх уже є спільний ID. Але при цьому сервер не має сліпо вірити всьому, що прийшло: хоча б банально обмежити довжину й не дозволяти покласти в requestId мегабайт тексту.
- І другий шлях — генерувати requestId на сервері, якщо клієнт його не надіслав. Для навчального проєкту це абсолютно нормальний базовий варіант: клієнт не зобов’язаний щось робити, а сервер усе одно видає ID.
Ще один практичний момент: багато команд додають requestId не лише в тіло помилки, а й у response header — також X-Request-Id. Це зручно, бо фронтенд і API Gateway часто простіше логують заголовки, ніж розбирають тіло відповіді. У нашому курсі це можна сприймати як «приємний бонус», але не як обов’язкову інфраструктурну систему.
4. Генерація requestId у Spring Boot
Найзрозуміліший спосіб отримати requestId для кожного запиту у Spring MVC-застосунку — поставити невеликий servlet filter, який спрацює до контролерів і до @ControllerAdvice. Цей фільтр або бере ID із заголовка, або генерує новий, а потім кладе його у два місця: в атрибут запиту, щоб легко дістати його з HttpServletRequest, і в MDC, щоб логер міг автоматично підхопити його в кожному рядку логів.
Почнемо з простого шматка: взяти ID із заголовка або створити новий.
import java.util.UUID;
import jakarta.servlet.http.HttpServletRequest;
// Беремо requestId від клієнта, якщо він уміє його проставляти.
String requestId = request.getHeader("X-Request-Id");
// Якщо заголовка немає або він порожній — генеруємо новий ідентифікатор на боці сервера.
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
Тепер ми хочемо, щоб цей ID був доступний у коді та в логах. Атрибут запиту — найпряміший шлях: його зручно читати в @ExceptionHandler.
import jakarta.servlet.http.HttpServletRequest;
// Зберігаємо requestId в атрибуті запиту, щоб потім дістати його в обробниках помилок.
request.setAttribute("requestId", requestId);
А MDC — це такий локальний контейнер для поточного потоку, який логер, якщо налаштований pattern, може підмішувати в кожен рядок. Навіть якщо ви зараз не налаштовуєте логування, сам підхід легко пояснити: «кладемо туди requestId, щоб він подорожував разом із логами цього запиту».
import org.slf4j.MDC;
// Кладемо requestId в MDC, щоб логер міг автоматично додавати його в рядки логів.
MDC.put("requestId", requestId);
І ось найважливіша частина, на якій часто спотикаються: MDC треба очищати, інакше в пулі потоків ви можете отримати «привид requestId» від попереднього запиту. Тому очищаємо його в finally.
import org.slf4j.MDC;
try {
chain.doFilter(request, response);
} finally {
// Важливо: очищаємо MDC, інакше requestId може "просочитися" в наступний запит у цьому ж потоці.
MDC.remove("requestId");
}
Зберімо це в максимально простий фільтр. Так, він трохи довший за «ідеальні 10 рядків», але тут кожен рядок щось робить, і це добрий навчальний компроміс:
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1) Беремо requestId із заголовка, якщо клієнт його надіслав, інакше генеруємо UUID.
String requestId = request.getHeader("X-Request-Id");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString();
}
// 2) Робимо requestId доступним усередині обробки запиту (наприклад, у @ExceptionHandler).
request.setAttribute("requestId", requestId);
// 3) Додаємо requestId в MDC, щоб він автоматично потрапляв у логи.
MDC.put("requestId", requestId);
// 4) Дублюємо requestId в response header — зручно для фронтенду та проксі.
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(request, response);
} finally {
// 5) Обов’язково очищаємо MDC (важливо при пулі потоків).
MDC.remove("requestId");
}
}
}
Залишилося зареєструвати фільтр. У Spring Boot часто достатньо просто створити bean:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RequestIdConfig {
@Bean
RequestIdFilter requestIdFilter() {
// Реєструємо фільтр у контексті Spring, щоб він застосовувався до всіх запитів.
return new RequestIdFilter();
}
}
Чому фільтр — гарне місце? Тому що він гарантовано спрацює і для звичайних запитів, і для запитів, які зламаються ще до контролера, наприклад на етапі десеріалізації JSON. Це саме те, що нам потрібно: requestId має бути доступний навіть тоді, коли «нічого ще не встигло початися».
5. Збагачення ProblemDetail
Найчастіша проблема з додатковими полями — не «як їх додати», а «як додати так, щоб вони не зникали в половині відповідей». Якщо ви вручну дописуєте timestamp в одному @ExceptionHandler, а в іншому забуваєте, то клієнт знову отримує два різні контракти помилок. Тому наш головний фокус — централізувати додавання полів.
У Task Tracker API у нас уже є GlobalExceptionHandler на базі @ControllerAdvice. Чудова ідея — зробити невеликий метод, який «збагачує» ProblemDetail стандартними додатковими полями. Тоді кожен обробник винятку виглядатиме однаково: зібрати базовий ProblemDetail, а потім викликати enrich.
Важливо: enrich(...) не замінює базовий набір type/title/status/detail/instance/code. Він дописує до вже зібраного ProblemDetail лише перехресні поля, які однаково корисні для 404, 400, 409 і помилок фреймворку.
Приклад такого допоміжного методу:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ProblemDetail;
import java.time.Instant;
private void enrich(ProblemDetail pd, HttpServletRequest request) {
// requestId беремо з атрибута запиту: туди його поклав наш фільтр.
Object requestId = request.getAttribute("requestId");
if (requestId != null) {
pd.setProperty("requestId", requestId);
}
// timestamp — час формування саме цієї відповіді, а не "момент, коли зламався світ".
pd.setProperty("timestamp", Instant.now().toString());
}
Тепер в обробнику доменного винятку код стає коротшим і не «плаває» від методу до методу:
import com.example.tasktracker.api.dto.error.ApiErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.net.URI;
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex, HttpServletRequest request) {
// Спочатку збираємо той самий базовий каркас помилки, що й раніше.
ProblemDetail pd = appProblem(
HttpStatus.NOT_FOUND,
URI.create("/problems/task-not-found"),
"Задачу не знайдено",
"Задачу з id " + ex.getTaskId() + " не знайдено",
ApiErrorCode.TASK_NOT_FOUND,
URI.create(request.getRequestURI())
);
// Потім централізовано додаємо діагностичні поля.
enrich(pd, request);
return pd;
}
Але є ще один тонкий момент, дуже схожий на production-like сценарій: частина помилок обробляється не нашими @ExceptionHandler методами, а базовою механікою ResponseEntityExceptionHandler. Наприклад, malformed JSON або невідповідність типів параметрів може потрапити до стандартних обробників. Якщо ми хочемо, щоб timestamp і requestId з’являлися і там теж, нам потрібно вибрати одне місце, через яке проходять ці відповіді.
Один із зручних варіантів — перевизначити handleExceptionInternal і, якщо body — це ProblemDetail, додати туди наші додаткові поля. Тут я використовую MDC.get, тому що в цій точці в нас не завжди є HttpServletRequest під рукою в явному вигляді:
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
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.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
// Якщо фреймворк уже сформував ProblemDetail, то дописуємо до нього наші поля.
if (body instanceof ProblemDetail pd) {
pd.setProperty("timestamp", Instant.now().toString());
// requestId беремо з MDC (його поклав фільтр на початку запиту).
String requestId = MDC.get("requestId");
if (requestId != null) {
pd.setProperty("requestId", requestId);
}
}
return super.handleExceptionInternal(ex, body, headers, status, request);
}
Сенс цього трюку дуже практичний: ви один раз «вшиваєте» обов’язкові розширення в загальний ланцюжок обробки, і потім не ловите баги формату «malformed JSON повернув ProblemDetail без requestId, і підтримка знову страждає».
Тут важливо не піти в «занадто хитро». Ми не будуємо повноцінну інфраструктуру розподіленого трасування, не тягнемо OpenTelemetry, не малюємо діаграми, гідні кандидатської. Ми просто хочемо: кожна помилка → один формат → однакові поля → однакові назви → однаковий сенс.
6. Антипатерни в error contract
Дуже підступна річ: можна щиро вважати, що «ми використовуємо ProblemDetail, отже все добре», і все одно зробити API незручним та непередбачуваним. Антипатерни зазвичай з’являються не зі злого умислу, а з серії «мені так швидше» або «клієнту ж простіше, якщо завжди 200». Давайте проговоримо найчастіші граблі, які особливо небезпечні саме на етапі додавання timestamp/requestId.
Один із найруйнівніших антипатернів — маскувати помилки під успіх. Це виглядає приблизно так:
import java.util.Map;
// Погано: 200 OK із тілом "помилки"
return Map.of("status", 200, "message", "Задачу не знайдено");
На рівні людини це «ну, начебто зрозуміло», а на рівні клієнтського коду це катастрофа. Клієнт бачить 200 і думає: «успіх», починає обробляти відповідь як успішну, а потім падає десь глибоко в іншому місці. Статус-код — це частина контракту, і якщо він бреше, то контракт уже зруйновано.
Другий антипатерн — помилка в одному рядку. Навіть якщо ви віддаєте правильний статус 400, але тіло на кшталт "invalid request" або {"message":"invalid request"}, ви змушуєте клієнта вгадувати. У нашому проєкті ми спеціально ввели code і fieldErrors, щоб не парсити текст. Тому «один рядок» — це крок назад.
Третій антипатерн — неузгоджені назви полів. Сьогодні ви зробили "requestId", завтра в одному обробнику написали "reqId", а післязавтра в ще одному — "correlationId". У результаті клієнту потрібно писати три різні варіанти обробки. Найсмішніше, що серверу це може здаватися «незначною дрібницею», а для клієнта це три різні контракти.
Четвертий антипатерн — різний формат часу в timestamp. В одному місці "2026-03-21T12:34:56Z", в іншому "2026-03-21 12:34:56", у третьому "Sat Mar 21 12:34:56 UTC 2026". Клієнту знову доводиться вгадувати, а якщо він будує якусь діагностику або відображення — усе стає нестабільним. Час має бути в одному форматі всюди, і ISO-8601 — найпрагматичніший варіант.
П’ятий антипатерн — витік внутрішніх деталей. Дуже легко в пориві чесності зробити так:
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
ex.getMessage() // Погано: сирий message часто містить внутрішні деталі
);
Проблема в тому, що ex.getMessage() може містити назви класів, шляхи до файлів, технічні деталі — усе, що ми не хочемо перетворювати на публічний договір. Для користувача та клієнта потрібне формулювання в межах API, а не в межах Java-стеку.
Шостий антипатерн — «зайві поля заради полів». Коли ми дізнаємося, що можна робити pd.setProperty(...), з’являється спокуса перетворити error response на склад: додати debugInfo, internalErrorCode, stackTrace, exceptionClass, serverThread. Це робить помилку довгою, шумною й часто небезпечною. Якщо поле не потрібне клієнту для ухвалення рішення або для нормальної діагностики через підтримку, то місце йому в логах, а не в публічному контракті.
Сьомий антипатерн — частковий контракт. Наприклад, requestId додали лише в TaskNotFoundException, але забули про validation errors, і тепер фронтенд не може корелювати проблеми валідації з логами. Або навпаки: додали requestId у validation, але забули в 409 конфлікт. Для клієнта це виглядає як «іноді сервер нормальний, а іноді дивний». Саме тому ми й робили централізоване збагачення ProblemDetail.
Приклади відповідей application/problem+json
Тепер зберімо в голові, як це виглядатиме в реальній відповіді. Я покажу JSON-форму так, як її побачить клієнт. Пам’ятаємо, що це application/problem+json, а поля code, timestamp, requestId, fieldErrors — наші додаткові поля.
Приклад 404 Не знайдено для відсутньої задачі:
{
"type": "/problems/task-not-found",
"title": "Задачу не знайдено",
"status": 404,
"detail": "Задачу з id 2dfc2e73-3c87-4b84-bb78-6b9f88c9c1a1 не знайдено",
"instance": "/api/v1/tasks/2dfc2e73-3c87-4b84-bb78-6b9f88c9c1a1",
"code": "TASK_NOT_FOUND",
"timestamp": "2026-03-21T12:34:56Z",
"requestId": "7f3c4f54-2a9e-4b1a-9c41-3a3d8dcb6f8a"
}
Приклад 400 Некоректний запит для validation failure, де ми додаємо fieldErrors:
{
"type": "/problems/invalid-input",
"title": "Некоректне введення",
"status": 400,
"detail": "Перевірку запиту не пройдено",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{ "field": "title", "code": "NotBlank", "message": "title не має бути порожнім" },
{ "field": "tags[1]", "code": "Size", "message": "довжина тега має бути від 1 до 30" }
],
"timestamp": "2026-03-21T12:35:10Z",
"requestId": "a1b19d9d-0b71-4d67-8b8b-58c1edc2e5aa"
}
І приклад чесного 409 Конфлікт для забороненого переходу статусу:
{
"type": "/problems/invalid-status-transition",
"title": "Некоректний перехід статусу",
"status": 409,
"detail": "Задача зі статусом ARCHIVED не може перейти до IN_PROGRESS",
"instance": "/api/v1/tasks/42/status",
"code": "INVALID_STATUS_TRANSITION",
"fromStatus": "ARCHIVED",
"toStatus": "IN_PROGRESS",
"timestamp": "2026-03-21T12:36:02Z",
"requestId": "c42c6b11-3e3b-4c6a-8f4b-0d9a61c5c6f0"
}
Зверніть увагу на головне: форма одна й та сама, поля timestamp і requestId присутні всюди, і клієнту не потрібно писати «а якщо це validation, то timestamp лежить тут, а якщо not found — то там». Це й є мета цілісного контракту.
7. Типові помилки під час додавання timestamp і requestId
Помилка №1: requestId є у фільтрі, але не очищується з MDC.
Це класична проблема «чому в логах у різних запитів один і той самий requestId». Якщо потік із пулу обслуговував один запит, а потім інший, MDC може зберегти старе значення. У результаті ви намагаєтеся знайти один запит за requestId і знаходите одразу кілька, ніби у вас не API, а серіал із переплутаними персонажами. Рішення просте: завжди MDC.remove(...) у finally.
Помилка №2: timestamp форматують локально, і він плаває між середовищами.
Коли поле часу збирають із LocalDateTime.now() і потім форматують «якось», ви отримуєте різні рядки на різних машинах, у різних локалях і часових поясах. Клієнт не зобов’язаний вгадувати ці правила. Для API найпростіше ISO-8601 через Instant.now().toString() — і не думати про часові пояси на рівні контракту.
Помилка №3: requestId іноді в тілі, іноді лише в заголовку, іноді ніде.
Таке зазвичай з’являється «випадково»: один розробник додав header, інший — body, третій — нічого. Клієнту та підтримці важливо, щоб requestId був в одному зрозумілому місці. У нашому проєкті логіка проста: заголовок можна віддавати всім, а в тілі помилки requestId має бути завжди, якщо його ввімкнено.
Помилка №4: додаткові поля додаються лише в доменні @ExceptionHandler, але не додаються в framework errors.
Дуже прикрий кейс: ви все красиво зробили для TaskNotFoundException, але malformed JSON повертає ProblemDetail без requestId, і саме ці помилки прилітають найчастіше на ранніх етапах інтеграції. Тому корисно централізовано дописувати поля в загальному місці (handleExceptionInternal або спільний helper), а не «вручну в кожному обробнику».
Помилка №5: у detail кладеться ex.getMessage(), і назовні витікають внутрішні деталі.
Повідомлення винятків часто пишуться для розробника, а не для клієнта. Вони можуть містити назви класів, технічні параметри, іноді навіть шматки вхідних даних. У публічному API краще формулювати detail окремо: зрозуміло, коротко, без внутрішньої будови застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ