Урок курса

Пустой ответ, 503, обрыв и повреждённый JSON

Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker

Полученный ответ и ошибка отправки — разные наблюдения

Когда клиент вызывает client.Do(req), возможны принципиально разные исходы, и Go разграничивает их на уровне возвращаемых значений.

200 с пустым списком — сервер ответил нормально. Do вернул (resp, nil): ошибки нет, статус 200, тело декодируется без проблем, декодер создаёт корректный пустой срез []Article{}. Список длиной ноль — валидный результат, а не признак сбоя.

503 — сервер тоже ответил, HTTP-обмен завершился. Do снова вернул (resp, nil), ошибки Go нет. Но статус говорит, что операция недоступна. Информация о неудаче находится в resp.StatusCode, а не в значении err.

Обрыв соединения — HTTP-ответа не существует вообще. Do вернул (nil, err). Обращаться к resp в этой ветке нельзя: указатель не инициализирован.

200 с повреждённым JSON — HTTP-обмен прошёл успешно, Do вернул (resp, nil), статус 200. Сбой происходит позже, когда json.Decoder читает тело: он не может разобрать неполный или невалидный JSON и возвращает ошибку уже из Decode, а не из Do.

Из этой логики вытекает порядок проверок в коде:

Наблюдение Do вернул ошибку? Есть resp.StatusCode? Можно использовать список?
200 и [] нет да, 200 да, его длина равна нулю
503 и тело ошибки нет да, 503 нет
Обрыв соединения да нет нет
200 и повреждённый JSON нет да, 200 нет

Ниже функция, которая сохраняет это различие в печатном результате. Структура Article содержит поля ID string и Title string с JSON-тегами id и title, как в API статей. Для запуска полного файла нужен также код подменного транспорта из следующего шага.


func probe(ctx context.Context, client *http.Client) string {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "http://articles.test/articles", nil)
    if err != nil {
        return "invalid request: " + err.Error()
    }
    resp, err := client.Do(req)
    if err != nil {
        return "transport error: " + err.Error()
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return fmt.Sprintf("HTTP %d", resp.StatusCode)
    }
    var articles []Article
    if err := json.NewDecoder(resp.Body).Decode(&articles); err != nil {
        return "JSON error: " + err.Error()
    }
    return fmt.Sprintf("HTTP 200, articles=%d", len(articles))
}

Проверка err стоит до обращения к resp: при ошибке Do нельзя полагаться на обычный ответ с кодом. Do возвращает ответ 503 без Go-ошибки, поэтому статус проверяется отдельно второй проверкой. Тело каждого полученного ответа закрывается через defer — в том числе в ветке 503 и при ошибке разбора JSON: в обоих случаях resp.Body открыт и его нужно освободить. Для 200 [] декодер создаёт корректный пустой срез, а функция возвращает articles=0.

Четыре ответа без внешнего сервера

Чтобы пройти по всем четырём веткам probe, не нужно запускать и останавливать реальный сервер. http.Client отправляет запрос не напрямую в сеть, а через поле Transport типа http.RoundTripper. Это интерфейс с единственным методом:

Четыре разных исхода HTTP-чтения. Полученный HTTP-статус, транспортная ошибка и ошибка декодирования — разные наблюдения.
Полученный HTTP-статус, транспортная ошибка и ошибка декодирования — разные наблюдения.

Статичная схема (PNG)

RoundTrip(*http.Request) (*http.Response, error)

Если подставить собственную реализацию, client.Do останется настоящим вызовом Go HTTP-клиента, но внешнее TCP-соединение не создаётся. Вместо него срабатывает ваша функция и возвращает готовый *http.Response или ошибку. Здесь подменный транспорт возвращает результат сразу и не проверяет отмену. Чтобы моделировать ожидание и отмену, собственный RoundTripper должен учитывать r.Context().Done().

В коде ниже roundTripFunc — это тип-адаптер: функция с нужной сигнатурой автоматически становится RoundTripper через метод RoundTrip. Вспомогательная response собирает минимальный *http.Response с нужным статусом и строкой в теле через io.NopCloser, чтобы тело корректно реализовывало io.ReadCloser.

Для воспроизведения создайте один main.go. Поместите в его начало следующий блок, затем функцию probe из предыдущего шага и код ниже. Вместе они образуют полную программу; отдельный файл к уроку не прикреплён.


package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"
)

type Article struct {
    ID    string `json:"id"`
    Title string `json:"title"`
}


type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
    return f(r)
}

func response(status int, body string) *http.Response {
    return &http.Response{
        StatusCode: status,
        Body:       io.NopCloser(strings.NewReader(body)),
        Header:     make(http.Header),
    }
}

func main() {
    cases := []struct {
        name string
        do   roundTripFunc
    }{
        {"empty", func(*http.Request) (*http.Response, error) {
            return response(200, "[]"), nil
        }},
        {"unavailable", func(*http.Request) (*http.Response, error) {
            return response(503, `{"error":"temporarily unavailable"}`), nil
        }},
        {"connection", func(*http.Request) (*http.Response, error) {
            return nil, errors.New("connection refused")
        }},
        {"broken JSON", func(*http.Request) (*http.Response, error) {
            return response(200, "["), nil
        }},
    }
    for _, tc := range cases {
        client := &http.Client{Transport: tc.do}
        fmt.Printf("%s: %s\n", tc.name, probe(context.Background(), client))
    }
}

В main каждый тест-кейс задаёт свою roundTripFunc. Разберём, какая ветка probe срабатывает в каждом случае.

  • empty — транспорт возвращает (response(200, "[]"), nil). Do получает готовый ответ без ошибки. В probe первая проверка err != nil не срабатывает, вторая StatusCode != 200 тоже. Декодер читает [] и создаёт пустой срез. Результат: articles=0.

  • unavailable — транспорт возвращает (response(503, …), nil). Do снова успешен. В probe ветка err != nil пропускается, зато StatusCode != 200 истинна — функция возвращает HTTP 503 и выходит. Тело закрыто через defer.

  • connection — транспорт возвращает (nil, errors.New("connection refused")). Do передаёт эту ошибку наружу. В probe первая же проверка err != nil истинна — функция немедленно возвращает transport error: …. К resp обращений нет.

  • broken JSON — транспорт возвращает (response(200, "["), nil). HTTP-обмен прошёл, Do вернул resp без ошибки. В probe обе первые проверки проходят. Декодер начинает читать незакрытый JSON-массив [ и обнаруживает конец потока раньше закрывающей скобки — возвращает unexpected EOF. Ветка JSON error срабатывает уже после завершения HTTP.

Команда go run main.go для собранного файла печатает:


empty: HTTP 200, articles=0
unavailable: HTTP 503
connection: transport error: Get "http://articles.test/articles": connection refused
broken JSON: JSON error: unexpected EOF

Читайте трассу слева направо: только connection прерывается на Do; broken JSON доходит до декодера после успешного HTTP-обмена. Именно это и делает подменный транспорт полезным инструментом: обе «поздние» ветки — 503 и сломанный JSON — воспроизводятся точно и предсказуемо, без остановки и перезапуска сервера.

Таймаут и пустой результат

Контекст ограничивает время ожидания запроса независимо от client.Timeout. В клиентском файле из предыдущего урока он создаётся перед вызовом FetchArticles и передаётся в http.NewRequestWithContext:


client := &http.Client{Timeout: 3 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
articles, err := FetchArticles(ctx, client, baseURL)

Здесь действуют два независимых ограничения: контекст с двухсекундным сроком и client.Timeout в три секунды. http.Client уважает оба и прекращает запрос, как только истечёт любое из них. При обычном зависшем сервере первым сработает контекст — через две секунды.

Важно понимать, когда именно проявляется отмена контекста. Если Do ещё не получил заголовки ответа, он вернёт ошибку напрямую — (nil, err). Если же заголовки уже пришли и Do вернул resp, отмена проявится позже: при чтении resp.Body в json.Decoder.Decode. Ошибка чтения тела из-за отмены — отдельный случай, а не повреждённый JSON из четвёртой строки таблицы, даже если она возникает при Decode. Ни в том ни в другом случае сервер не отправлял статус 503; он мог вообще ничего не ответить. Чтобы программно проверить причину отмены, применяйте errors.Is к err из FetchArticles: эта функция сохраняет исходную причину ошибки. probe возвращает только строку, поэтому его результат для такой проверки не подходит:

errors.Is(err, context.DeadlineExceeded)  // истёк срок контекста
errors.Is(err, context.Canceled)          // контекст отменён явно

errors.Is работает и с исходной ошибкой. Если функция добавляет к ней пояснение, она должна сохранить причину через %w, чтобы вызывающий код мог её проверить. Угадывать причину по тексту сообщения ненадёжно: формулировки могут меняться между версиями Go.

Теперь о главной ловушке. Соблазн привести все сбои к одному виду — пустому срезу — понятен: меньше ветвлений, код короче. Вот опасная версия:


articles, err := FetchArticles(ctx, client, baseURL)
if err != nil {
    articles = []Article{} // сбой теперь выглядит как ответ 200 []
}
fmt.Println("статей:", len(articles))

Она напечатает статей: 0 одинаково при настоящем 200 [], при 503 и при обрыве связи. Вызывающий код теряет информацию о том, что произошло. Пустой срез в Go — корректное значение, а не сигнал об ошибке; подменяя им ошибку, вы делаете её невидимой.

Правильная форма сохраняет оба значения раздельно:


articles, err := FetchArticles(ctx, client, baseURL)
if err != nil {
    fmt.Println("не удалось получить статьи:", err)
    return
}
fmt.Println("статей:", len(articles))

Эта проверка не решает, повторять ли запрос или как реагировать на сбой в масштабе системы. Она делает ровно одно: точно называет то, что клиент наблюдал при одном обращении к API статей. Именно это различие — между «сервер сказал, что статей нет» и «мы вообще не получили ответ» — и нужно сохранить для любого кода, который будет работать с результатом.

Задача 1

client.Do вернул err == nil, resp.StatusCode == 503. Что произошло?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

В какой трассе клиент может безопасно сообщить «статей: 0» как результат успешного запроса?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Срок ctx истёк до получения ответа, и Do вернул ошибку. Какой статус получил клиент?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Различите четыре исхода запроса статей

Напишите Classify(ctx, client, endpoint) Outcome. Функция делает один GET по переданному полному адресу endpoint. Клиент получает в конструкторе подменный HTTP transport, поэтому для решения не нужно запускать сервер, ждать таймаут или обращаться к внешней сети.

Верните один из результатов:

Наблюдение Kind Status Articles Err
200 с корректным JSON-массивом, в том числе [] OK 200 декодированный срез nil
любой полученный статус, отличный от 200 HTTPError фактический код nil nil
ошибка создания или отправки запроса TransportError 0 nil ошибка, доступная через errors.Is
200 с неверным JSON InvalidJSON 200 nil ошибка декодирования

Создайте запрос с переданным ctx. Сначала проверяйте ошибку Do, затем, если ответ получен, закрывайте Body на всех путях возврата. Для HTTP 503 Do обычно возвращает ответ без Go-ошибки; для 200 [] должен сохраниться успешный пустой срез, а не ошибка.

Все типы и константы уже есть в шаблоне. Ввод через stdin и печать в stdout не нужны: проверка вызывает функцию с разными ответами. Не анализируйте текст ошибок: сохраняйте исходную причину через %w либо возвращайте её напрямую.

Запуск на Latorn: редактируйте реализацию в полном шаблоне, сохраняя предоставленные объявления и публичный адаптер RunScenarios. main не требуется. Адаптер принимает массив сценариев и возвращает наблюдения; ожидаемые результаты сравниваются отдельно на сервере. Для собственного запуска аргументы — JSON-массив с одним массивом сценариев: [[...]].

РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку