1. HTTP-статус как смысловой сигнал
Когда мы только начинаем писать API, статус-код часто воспринимается как формальность: «ну вернулось же что-то, значит 200». Но в реальности статус — это как дорожный знак, который клиент видит раньше, чем читает тело ответа. И если знак врёт, клиент едет в кювет.
На практике клиентское приложение (даже простейший скрипт) почти всегда сначала принимает решение по статусу: можно ли повторить запрос, стоит ли показывать пользователю форму исправления, нужно ли переключиться на другой экран. Разница между 4xx и 5xx особенно важна: 4xx чаще говорит «клиент, ты что-то не так сделал», а 5xx — «сервер сломался, у клиента обычно нет способа это исправить». И если вы перепутаете эти смысловые сигналы, клиентская логика станет хрупкой и непредсказуемой.
Хорошая новость: нам не нужно знать все HTTP-статусы наизусть, как таблицу умножения. Плохая новость: пять-шесть ключевых статусов нужно выбирать сознательно, иначе вы сделаете “API на удачу”.
2. Карта соответствий статусов
Если пытаться выбирать статус “по настроению”, быстро получается комедия: один контроллер отдаёт 400 за отсутствие ресурса, другой — 500 за бизнес-правило, третий — 200 с текстом "error": "oops". Поэтому нам нужна простая карта: какой смысл — такой статус. В нашем Task Tracker API мы фиксируем минимальный, но рабочий набор.
Ниже — таблица, которая даёт быстрый ориентир. Она не заменяет мышление, но здорово экономит время и помогает не спорить с коллегами в стиле «мне кажется, тут 400… нет, 409… или 404». Если вы можете объяснить свой выбор в терминах этой таблицы — вы почти наверняка выбрали правильно.
| Статус | Как это звучит “по-человечески” | Типичная категория | Примеры в Task Tracker API |
|---|---|---|---|
| 400 Bad Request | «Запрос не соответствует контракту, исправь input» | validation / input | невалидный title, неправильная дата, malformed JSON, page=abc |
| 404 Not Found | «Запрошенного ресурса не существует» | not found | GET /tasks/{taskId} с неизвестным taskId |
| 409 Conflict | «Запрос понятен, но конфликтует с состоянием/правилом» | business | попытка изменить архивную задачу, запрещённый переход статуса |
| 415 Unsupported Media Type | «Я не умею читать такой Content-Type для этого endpoint’а» | format | прислали text/plain вместо application/json |
| 500 Internal Server Error | «Мы сломались внутри, это не твоя вина» | technical | баг, NullPointerException, неожиданное состояние |
Эта карта уже даёт вам базовую дисциплину: если вы видите 400, вы сразу думаете про контракт запроса; если 404 — про существование ресурса; если 409 — про состояние ресурса и правила домена; если 415 — про формат тела запроса; если 500 — про проблемы сервера.
3. 400 Bad Request: граница контракта
400 Bad Request — это не наказание клиента, а вполне дружелюбное сообщение: «я не могу обработать запрос в таком виде; исправь, пожалуйста, форму или значения». Часто новички пытаются запихнуть в 400 вообще всё, что не нравится серверу. Так делать не нужно, но понимать, где 400 действительно уместен, очень важно: это самый частый 4xx в обычном JSON API.
В Task Tracker API 400 появляется в нескольких типовых местах. Самое очевидное — Bean Validation: запрос может быть корректным JSON-ом, но нарушать ограничения, например title пустой или слишком короткий. Второе место — ошибки чтения тела запроса: malformed JSON, “тип не тот”, неверный формат даты. Третье — ошибки параметров URL, например page=abc, когда вы ожидали число.
Посмотрим на маленький, но очень показательный request DTO. Он не “делает магию”, он просто документирует контракт: что считается допустимым вводом.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
// Заголовок обязателен: пустая строка не принимается
@NotBlank
// Ограничиваем длину, чтобы контракт был явным (и чтобы UI/клиент знал рамки)
@Size(min = 3, max = 120)
String title
) {}
Теперь контроллер, который принимает DTO, логически говорит: «если запрос не проходит этот контракт — это 400».
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping("/api/v1/tasks")
public TaskDetailsResponse create(
// @Valid включает Bean Validation: нарушения -> 400 (ошибка контракта запроса)
@Valid
// @RequestBody говорит, что ожидаем тело запроса (обычно JSON)
@RequestBody TaskCreateRequest request
) {
return taskService.create(request);
}
И вот пример запроса, который формально JSON, но по смыслу “плохой”:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": ""
}
Такой запрос должен приводить к 400 Bad Request, потому что клиент нарушил контракт: title не может быть пустым. Здесь нет никакого “конфликта” и нет “ресурс не найден” — ресурс ещё даже не создавался, мы просто не можем принять вход.
Отдельно полезно почувствовать разницу между “валидный JSON” и “malformed JSON”. Вот это уже не “плохой title”, а “сломанный документ”:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Write docs"
Закрывающей фигурной скобки нет — это тоже 400, но уже не Bean Validation, а ошибка чтения body. С точки зрения клиента смысл тот же: «исправь запрос».
4. 404 Not Found: ресурс отсутствует
404 Not Found многие любят использовать неправильно: то за пустой список, то за “не тот статус”, то за всё подряд, лишь бы “похоже на not found”. Но 404 на самом деле довольно честный и узкий по смыслу: клиент адресовал конкретный ресурс, а такого ресурса не существует.
Классика в нашем проекте — GET /api/v1/tasks/{taskId}. Если taskId выглядит нормально (например, это строка UUID, как у нас принято), запрос структурно корректен, но записи нет — это чистый 404. Клиент не должен угадывать: “может, я тело не так отправил?” или “может, сервер сломался?”. Сервер должен сказать: «такой задачи нет».
Типичный сервисный код выглядит так. Обратите внимание: мы делаем негативный сценарий явным — через именованное исключение.
public Task getById(String taskId) {
// Репозиторий может вернуть null: это "ресурс отсутствует", а не "сломался сервер"
Task task = repository.findById(taskId);
if (task == null) {
// Дальше это исключение маппится в 404 на уровне обработчика ошибок
throw new TaskNotFoundException(taskId);
}
return task;
}
Сам TaskNotFoundException здесь важен своим смыслом и тем, что может хранить taskId внутри. Мы явно помечаем сценарий ресурс отсутствует, а потом честно маппим его в 404.
Внутри exception message может быть технический текст — это полезно для логов. Но сам смысл этого исключения мы связываем именно с 404. Клиенту мы хотим сообщить: «ресурс отсутствует».
Ещё один важный нюанс, который очень часто путают: 404 — это не “ничего не найдено по фильтру”. Если вы запрашиваете коллекцию (например, /tasks с фильтрами), пустой результат обычно означает 200 OK и пустой список. 404 — это про конкретный адресуемый ресурс, а не про “результат поиска”.
5. 409 Conflict: конфликт состояния
409 Conflict — статус, который кажется “каким-то редким”, пока вы не начинаете добавлять реальные бизнес-правила. В учебных CRUD-проектах часто всё разрешено, и поэтому конфликтов будто бы не существует. Но как только появляется правило «архивную задачу нельзя менять» или «статус нельзя откатить из DONE в TODO», 409 становится вашим лучшим другом.
Смысл 409 очень важен: клиент прислал корректный запрос. DTO валиден, JSON читается, ресурс существует. Но выполнить операцию нельзя, потому что она конфликтует с текущим состоянием ресурса или с предметным правилом. Клиенту бессмысленно “поправить формат JSON” — он должен изменить действие или последовательность действий.
Вот мини-кусочек логики смены статуса, который демонстрирует идею:
public void changeStatus(Task task, TaskStatus newStatus) {
// Бизнес-правило: архивную задачу нельзя менять вообще
if (task.getStatus() == TaskStatus.ARCHIVED) {
throw new TaskArchivedException(task.getId());
}
// Бизнес-правило: переходы статусов ограничены (матрица/граф переходов)
if (!TaskStatusTransitions.isAllowed(task.getStatus(), newStatus)) {
throw new InvalidStatusTransitionException(task.getId(), task.getStatus(), newStatus);
}
// Только после всех проверок меняем состояние
task.setStatus(newStatus);
}
Разберите этот код “с позиции клиента”. Клиент мог прислать идеальный запрос: "status": "TODO" — валидный enum, JSON корректен. Но если текущий статус DONE, операция запрещена правилами переходов. И это не 400, потому что запрос не “сломанный”. Это конфликт — 409.
Важно, что 409 не должен превращаться в “любой клиентский косяк”. Если запрос просто невалиден (например, title пустой) — это 400. Если ресурса нет — 404. 409 — это именно “операция не может быть применена к ресурсу в текущем состоянии”.
6. 415 Unsupported Media Type: неверный формат
415 Unsupported Media Type звучит немного “как взрослый статус” (почти как “мы прочитали RFC и теперь страшные”). Но на самом деле он очень практичный и довольно простой: клиент отправил тело запроса в формате, который этот endpoint не обещал принимать. Обычно это про заголовок Content-Type.
В JSON-first API чаще всего контракт такой: Content-Type: application/json. Если клиент прислал text/plain, сервер честно говорит: «я не умею читать это как вход для данного endpoint’а». И это отличается от 400: при 400 формат вроде бы правильный (application/json), но содержимое сломано или не проходит validation; при 415 сам формат не подходит.
Самый простой способ подчеркнуть контракт — явно зафиксировать consumes:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping(
path = "/api/v1/tasks",
// Явно фиксируем контракт: endpoint принимает только JSON
consumes = MediaType.APPLICATION_JSON_VALUE
)
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
return taskService.create(request);
}
И вот запрос, который провоцирует 415:
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
just create task please
С точки зрения человека в чате это может звучать “миленько”, но с точки зрения API контрактов — это другой формат. Если endpoint объявлен как JSON endpoint, 415 — самый честный ответ.
7. 500 Internal Server Error: внутренняя ошибка
500 — это статус, который хочется либо спрятать («давайте везде 400, так безопаснее»), либо, наоборот, использовать как универсальную мусорку. Оба подхода плохие. 500 должен означать конкретную вещь: сервер не смог корректно обработать запрос из-за внутренней ошибки, и клиент обычно ничего не может исправить в своём запросе, чтобы “вылечить сервер”.
Внутренние ошибки бывают разными: баг в коде, неожиданный null, нарушение инвариантов, ошибки работы со сторонними ресурсами. В рамках нашего проекта без БД такие сбои тоже возможны, например, если репозиторий вернул объект в странном состоянии (что в реальном мире бывает из‑за гонок, миграций, багов, неважно). Серверу важно уметь честно сказать “это 500”, а не притворяться, что “виноват клиент”.
Мини-пример “защитной проверки”, которая превращает странное состояние в технический сбой:
public TaskDetailsResponse create(TaskCreateRequest request) {
Task task = repository.save(mapper.toModel(request));
// Если ID не сгенерировался — это не проблема клиента, а нарушение инварианта на стороне сервера
if (task.getId() == null) {
throw new IllegalStateException("Task id was not generated");
}
return mapper.toDetails(task);
}
Клиент здесь ни при чём. Он мог прислать идеальный JSON. Если task.getId() оказался null, это проблема сервера (или нашего кода). Значит, статус должен быть 500. Да, нам как разработчикам неприятно признавать ошибки, но API-контракт — штука честная: если сломался сервер, он должен сказать, что сломался сервер.
8. Один endpoint — разные статусы
Когда смотришь на endpoint глазами автора кода, кажется, что у него один сценарий: “обновить задачу”. Но глазами клиента endpoint — это целый “коридор”, где на каждом шаге можно упасть по разной причине. И на каждом таком падении статус-код должен быть осмысленным, иначе клиент будет гадать, что делать дальше.
Удобно держать в голове простой алгоритм: сначала проверяем, что запрос пришёл в правильном формате (415), затем что он читается и валиден (400), затем что ресурс существует (404), затем что действие допустимо по бизнес-правилам (409), и только если что-то неожиданно взорвалось — это 500. Ниже — схема этого мышления (не реализации!), которую можно мысленно применять почти к любому endpoint’у.
flowchart TD
%% Порядок проверок запроса: от формата к домену, и только потом "внутренние взрывы"
A[Запрос пришёл] --> B{Content-Type подходит?}
B -- нет --> S415[415 Unsupported Media Type]
B -- да --> C{Запрос читается? JSON/params OK?}
C -- нет --> S400[400 Bad Request]
C -- да --> D{Валидация прошла?}
D -- нет --> S400
D -- да --> E{Ресурс существует?}
E -- нет --> S404[404 Not Found]
E -- да --> F{Бизнес-правила разрешают?}
F -- нет --> S409[409 Conflict]
F -- да --> G{Сервер не упал?}
G -- нет --> S500[500 Internal Server Error]
G -- да --> OK[2xx Success]
Чтобы почувствовать это на “пальцах”, представьте PATCH /api/v1/tasks/{taskId}. У него вполне реальная матрица негативных сценариев, и она отлично показывает, почему статус — не “украшение”.
| Ситуация | Что сломалось по смыслу | Статус |
|---|---|---|
| Content-Type: text/plain вместо JSON | формат входа не тот | 415 |
| JSON битый (malformed) | запрос не читается | 400 |
| DTO частично валиден, но нарушены ограничения | input validation | 400 |
| taskId выглядит как UUID, но задачи нет | ресурс не найден | 404 |
| задача есть, но ARCHIVED, а мы пытаемся менять | конфликт с состоянием | 409 |
| внутри сервиса баг/неожиданное состояние | технический сбой | 500 |
Обратите внимание: это всё один endpoint. Поэтому “давайте всегда 400” или “давайте всегда 500” делает контракт расплывчатым: клиент не понимает, что именно случилось, и что ему делать — исправить запрос, поменять действие или просто подождать, пока сервер починят.
9. Типичные ошибки при выборе HTTP-статусов
Ошибка №1: превращать 400 Bad Request в универсальную помойку “на всё плохое”.
Такое происходит, когда разработчик видит любой негативный сценарий и автоматически отвечает 400, потому что “клиент же что-то не так сделал”. В итоге клиент получает 400 и за невалидный JSON, и за отсутствие ресурса, и за запрещённый переход статуса. Контракт начинает врать: клиент не понимает, что исправлять — формат запроса или бизнес-действие.
Ошибка №2: ставить 404 Not Found там, где на самом деле конфликт (409).
Например, архивную задачу нельзя менять — но некоторые API вместо 409 начинают отвечать 404, будто бы “задачи нет”. Это создаёт ложную картину: ресурс существует, клиент мог бы его прочитать, но действие запрещено. 404 скрывает смысл ошибки и делает отладку клиентом мучительной: “как это нет, если я только что её получал?”.
Ошибка №3: возвращать 500 Internal Server Error для известных предметных ошибок.
Если вы уже знаете, что запрещён переход статуса или запрещено изменение архивной задачи, это не “неожиданная ошибка сервера”. Это нормальная бизнес-ситуация, просто с отрицательным результатом. У неё должен быть контролируемый статус (409) и нормальная публичная модель ошибки. 500 здесь будет провоцировать клиента на бессмысленные ретраи и создаст впечатление, что API нестабильно.
Ошибка №4: игнорировать 415 Unsupported Media Type и “притягивать” всё к 400.
Формально можно сказать: “ну формат же неправильный — значит bad request”. Но 415 даёт более точный сигнал: дело не в значениях полей, а в том, что клиент говорит с сервером “не на том языке”. Когда вы используете 415 по назначению, клиенту проще автоматически исправиться: поменять Content-Type, а не перебирать поля JSON.
Ошибка №5: возвращать 200 OK, а ошибку прятать в теле ответа.
Это выглядит как желание “упростить жизнь клиенту”, но на практике ломает HTTP-семантику и усложняет жизнь всем: клиентским библиотекам, прокси, тестам, логированию и даже человеку, который смотрит на ответ в Postman. Если ошибка — она должна быть ошибкой и на уровне статуса, а не только “строчкой внутри JSON”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ