Go HTTP-клиент: зачем читать и закрывать response.Body

Сервис обращается к другому HTTP API несколько раз подряд. Первый запрос проходит, а потом растёт число открытых соединений и задержка. В коде легко увидеть client.Do(req) и проверку статуса, но пропустить оставшийся ресурс: response.Body — поток данных ответа, который живёт после получения заголовков.

Что происходит после client.Do

client.Do возвращает ответ, когда пришли заголовки. Тело ещё может читаться по сети. Вызывающий код обязан закрыть Body, даже если статус 404 или 503 и содержимое не нужно. Поэтому сразу после успешного Do поставьте defer resp.Body.Close() в функции, обрабатывающей один запрос.

Для повторного использования HTTP/1-соединения обычно важно и дочитать тело до конца. Если вы прочли короткий ответ целиком и закрыли его, транспорт может оставить TCP-соединение в пуле для следующего запроса к тому же серверу. Если оборвали чтение на середине большого ответа, закрытие освободит ресурс, но тот же сокет может не пригодиться повторно.

Это особенно важно в цикле по одному адресу сервиса: без reuse транспорт снова устанавливает соединение, хотя предыдущее могло обслужить следующий запрос. Но статус HTTP сам по себе ничего не закрывает. Ответ с 503 всё равно имеет Body; client.Do обычно возвращает его без ошибки, и код должен решить, что прочесть из ответа, а затем закрыть поток.

Маленький ответ: дочитать, ограничить и проверить reuse

Представим внутренний endpoint, который возвращает короткую метку товара. Контракт задаёт максимум 32 байта. Читаем не больше 33: лишний байт показывает превышение лимита, а небольшой корректный ответ дочитывается до конца. Один переиспользуемый http.Client и его транспорт обслуживают обе попытки. Тест запускает локальный httptest-сервер и через httptrace.GotConn проверяет, что второму запросу досталось уже использованное HTTP/1-соединение.

В пустой папке создайте go.mod:

module example.com/bodydemo

go 1.22

Код ниже сохраните как body_test.go:

package bodydemo

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "net/http/httptrace"
    "strings"
    "testing"
    "time"
)

const maxLabel = 32

func fetchLabel(ctx context.Context, client *http.Client, url string) (string, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return "", err
    }
    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("server returned %s", resp.Status)
    }
    data, err := io.ReadAll(io.LimitReader(resp.Body, maxLabel+1))
    if err != nil {
        return "", err
    }
    if len(data) > maxLabel {
        return "", fmt.Errorf("label exceeds %d bytes", maxLabel)
    }
    return string(data), nil
}

func TestFetchLabel(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path == "/large" {
            io.WriteString(w, strings.Repeat("x", maxLabel+1))
            return
        }
        io.WriteString(w, "alpha")
    }))
    defer server.Close()

    transport := &http.Transport{MaxIdleConnsPerHost: 1}
    defer transport.CloseIdleConnections()
    client := &http.Client{Transport: transport, Timeout: time.Second}

    first, err := fetchLabel(context.Background(), client, server.URL+"/small")
    if err != nil || first != "alpha" {
        t.Fatalf("first: label=%q err=%v", first, err)
    }

    var reused bool
    ctx := httptrace.WithClientTrace(context.Background(), &httptrace.ClientTrace{
        GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
    })
    second, err := fetchLabel(ctx, client, server.URL+"/small")
    if err != nil || second != "alpha" || !reused {
        t.Fatalf("second: label=%q reused=%t err=%v", second, reused, err)
    }

    _, err = fetchLabel(context.Background(), client, server.URL+"/large")
    if err == nil || !strings.Contains(err.Error(), "exceeds") {
        t.Fatalf("large response: %v", err)
    }
}

Выполните go test -v. Первая проверка получает alpha, вторая дополнительно требует reused=true, а запрос /large возвращает ошибку лимита. Важен порядок в fetchLabel: defer появляется только после ответа без транспортной ошибки; поэтому не вызывается на несуществующем Body. При плохом HTTP-статусе отложенное закрытие тоже сработает.

В реальном клиенте лимит выбирайте по контракту конкретного endpoint. io.LimitReader ограничивает объём, который этот код загрузит в память; 33-й байт уже делает ответ слишком большим для метки. Для крупных файлов нужен потоковый разбор или запись с отдельным пределом, а не io.ReadAll без границы. Если сервер присылает сжатый ответ, лимит должен относиться к данным после распаковки — эту отдельную проблему разбирает статья о размере распакованного тела.

Лимит по Content-Length здесь был бы слабее: сервер может передавать тело частями, и заголовок длины отсутствует. Чтение maxLabel+1 проверяет фактические байты, которые увидел клиент. Если метка занимает 32 байта или меньше, чтение доходит до конца тела и соединение может вернуться в пул. На 33 байтах функция прекращает работу с ответом и сообщает именно о нарушенном лимите.

Один запрос — одна область жизни Body

При обходе сотен URL не ставьте defer resp.Body.Close() прямо внутри длинного цикла: все закрытия отложатся до выхода из функции. Вынесите один HTTP-вызов в небольшую функцию, как fetchLabel, и вызывайте её в цикле. Тогда каждый ответ закрывается перед следующей итерацией. Повторно используйте http.Client и транспорт, вместо создания нового клиента на каждый URL: именно транспорт хранит свободные соединения.

Закрытие тела не заменяет таймаут и отмену запроса. Здесь Client.Timeout ограничивает весь обмен, включая чтение ответа; если запрос пришёл от внешнего клиента, его контекст стоит передать зависимому HTTP-вызову. А решение о повторе после ошибки зависит от того, что мог успеть сделать сервер; это отдельный разбор HTTP retry в Go.

Дальше — контракты между сервисами

В платном курсе «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker» вы напишете клиентский вызов другого Go-сервиса, обработаете пустой ответ, 503 и повреждённый JSON, затем проверите контракт обработчика и клиента. Дальше курс ведёт к дедлайнам, безопасным повторам и согласованности данных между сервисами. Откройте программу курса и урок об ошибках HTTP-клиента, чтобы продолжить от управления ответом к поведению всего сервиса.