JavaRush /Курсы /Java Server /Не- 2xx ответы и гран...

Не- 2xx ответы и граница ошибок

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

1. HttpResponse — ещё не победа

Когда вы начинаете писать HTTP-клиент, мозг очень быстро приучается к простой логике: “если client.send(...) вернул HttpResponse, значит всё хорошо”. И это правда… примерно на 60%. Ответ действительно пришёл, значит мы дозвонились, договорились о протоколе, получили статус, заголовки и тело. Но дальше начинается взрослая часть: сервер мог сказать “я тебя понял, но не согласен”.

Представьте, что вы заказываете пиццу. Если телефон не отвечает — это транспортная проблема: вы даже не поговорили. Если вам ответили и сказали “мы сегодня не работаем” — разговор состоялся, но результат для вас всё равно “не пицца”. В HTTP это ровно то же самое: исключение при send() — “телефон не отвечает”, а HttpResponse со статусом 404 или 500 — “ответили, но отказали”.

С таймаутами, IOException и InterruptedException мы уже разобрались: там ответа могло не быть вообще. Здесь другая ветка. HttpResponse уже пришёл, но пользоваться им как успехом можно только после проверки статуса.

Ключевая мысль здесь простая: наличие HttpResponse означает, что transport-уровень добрался до удалённого сервиса и получил ответ, но успешность определяется не фактом ответа, а его statusCode().

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

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

// Важно: send() может успешно вернуть HttpResponse даже при 404/500 — это НЕ исключение.
HttpResponse<String> response = client.send(
        HttpRequest.newBuilder(URI.create("https://catalog.example/books/NOPE"))
                .header("Accept", "application/json") // Просим JSON, но это не гарантирует успешный статус
                .GET()
                .build(),
        HttpResponse.BodyHandlers.ofString() // Читаем тело как строку, но это может быть и ошибка
);

System.out.println(response.body()); // может быть не книга, а описание ошибки

Такой код часто “работает” ровно до первого негативного сценария. А негативные сценарии в сетевом мире приходят быстро, как уведомления от банка после покупки кофе.

2. Три исхода вызова: успех и ошибки

Пока мы не разложим исходы по полочкам, клиентский код будет либо слишком оптимистичным (“всё успех”), либо слишком параноидальным (“всё ошибка”). На практике у нас есть три принципиально разные ситуации, и они отличаются тем, что именно у нас есть “в руках” после вызова.

Ниже — таблица, которая помогает не путать эти случаи (и не ловить Exception на всё подряд, как будто это универсальный пластырь).

Ситуация Есть HttpResponse? Что мы знаем Типичный пример Что делать в коде
Успешный ответ (2xx) Да Статус успеха, заголовки, body (обычно JSON) 200 OK на поиск,
200 OK
на детали
Разрешить читать body как “данные”
Ответ с ошибкой (non-2xx) Да Статус ошибки, заголовки, body (часто описание ошибки) 404 Not Found,
429 Too Many Requests
,
500 Internal Server Error
Не считать body “данными”; обработать статус и сообщить ошибку
Transport-ошибка (исключение) Нет Ответа нет, только факт проблемы (timeout, сеть, прерывание) HttpTimeoutException,
IOException
Обработать как сбой связи; это не “ошибка статуса”, статуса вообще нет

И очень полезная (и честная) блок-схема. Её удобно держать в голове, когда пишете send():

flowchart TD
    A["client.send(request)"] -->|"HttpTimeoutException / IOException / InterruptedException"| B["Transport-ошибка: ответа нет"]
    A -->|получили HttpResponse| C{"statusCode 2xx?"}
    C -->|да| D["Успех: можно читать body как данные"]
    C -->|нет| E["HTTP-ошибка: статус != 2xx, body не = данные"]

Если вы запомните только это, уже станет легче. Потому что основная путаница новичка выглядит так: “Я получил JSON в body, значит всё ок”. Нет. JSON может быть и в успешном ответе, и в ошибке. Статус — главный переключатель ветки поведения.

3. Проверка диапазона 200..299, а не 200

Когда вы пишете клиент под конкретный сценарий (например, GET поиск), действительно часто прилетает 200. Но как только вы начинаете думать “как разработчик транспортного слоя”, вам надо быть чуть шире: успешные статусы — это не одна цифра, а диапазон 2xx.

Если проверять только status == 200, вы сами себе устроите “мини-лотерею багов”. Например, 201 Created — тоже успех, просто успех другого типа. 204 No Content — успех без body. И да, даже если сегодня ваш провайдер всегда отдаёт 200, завтра он может поменяться (или вы пойдёте в другой endpoint).

Минимальная, но очень полезная функция:

private static boolean is2xx(int status) {
    // 2xx — это диапазон успеха, а не одно значение 200
    return status >= 200 && status < 300;
}

Теперь любую проверку успеха вы можете формулировать “по-взрослому”:

int status = response.statusCode(); // Статус решает: успех или ошибка

if (!is2xx(status)) {
    // Не пропускаем дальше body как "данные", если статус неуспешный
    System.out.println("Каталог вернул ошибку: " + status);
    return;
}

System.out.println("Успех: " + response.body()); // Здесь body уже можно трактовать как данные

Обратите внимание: здесь мы сделали важную вещь — не дали body пройти дальше, пока не убедились, что статус успешный. Это простая привычка, которая спасает кучу нервов, когда вы позже начнёте превращать body в DTO (но это будет не сегодня).

И ещё маленький нюанс: когда вы используете HttpResponse.BodyHandlers.ofString(), то при 204 вы получите просто пустую строку. И это нормально: “нет контента” — это легальный успех. Для нашего каталога книг 204 вряд ли встретится, но для общего транспорта — встречается.

4. Body при ошибках: не данные, а подсказка

Когда статус не 2xx, возникает соблазн сделать одно из двух крайних действий. Первая крайность — игнорировать body полностью: “ошибка и ошибка”. Вторая крайность — радостно пытаться читать body как будто это нормальные данные: “ну там же текст/JSON, значит ок”.

Правильная позиция где-то посередине: body при ошибке — часто очень полезно для диагностики, но это не happy-path данные. Для нашего проекта ReadLater Starter сейчас достаточно простого правила: при non-2xx мы можем сохранить или показать body как подсказку (в разумных пределах), но не продолжаем обработку как будто это результат поиска/деталей.

Чтобы “в разумных пределах” было не просто словами, добавим маленький helper, который режет слишком длинное тело. Это спасает консоль от превращения в свиток на 300 экранов:

private static String shorten(String text, int maxLen) {
    // На ошибках сервер может прислать что угодно и очень много — ограничиваем длину
    if (text == null) return "<no body>";
    if (text.length() <= maxLen) return text;

    // Возвращаем только начало, чтобы не превращать вывод в "простыню"
    return text.substring(0, maxLen) + "...";
}

Теперь при ошибке можно вывести что-то аккуратное:

int status = response.statusCode(); // Сначала смотрим на статус

if (!is2xx(status)) {
    // Body печатаем как подсказку (диагностику), а не как "данные"
    System.out.println("Ошибка каталога: " + status);
    System.out.println("Фрагмент body: " + shorten(response.body(), 200));
}

А ещё полезно помнить, что сервер в ошибке может внезапно прислать не JSON, даже если вы просили JSON через Accept: application/json. Иногда это HTML-страница прокси, иногда plain text, иногда “что-то странное”. Поэтому в диагностике иногда помогают заголовки:

String contentType = response.headers()
        .firstValue("Content-Type") // Заголовка может не быть — поэтому читаем через Optional
        .orElse("<unknown>");

System.out.println("Content-Type = " + contentType); // Content-Type = application/json

Снова напомню: мы сейчас не строим “идеальный error-parser”. Мы строим базовую привычку: статус решает ветку, а body при ошибке — это подсказка, а не результат.

5. 4xx и 5xx: минимальная реакция

Когда мы видим non-2xx, это ещё не значит, что нужно устраивать энциклопедию статусов. Но есть одно очень полезное деление, которое помогает и вам, и пользователю: ошибки клиента и ошибки сервера.

4xx обычно означает: “запрос плохой, или ресурс не найден, или нельзя так делать”. Частый пример для каталога: неправильный externalId, и сервер честно говорит 404 Not Found. Это не “провайдер сломался”, это “такой книги нет”.

5xx означает: “сервис сам не смог обработать”. Это уже ближе к “провайдер болеет”, и от вашего кода часто мало что зависит.

В проекте удобно иметь очень простой перевод статуса в сообщение для пользователя (пока без глубокого анализа body):

private static String userMessageForStatus(int status) {
    // Тут мы не делаем "справочник HTTP", а даём человеку понятное объяснение
    if (status == 404) return "Книга не найдена в каталоге";
    if (status >= 500) return "Каталог временно недоступен (ошибка сервера)";

    // Для остальных статусов — хотя бы покажем код, чтобы было за что зацепиться в диагностике
    return "Каталог вернул ошибку: " + status;
}

Почему это полезно даже “до красивой архитектуры”? Потому что наш ReadLater Starter сейчас запускается как консольное приложение с командами. Пользователь (то есть вы же) должен понимать, что произошло: “я ошибся ID” или “провайдер лежит”.

И аккуратный момент про 3xx: Java HttpClient по умолчанию редиректы не следует. Если вы случайно используете http://... вместо https://..., провайдер может ответить 301/302. Для обычного JSON API это часто сигнал, что baseUrl неверный, и лучше его исправить, а не пытаться “читать редирект как данные”.

6. Transport-правило: body только при 2xx

Когда вы пишете несколько запросов (поиск, детали, возможно позже ещё что-то), одна и та же логика проверки статуса начинает дублироваться. А любое дублирование в коде имеет забавное свойство: когда вы исправили баг в одном месте, он обязательно останется в другом, чтобы не было скучно.

Поэтому правильный следующий шаг — сделать одно место, где живёт правило “успех/неуспех”. Сейчас мы ещё не выносим всё в отдельный CatalogClient (это будет следующей лекцией), но правило уже можно централизовать в helper-методе.

Введём маленькое исключение, чтобы не терять статус:

public class CatalogApiException extends RuntimeException {
    // Сохраняем статус-код, чтобы его можно было использовать в сообщении пользователю/логике обработки
    private final int status;

    public CatalogApiException(int status, String message) {
        // message здесь — диагностическая подсказка (например, укороченное body)
        super(message);
        this.status = status;
    }

    public int status() {
        return status;
    }
}

А теперь — метод, который либо возвращает body, либо бросает CatalogApiException:

import java.net.http.HttpResponse;

private static String require2xx(HttpResponse<String> response) {
    int status = response.statusCode(); // Статус читаем один раз, чтобы не повторяться
    if (is2xx(status)) {
        // Только на успехе считаем body "данными"
        return response.body();
    }

    // На ошибке бросаем исключение со статусом и диагностикой (укороченный фрагмент body)
    throw new CatalogApiException(status, shorten(response.body(), 200));
}

Обратите внимание, что это метод почти “математический”: вход — HttpResponse<String>, выход — строка, но только при успехе. Всё остальное — исключение. Такой стиль резко сокращает количество “проверок по месту” и делает код чуть более линейным: вы либо получаете данные, либо не получаете.

И это не какая-то “религия исключений”, а простая дисциплина транспорта: transport-слой должен не пропускать ошибочные ответы дальше как будто это данные.

7. В ReadLater Starter: печать ошибок

Теперь давайте соединим всё в маленький сценарий, похожий на то, что делает наше приложение: отправили запрос, получили ответ, проверили статус, напечатали результат, а если ошибка — показали понятное сообщение.

Схематично это можно держать в ReadLaterApplication (или как у вас называется entrypoint) пока что прямо в “командной логике”:

import java.io.IOException;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

try {
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    String body = require2xx(response);

    System.out.println(body); // печатаем JSON как строку (пока)
} catch (CatalogApiException e) {
    System.out.println(userMessageForStatus(e.status())); // Книга не найдена в каталоге
} catch (HttpTimeoutException e) {
    System.out.println("Каталог отвечает слишком долго");
} catch (IOException e) {
    System.out.println("Сетевая ошибка: " + e.getMessage());
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    System.out.println("Запрос был прерван");
}

Здесь важно увидеть границу: CatalogApiException означает «ответ пришёл, но статус плохой», а остальные catch — что до ответа, с которым можно работать, мы вообще не добрались. Это другая ветка, другой смысл, другой текст.

Если свалить это обратно в один catch (Exception), вы потеряете важный сигнал: что именно случилось — сеть не дала ответ или сервер ответил отказом. А потом вы удивитесь, почему “всё выглядит одинаково”, хотя причины разные. Спойлер: потому что вы сами так написали.

8. Типичные ошибки с non-2xx

Ошибка №1: считать любой непустой body успехом.
Это самая частая ловушка: сервер вернул 404, но body содержит JSON с описанием ошибки, и рука тянется парсить его “как обычный ответ”. Итог — вы либо печатаете пользователю “мусор”, либо позже словите ошибку десериализации. Правильная привычка: сначала статус, потом body как данные.

Ошибка №2: проверять только status == 200.
Пока вы делаете один GET, может казаться, что “и так сойдёт”. Но транспортный код — это часть “скелета” приложения: он должен быть чуть более универсальным. Проверка 200..299 не сложнее, но намного надёжнее. Иначе вы сами себе закладываете баги на ровном месте, как только встретите 201 или 204 (или редирект 301 из-за неправильного baseUrl).

Ошибка №3: смешивать transport-ошибку и HTTP-ошибку в одну кашу.
IOException и HttpResponse со статусом 500 — это разные истории. В первом случае ответа нет вообще, во втором — ответ есть, просто он говорит “ошибка”. Если вы пользователю (или себе) сообщаете “что-то сломалось” в обоих случаях одинаково, вы лишаете себя шанса быстро понять, где проблема: в сети или в удалённом сервисе.

Ошибка №4: терять статус-код при формировании сообщения об ошибке.
Если вы бросаете new IllegalStateException("Ошибка") без статуса, вы обрезаете половину смысла. Статус — это короткое, но очень информативное резюме. Даже если вы не умеете красиво разбирать body, статус уже даёт направление: 404 — “не найдено”, 429 — “слишком часто”, 500 — “у них там пожар”.

Ошибка №5: печатать пользователю гигантское body “как есть”.
Иногда провайдер возвращает огромный HTML с трассировкой или страницу ошибки прокси. Если вы бездумно печатаете это в консоль, вы превращаете вывод в нечитаемый поток. Нормальная стратегия для учебного клиента — ограничить длину и показать только фрагмент. Полное body, если оно нужно, вы будете логировать позже через нормальный logging stack, а не через “простыню в консоль”.

1
Задача
Java Server, 15 уровень, 3 лекция
Недоступна
Успех по диапазону 2xx, а не только по 200
Успех по диапазону 2xx, а не только по 200
1
Задача
Java Server, 15 уровень, 3 лекция
Недоступна
Классификация ответа сервиса и transport-сбоя
Классификация ответа сервиса и transport-сбоя
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ