HTTP-клиент в Go: таймауты, повторы и пул соединений
Стандартный http.Client в Go по умолчанию работает без таймаута и с неограниченным пулом соединений. Разбираем, как настроить Transport, грамотно выставить дедлайны на каждом этапе запроса и организовать повторы без лавины — с кодом, замерами и граничными случаями.

Почему дефолтный клиент опасен
У http.DefaultClient поле Timeout равно нулю. Это значит: если удалённый сервер принял соединение и замолчал, горутина повиснет навсегда. В сервисе с парой сотен RPS такие горутины копятся, файловые дескрипторы заканчиваются, и через несколько минут процесс перестаёт принимать новые соединения. Дефолтный http.Transport при этом держит неограниченный пул idle-соединений на хост — при всплеске нагрузки это сотни TCP-сокетов, которые не переиспользуются и не закрываются вовремя.
Ниже — три слоя настройки, которые превращают клиент из «работает на локалке» в «выдерживает прод»: таймауты на каждой фазе запроса, ограниченный пул соединений и повторы с экспоненциальной задержкой. Всё на стандартной библиотеке Go 1.22, без сторонних зависимостей.
Таймауты: не один, а четыре
Поле http.Client.Timeout задаёт общий дедлайн — от отправки запроса до полного чтения тела ответа. Но внутри одного запроса есть четыре фазы, и каждая может зависнуть по-своему:
- DNS-резолв и установка TCP-соединения — управляется через
net.Dialer.Timeout; - TLS-хендшейк — через
http.Transport.TLSHandshakeTimeout; - ожидание первых байтов ответа (time to first byte) — через
http.Transport.ResponseHeaderTimeout; - чтение тела — покрывается общим
http.Client.Timeoutили контекстом с дедлайном.
Если вы выставите только общий Timeout: 30s, а TLS-хендшейк с медленным хостом займёт 29 секунд, на чтение тела останется одна секунда — и запрос упадёт по таймауту уже на полезных данных. Поэтому фазные таймауты задают отдельно.
Пример: настройка клиента с фазными таймаутами
// Go 1.22+
package main
import (
"net"
"net/http"
"time"
)
func newHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second, // TCP-соединение
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second, // общий дедлайн
}
}
Проверка: запустите запрос к заведомо медленному эндпоинту (например, httpbin.org/delay/15) — клиент должен вернуть ответ, а не зависнуть. Если задержка превысит ResponseHeaderTimeout, вы получите ошибку с текстом net/http: timeout awaiting response headers.
Контекст с дедлайном (context.WithTimeout) перебиваетhttp.Client.Timeout, если его дедлайн наступает раньше. Используйте контекст, когда таймаут зависит от вызывающего кода — например, от gRPC-дедлайна входящего запроса.
Пул соединений: что крутить в Transport
Три параметра http.Transport определяют размер пула и его поведение под нагрузкой:
| Параметр | По умолчанию | Что регулирует | Когда менять |
|---|---|---|---|
MaxIdleConns | 100 | Общий лимит idle-соединений по всем хостам | Много разных хостов — поднять; один хост — значение не критично |
MaxIdleConnsPerHost | 2 | Idle-соединений на один хост | Высокий RPS к одному бэкенду — поднять до 10-50 |
MaxConnsPerHost | 0 (безлимит) | Все соединения (активные + idle) на хост | Защита от лавины — ставить явно |
Дефолтные два idle-соединения на хост — главная причина, по которой нагрузочные тесты показывают плохой throughput при переиспользовании клиента. При 200 RPS к одному API два idle-соединения означают, что 198 запросов каждый раз открывают новый TCP+TLS — это десятки миллисекунд на каждый.
Граничный случай: MaxConnsPerHost как backpressure
Если MaxConnsPerHost установлен, а все соединения заняты, следующий запрос блокируется в ожидании свободного слота. Это контролируемая очередь, а не отказ. Но если общий Timeout клиента истечёт за время ожидания в очереди, запрос вернёт ошибку таймаута, хотя до сервера он даже не дошёл. Текст ошибки в этом случае будет содержать context deadline exceeded, а не сетевую ошибку — и логи могут ввести в заблуждение.
Рекомендация: MaxConnsPerHost ставьте не меньше, чем ожидаемый параллелизм запросов к этому хосту. Если сервис шлёт к API до 50 одновременных запросов, значение 20 создаст очередь и скроет реальную латентность.
Повторы: не retry-библиотека, а 30 строк
Повторять запрос после ошибки кажется простым, но наивный retry(3) превращает 300 RPS в 900 RPS на уже больной бэкенд. Три правила, без которых повторы вредят:
- Повторять только идемпотентные запросы или запросы, которые гарантированно не дошли до сервера (ошибка соединения, а не 500).
- Экспоненциальная задержка с джиттером — чтобы клиенты не стучали синхронно.
- Ограниченное число попыток и общий дедлайн через контекст.
Пример: retry с экспоненциальной задержкой
// Go 1.22+
package main
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"time"
)
// doWithRetry выполняет запрос с повторами.
// Повторяет только при сетевых ошибках и статусах 502, 503, 429.
func doWithRetry(
ctx context.Context,
client *http.Client,
req *http.Request,
maxAttempts int,
) (*http.Response, error) {
var resp *http.Response
var err error
baseDelay := 500 * time.Millisecond
for attempt := range maxAttempts {
resp, err = client.Do(req.WithContext(ctx))
if err == nil && resp.StatusCode != 502 &&
resp.StatusCode != 503 && resp.StatusCode != 429 {
return resp, nil
}
// Закрываем тело, если статус retryable
if resp != nil {
resp.Body.Close()
}
if attempt == maxAttempts-1 {
break
}
// Экспоненциальная задержка: 500ms, 1s, 2s + джиттер до 25%
delay := baseDelay * (1 << attempt)
jitter := time.Duration(rand.Int64N(int64(delay / 4)))
delay += jitter
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
if err != nil {
return nil, fmt.Errorf("все %d попыток провалились: %w", maxAttempts, err)
}
return resp, errors.New("retryable status after all attempts")
}
Обратите внимание: тело запроса (req.Body) при повторе должно быть перечитываемым. Если вы передаёте bytes.Reader, вызовите req.Body.(io.Seeker).Seek(0, io.SeekStart) перед повтором. Для POST с потоковым телом повтор невозможен без буферизации — и это сознательное ограничение, а не баг.
* Джиттер — случайная добавка к задержке между повторами. Без него все клиенты, получившие ошибку одновременно, отправят повтор в один и тот же момент, создавая «стадный эффект» (thundering herd).
Что ломается первым
Настроенный клиент — не гарантия. Вот три сценария, которые ломают даже правильно сконфигурированный Transport:
- Утечка тел ответа. Если не вызвать
resp.Body.Close(), TCP-соединение не вернётся в пул. При 100 RPS за минуту это 6 000 повисших сокетов. Линтерbodycloseловит это на этапе CI. - Частичное чтение тела. Если вы читаете только заголовки и закрываете тело, Go не может переиспользовать соединение — ему нужно либо дочитать тело, либо разорвать TCP. Для коротких ответов (до нескольких килобайт) дешевле дочитать:
io.Copy(io.Discard, resp.Body)передClose(). - DNS-кеширование. Стандартный резолвер Go кеширует DNS-записи на уровне ОС, а не клиента. Если бэкенд за балансировщиком меняет IP, старые соединения из пула продолжают ходить на прежний адрес, пока не истечёт
IdleConnTimeout. Для критичных сценариев придётся либо сократитьIdleConnTimeout, либо использовать кастомныйDialContextс принудительным резолвом.
Сводка: минимальный чек-лист для прода
| Что проверить | Плохо | Нормально |
|---|---|---|
| Общий таймаут | 0 (безлимит) | 10–60 с, зависит от SLA |
| TLS Handshake Timeout | 0 | 5–10 с |
| Response Header Timeout | 0 | 5–15 с |
| MaxIdleConnsPerHost | 2 (дефолт) | 10–50, по RPS к хосту |
| MaxConnsPerHost | 0 (безлимит) | Равен или чуть больше пикового параллелизма |
| Retry | Без задержки, без лимита | Экспоненциальная задержка + джиттер, 3–5 попыток |
| Body.Close() | Иногда забыт | defer resp.Body.Close() сразу после проверки err |
Что делать дальше
После настройки клиента следующий шаг — наблюдаемость. Стандартный http.Transport поддерживает хуки через httptrace.ClientTrace: вы можете замерить время каждой фазы запроса (DNS, connect, TLS, TTFB) и экспортировать метрики в Prometheus или OpenTelemetry. Без этих метрик вы не узнаете, что MaxConnsPerHost стал бутылочным горлышком, пока пользователи не пожалуются.
Отдельная тема — circuit breaker, который отключает запросы к хосту после серии ошибок. Retry спасает от единичных сбоев, circuit breaker — от каскадного отказа. Но это уже другой слой, и смешивать его с настройкой Transport не стоит.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: net/http — Go standard library documentation, net/http/httptrace — Go standard library documentation
Частые вопросы
Можно ли использовать http.DefaultClient в продакшене?
Нужно ли создавать отдельный клиент для каждого запроса?
Что будет, если выставить слишком маленький MaxConnsPerHost?
Как понять, что соединения утекают и не возвращаются в пул?
Retry безопасен для POST-запросов?
А если у меня другая версия Go, код будет работать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.