Пустой ответ, 503, обрыв и повреждённый JSON
Содержание курса
Четыре ответа без внешнего сервера
Чтобы пройти по всем четырём веткам 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 — воспроизводятся точно и предсказуемо, без остановки и перезапуска сервера.
