JavaRush /Курсы /Java Server /HTTP-ошибки: 400, <...

HTTP-ошибки: 400, 404, 409, 500

Java Server
8 уровень , 1 лекция
Открыта

1. Статус ошибки как часть контракта

Когда вы только начинаете, очень хочется относиться к status code как к формальности: мол, главное — отправить в ответ строку “ошибка” (или даже стек-трейс, чтобы клиент “точно понял”). Но HTTP придуман так, что первая и самая важная подсказка о результате — это именно статус. И если вы выбираете статус “наугад”, вы ломаете диалог клиента и сервера.

Представьте, что клиент — это не человек, который прочитает ваш текст “ну там что-то не так”, а программа. У программы обычно есть простая логика: если 2xx, продолжаем по happy-path; если 4xx, скорее всего, надо исправить запрос; если 5xx, скорее всего, запрос был нормальный, но сервер не справился. И если вы на любую проблему отвечаете 500, клиент начинает думать: “сервер сломан”, хотя на самом деле пользователь просто прислал id=abc вместо числа. А если вы на любой провал отвечаете 400, клиент думает: “виноват я”, хотя сервер упал от NullPointerException.

В нашем проекте ReadLater Starter это будет критично, когда появится локальный API для чтения списка: мы захотим, чтобы клиент (Postman, браузер или другой сервис) мог отличить “я прислал ерунду” от “ресурса нет” и от “я столкнулся с конфликтом данных”.

Ошибки 4xx и 5xx

Сейчас будет очень практичное упрощение, которое спасает от хаоса. Ошибки в HTTP делятся на две большие группы: 4xx и 5xx. В этом делении нет морали “кто виноват”, но есть ключ к правильной реакции: может ли клиент исправить ситуацию, изменив запрос.

Если сервер возвращает 4xx, он как бы говорит: “Я тебя понял, но у твоего запроса есть проблема”. Это может быть неправильный формат параметра, отсутствие обязательного поля, недопустимое значение, попытка создать то, что конфликтует с уже существующим состоянием. Важно: это не обязательно “клиент плохой”, просто запрос сейчас не может быть выполнен в текущем виде.

Если сервер возвращает 5xx, он говорит: “Запрос в целом нормальный, но у меня внутри что-то пошло не так”. Это может быть баг, исключение, неучтённый случай, сломанная внутренняя зависимость. Клиент обычно не может “починить” это изменением одного параметра.

Удобно держать это в голове как мини-таблицу:

Класс Кто “может повлиять” Что это обычно значит
4xx Клиент Запрос надо исправить/уточнить
5xx Сервер Сервер не смог обработать корректный запрос

Дальше мы разберём четыре кода, и каждый раз будем задавать один и тот же вопрос: “Что именно пошло не так, и кто реально может это исправить?”

2. 400 Bad Request: сервер не может принять запрос в текущем виде

400 Bad Request — это самый частый код, который начинающие используют “везде, где непонятно”. Но на самом деле у него есть довольно конкретный смысл: запрос некорректный по форме или по значениям. Сервер не обязан “догадываться, что вы имели в виду”, если вы прислали что-то не то. Он имеет право ответить: “Я не могу это обработать, потому что данные некорректны”.

Самый понятный пример для нашей будущей reading-list истории — это id в пути. Если мы договорились, что id — это число, то запрос GET /api/v1/reading-list/abc нельзя трактовать как “ресурс не найден”. Ресурс тут даже не пытались искать: сначала нужно было получить число, а его нет. Это классический 400.

Вот мини-пример на Java, который показывает именно эту мысль: сначала проверяем, что вход парсится, а уже потом говорим о “нашёл/не нашёл”.

public class StatusCodeDecider {

    public static int statusForGetById(String rawId, boolean exists) {
        try {
            // Сначала пытаемся привести вход к нужному типу (контракт: id — число)
            long id = Long.parseLong(rawId);

            // Если распарсилось — только тогда имеет смысл отвечать 200/404 (ресурс есть/нет)
            return exists ? 200 : 404;
        } catch (NumberFormatException e) {
            // Если id не парсится, мы даже НЕ переходим к поиску ресурса — это 400
            return 400;
        }
    }

    public static void main(String[] args) {
        // "abc" не число => 400 (плохой запрос)
        System.out.println(statusForGetById("abc", false)); // 400

        // "999" — число, но ресурс не найден => 404
        System.out.println(statusForGetById("999", false)); // 404

        // "42" — число и ресурс существует => 200
        System.out.println(statusForGetById("42", true));   // 200
    }
}

Обратите внимание на скрытую, но важную логику. Сценарии “не парсится” и “не найден” — это разные ветки. Если вы их смешаете, клиенту будет сложно понять, что делать дальше: менять формат запроса или искать другой id.

Ещё один типичный 400 — это некорректные query-параметры. Например, вы захотите фильтровать список по status, но клиент прислал status=READING вместо ожидаемого IN_PROGRESS. Запрос вроде бы “похож на нормальный”, но значение недопустимо, и это снова 400. Мы не углубляемся в формат тела и валидацию сегодня, но сам принцип уже работает: ошибка в значении входных данных400.

3. 404 Not Found: запрос понятен, но нужного ресурса нет

404 Not Found многие знают по браузеру (“страница не найдена”), поэтому есть соблазн лепить его куда угодно, когда “ничего не получилось”. На самом деле смысл 404 очень прикладной: сервер понял, что вы просите, но не нашёл то, что вы хотите получить. Запрос корректный, просто целевого ресурса нет.

Возвращаемся к нашему примеру с id. Если id — нормальное число, но в данных нет элемента с таким идентификатором, это честный 404. Сервер не спорит с формой запроса и не говорит “ты сломал протокол”. Он говорит: “Да, ты просишь элемент с id=999. Я посмотрел — такого нет”.

Это важно отличать от ситуации “пустой список”. Когда клиент делает GET /api/v1/reading-list и получает пустую коллекцию, это не 404. Коллекция как ресурс существует, просто она сейчас пуста. Поэтому ответ будет успешным (200 OK), а в body (в будущей версии API) может быть пустой список. Иначе вы получаете странный контракт: клиент хочет “получить список”, а сервер отвечает “списка нет”, хотя на самом деле список есть, просто без элементов.

Небольшой “контрастный” пример в виде псевдо-ответов (без углубления в заголовки, мы сегодня про коды):

# Запрос конкретного элемента: id корректный, но элемента нет
GET /api/v1/reading-list/999
-> 404 Not Found
-> "item not found"

# Запрос коллекции: сам ресурс "список" существует, просто сейчас он пустой
GET /api/v1/reading-list
-> 200 OK
-> "[]"

Смысл такой: 404 — это не про “пусто”, а про “нет того конкретного ресурса, который ты запросил”. Если вы держите в голове эту фразу, вы перестаёте делать 404 на всё подряд, и ваш API становится заметно “взрослее”.

И ещё один нюанс. 404 бывает и в ситуации, когда сам путь не существует (маршрут не найден). В реальной жизни это смешивается, и именно поэтому чуть позже нам понадобится аккуратное различение с 405 и заголовком Allow. Но сейчас достаточно помнить: если запрос корректный, но целевого ресурса нет, 404 — ваш друг.

4. 409 Conflict: запрос корректный, но конфликтует

409 Conflict — это код, который сначала кажется “капризом перфекционистов”. Мол, зачем отдельный статус, если можно сказать 400 и написать “конфликт”? Но разница есть, и она очень практичная. 400 — это “в запросе проблема”. 409 — это “запрос нормальный, но в текущем состоянии данных он не может быть выполнен”.

Самый учебно-понятный пример для ReadLater — правило уникальности externalId, если он задан. Представьте: вы добавляете в свой список чтения книгу и храните внешний идентификатор из каталога. Если вы решили, что externalId не должен повторяться (иначе один и тот же внешний ресурс окажется у вас в списке дважды), то попытка создать второй элемент с тем же externalId — это не “плохой формат запроса”. Это хороший запрос, просто он конфликтует с уже существующими данными.

Можно выразить это в мини-логике без “настоящего сервера”, чисто как мыслительный эксперимент:

public class ConflictExample {

    static int statusForCreate(String externalId, boolean externalIdAlreadyUsed) {
        // Если внешний идентификатор задан и уже встречался — это конфликт состояния данных
        if (externalId != null && externalIdAlreadyUsed) {
            return 409;
        }

        // Иначе ресурс можно создать (в этой модели — "успешное создание")
        return 201;
    }

    public static void main(String[] args) {
        // Дубликат externalId => 409 Conflict
        System.out.println(statusForCreate("OL12345M", true));  // 409

        // Новый externalId => 201 Created
        System.out.println(statusForCreate("OL99999M", false)); // 201

        // externalId не задан => конфликта по этому правилу нет => 201 Created
        System.out.println(statusForCreate(null, false));       // 201
    }
}

Заметьте, что запрос с “дубликатом” вполне валиден. Поля переданы нормально, типы нормальные, смысл понятный. Просто сервер говорит: “Я не могу создать новый ресурс, потому что это противоречит текущим правилам и данным”.

409 полезен ещё и тем, что он подсказывает клиенту тип реакции. Если 400, клиент обычно исправляет запрос (формат/значения). Если 409, клиент чаще всего либо выбирает другое значение (другой externalId), либо меняет сценарий (например, вместо “создать” делает “обновить существующий”).

5. 500 Internal Server Error: сервер сломался

500 Internal Server Error — это тот самый код, которым нельзя прикрывать всё подряд. Его смысл довольно прямолинейный: сервер получил запрос, попытался обработать, но внутри случилось что-то непредвиденное, и обработку корректно завершить не получилось. Причём это именно “непредвиденное” с точки зрения сервера: баг, исключение, сломанная внутренняя часть, неожиданный null.

Здесь важна психология контракта. Если клиент получил 500, он не должен думать: “Наверное, я что-то неправильно отправил”. Это ответственность сервера — сделать так, чтобы 500 был редким и честным сигналом: “Проблема на моей стороне”.

Как это выглядит в самой простой Java-модели, где мы отделяем “ожидаемые проблемы запроса” от “неожиданных исключений”:

public class InternalErrorExample {

    static int runSafely(Runnable action) {
        try {
            // Пытаемся выполнить "полезную работу"
            action.run();

            // Если всё прошло без исключений — успех
            return 200;
        } catch (Exception e) {
            // Любое неожиданное исключение в этой модели — 500
            return 500;
        }
    }

    public static void main(String[] args) {
        // Успешный сценарий: исключений нет
        int ok = runSafely(() -> System.out.println("saved")); // saved
        System.out.println(ok);                                // 200

        // Падающий сценарий: внутри случилось исключение
        int fail = runSafely(() -> { throw new NullPointerException(); });
        System.out.println(fail);                              // 500
    }
}

В реальном API, конечно, вы не будете печатать “saved” в консоль как финальный дизайн мира (мы всё ещё в учебной зоне), но логика ветвления полезная: предсказуемые, “контрактные” проблемы должны уходить в 4xx, а неожиданные исключения — в 500.

И ещё тонкий момент. Иногда хочется “сделать клиенту приятно” и вместо 500 вернуть 400 с текстом “что-то пошло не так”. Но это медвежья услуга. Вы маскируете серверную проблему под клиентскую, клиент начинает исправлять то, что не сломано, а реальная ошибка так и остаётся внутри.

6. Выбор кода ошибки в ReadLater

Пора собрать всё это в одну понятную картину, чтобы в голове не осталось четырёх отдельных карточек, а появилась одна “машинка выбора”. На практике статус выбирается не “по вкусу”, а по двум вопросам: корректен ли запрос по форме/значениям и существует ли целевой ресурс/нет ли конфликта с текущими данными. И только если всё было ок, но система упала — это 500.

Ниже — простая блок-схема, которая помогает не путаться (это не стандарт RFC, а учебная логика для новичка, но она хорошо работает):

flowchart TD
    %% Учебная логика выбора статуса: идём сверху вниз и "отсекаем" неподходящие случаи
    A["Пришел запрос"] --> B{"Запрос корректен по форме и значениям?"}
    B -- "нет" --> C["400 Bad Request"]
    B -- "да" --> D{"Целевой ресурс существует?"}
    D -- "нет" --> E["404 Not Found"]
    D -- "да" --> F{"Есть конфликт с текущим состоянием?"}
    F -- "да" --> G["409 Conflict"]
    F -- "нет" --> H{"Сервер обработал без исключений?"}
    H -- "нет" --> I["500 Internal Server Error"]
    H -- "да" --> J["2xx Success"]

Теперь те же идеи, но на очень приземлённых примерах будущего local API. Смотрите, как меняется код при маленьком изменении входных данных:

Запрос Что не так (или что так) Код
GET /api/v1/reading-list/abc
id не число, запрос “непарсибелен” 400
GET /api/v1/reading-list/999
id ок, но элемента нет 404
POST /api/v1/reading-list с externalId=OL12345M, который уже есть запрос валиден, но конфликт данных 409
Любой запрос, во время которого код упал с исключением сервер не справился внутри 500

Заметьте: это четыре разных сообщения клиенту, даже если “всё плохо”. Один говорит “исправь запрос”, другой — “ищешь то, чего нет”, третий — “переиграй сценарий, у тебя конфликт”, четвёртый — “это моя проблема, я сломался”.

7. Типичные ошибки при выборе 400, 404, 409, 500

В начале работы с HTTP почти все наступают на одни и те же грабли, потому что голова ещё помнит мир “одного main()”, где ошибку можно просто напечатать в консоль и пойти пить чай. В HTTP-контракте так не работает: код ответа — это часть общения между программами. Поэтому ошибки здесь обычно не “случайные”, а системные и повторяемые.

Ошибка №1: возвращать 404 для невалидного идентификатора.
Очень частая история — запрос GET /.../abc, и сервер отвечает 404, потому что “такого же нет”. Но сервер даже не дошёл до поиска: он не смог превратить abc в long. Такой ответ путает клиента: клиент начинает думать, что нужно подобрать другой id, хотя на самом деле нужно исправить формат. В таких случаях 400 гораздо честнее.

Ошибка №2: превращать пустую коллекцию в 404 Not Found.
Если клиент читает список (GET /reading-list) и в списке сейчас просто ноль элементов, это нормальная ситуация, а не “ресурс исчез”. 404 тут заставляет клиента делать странные выводы (“эндпоинт не существует”, “доступ запрещён”, “сервер сломан”), хотя сервер работает отлично. Пустой список — это успешное чтение, просто без элементов.

Ошибка №3: отвечать 400 там, где на самом деле конфликт (409).
Когда запрос валиден, но противоречит состоянию (например, нарушает уникальность externalId), 400 превращается в “универсальную корзину для всего непонятного”. Клиенту сложнее понять, что не нужно “чинить формат”, нужно менять сценарий: выбрать другое значение или сначала удалить/обновить существующий ресурс.

Ошибка №4: отвечать 500 на любую неприятность, чтобы “не разбираться”.
500 — это признание: “внутри сервера неожиданная проблема”. Если вы используете его вместо 400 или 409, вы маскируете обычные контрактные ситуации под “сервер сломан”. В результате клиентская сторона будет вести себя неадекватно, а отладка превратится в сериал “почему у нас всё 500, но ничего не падает”.

Ошибка №5: смешивать “не найден ресурс” (404) и “не существует маршрут” (тоже часто 404) и пытаться объяснять это текстом вместо статуса.
У начинающих часто появляется желание: “ну я всегда верну 404, а в тексте напишу, что это было”. Это быстро превращается в хаос, потому что текст нестабилен, его трудно парсить, его легко “улучшить” и случайно сломать клиент. Гораздо полезнее дисциплинированно выбирать статус по смыслу, а текст держать как краткое пояснение, не подменяющее основной сигнал.

1
Задача
Java Server, 8 уровень, 1 лекция
Недоступна
Классификатор неуспешных сценариев
Классификатор неуспешных сценариев
1
Задача
Java Server, 8 уровень, 1 лекция
Недоступна
Поиск клиента по идентификатору
Поиск клиента по идентификатору
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ