Урок курса
Пустой ответ, 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. Это интерфейс с единственным методом:

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-массив с одним массивом сценариев: [[...]].
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
