HTTP-клиент к внешнему API в Go: чек-лист
Дефолтный http.Client без таймаутов рано или поздно подвесит сервис на зависшем соединении. Разбираем, что настроить в клиенте к чужому API, чтобы он не убивал вас в проде.

Строка http.Get(url) в Go выглядит безобидно, пока внешний API отвечает быстро. Но у http.DefaultClient нет общего таймаута: если провайдер зависнет с открытым соединением и не пришлёт ни байта, ваша горутина будет ждать бесконечно. Под нагрузкой это превращается в лавину заблокированных горутин, исчерпание пула соединений и 502 на вашей стороне из-за чужого сбоя. Ниже — чек-лист того, что стоит настроить в клиенте к внешнему API, с ценой каждого решения. Актуально для Go 1.22+, но большинство пунктов работает с давних версий.
1. Таймауты — не один, а несколько
Главная ошибка — думать, что Client.Timeout решает всё. Он задаёт общий лимит на весь запрос, включая чтение тела. Если вы стримите большой ответ, общий таймаут придётся ставить огромным, и тогда он перестанет ловить зависшее подключение. Поэтому таймауты живут на разных уровнях.
- Client.Timeout — общий лимит от начала запроса до закрытия тела ответа.
- Transport.DialContext с таймаутом на установку TCP-соединения (обычно через
net.Dialer). - Transport.TLSHandshakeTimeout — на рукопожатие TLS.
- Transport.ResponseHeaderTimeout — сколько ждать первого байта заголовков после отправки запроса. Ловит именно тот случай, когда соединение установлено, а сервер молчит.
package apiclient
import (
"net"
"net/http"
"time"
)
// NewHTTPClient собирает клиент с явными таймаутами на каждом этапе.
func NewHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 3 * time.Second, // установка TCP-соединения
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second, // ждём первый байт заголовков
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
}
return &http.Client{
Timeout: 15 * time.Second, // общий лимит на весь запрос
Transport: transport,
}
}
Цифры здесь — стартовая точка, а не догма. Их надо подбирать под реальную задержку конкретного API: если провайдер честно отвечает за 8 секунд, Timeout: 15s оправдан, а ResponseHeaderTimeout: 5s — уже нет.
2. context вместо голого Client.Timeout
Client.Timeout одинаков для всех запросов. Но вызов из фонового джоба и вызов внутри обработки пользовательского запроса терпят разное время ожидания. Пробрасывайте context.Context в каждый запрос через http.NewRequestWithContext — тогда таймаут и отмена управляются вызывающей стороной.
func (c *Client) Fetch(ctx context.Context, id string) (*Item, error) {
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/items/"+id, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch item %s: %w", id, err)
}
defer resp.Body.Close()
// ... разбор ответа
return nil, nil
}
Когда родительский контекст отменён (клиент закрыл соединение, истёк дедлайн запроса), Do прервётся сам. Это важнее любого статического таймаута: вы не держите соединение к чужому API ради ответа, который уже некому вернуть.
Клиент без проброшенного контекста — это ресурс, который никто не может отменить. Один медленный внешний вызов утаскивает за собой всю цепочку, потому что отменить его нечем.
3. Тело ответа: читать и закрывать
Два правила, которые нарушают чаще всего.
Всегда закрывайте resp.Body — иначе соединение не вернётся в пул и не переиспользуется, а при потоке запросов вы упрётесь в лимит открытых сокетов. defer resp.Body.Close() ставится сразу после проверки ошибки Do, до любого return.
Дочитывайте тело до конца перед закрытием, если хотите переиспользовать keep-alive соединение. Если вы прочитали половину и закрыли, транспорт не сможет вернуть соединение в пул. При ранних выходах помогает io.Copy(io.Discard, resp.Body) перед Close.
Отдельная ловушка — ограничение размера. Внешний API может (случайно или намеренно) отдать гигабайтный ответ, и io.ReadAll положит вам память. Оборачивайте тело в io.LimitReader:
const maxBody = 5 << 20 // 5 МБ
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
4. Статус-коды: не всё, что 200, — успех, и наоборот
Client.Do возвращает ошибку только при сетевых проблемах и нарушении таймаута. HTTP 500, 404, 429 — это успешный с точки зрения Go запрос: err == nil, а в resp.StatusCode лежит код. Проверять статус нужно вручную.
- 2xx — разбираем тело.
- 429 и 503 — стоит посмотреть заголовок
Retry-Afterи уйти в повтор, а не считать это фатальной ошибкой. - 4xx (кроме 429) — ошибка на вашей стороне (неверный запрос, истёкший ключ). Повтор бессмысленен.
- 5xx — проблема провайдера, повтор может помочь.
5. Ретраи — только на безопасных методах и с задержкой
Повторять GET обычно безопасно: он идемпотентен. Повторять POST, создающий заказ, — нет: получите два заказа. Ретраить вслепую все запросы — прямой путь к дублям и к тому, чтобы добить уже перегруженный API.
Рабочая схема: повторяем только идемпотентные методы, только на кодах 429/5xx и сетевых таймаутах, с экспоненциальной задержкой и джиттером, с ограничением по числу попыток. Джиттер (случайная добавка к задержке) нужен, чтобы сотня клиентов не пришла с повтором в одну и ту же миллисекунду.
func withRetry(ctx context.Context, attempts int, fn func() error) error {
var lastErr error
for i := 0; i < attempts; i++ {
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
// экспоненциальная пауза с джиттером
backoff := time.Duration(1<
Проверьте, что генератор rand инициализирован. В Go 1.20+ глобальный math/rand сидируется автоматически, для более старых версий нужен явный сид. Для криптостойкости здесь math/rand достаточно — джиттер не секрет.
6. Один клиент на всё приложение
Создавать *http.Client (а точнее *http.Transport) на каждый запрос — распространённая ошибка. Пул keep-alive соединений живёт внутри транспорта. Новый транспорт на каждый вызов означает новое TCP- и TLS-рукопожатие каждый раз, рост задержки и утечку сокетов в состоянии TIME_WAIT. Создайте клиент один раз при старте и переиспользуйте — он потокобезопасен.
Сравнение подходов к таймауту
Подход Что ловит Чем платите
http.DefaultClient Ничего: таймаута нет Зависшие горутины под нагрузкой
Только Client.Timeout Общее зависание запроса Не различает установку соединения и медленное тело; один лимит на все вызовы
Таймауты на Transport Зависание на TCP/TLS/заголовках отдельно Больше кода в настройке, надо подбирать цифры
context на каждый запрос Отмену сверху и разные дедлайны на вызов Нужно пробрасывать ctx через весь стек
На практике это не альтернативы, а слои: таймауты транспорта плюс контекст на каждый запрос дают и защиту от зависшего сокета, и управляемую отмену.
Что ломается в проде
- Утечка соединений — забыли
Body.Close() на пути с ошибкой. Симптом: растёт число открытых сокетов, потом отказы на установку новых.
- Ретрай-шторм — повторы без джиттера синхронизируются и добивают API, который только начал восстанавливаться.
- Дубли от повтора POST — не проверили идемпотентность метода. Лечится ключом идемпотентности в заголовке, если API его поддерживает.
- OOM от большого тела —
io.ReadAll без LimitReader на ответе, размер которого вы не контролируете.
- Ложная уверенность в 200 — не проверили
StatusCode, разбираете HTML страницы ошибки как JSON и получаете невнятный сбой парсинга.
Что настроить дальше
Базовый клиент готов — дальше стоит смотреть на устойчивость под нагрузкой. Circuit breaker (размыкатель) перестаёт слать запросы к API, который стабильно отвечает ошибками, вместо того чтобы копить таймауты. Rate limiter (например, golang.org/x/time/rate) удержит вас в пределах квоты провайдера. И заведите метрики: задержка запроса по перцентилям, доля ошибок по статус-кодам, число активных соединений транспорта. Именно эти три показателя первыми покажут, что внешний API начал деградировать, — до того, как это увидят ваши пользователи. Инструментировать транспорт удобно через net/http/httptrace или готовые обёртки для OpenTelemetry.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: net/http package — Go Documentation, net/http/httptrace package — Go Documentation
Частые вопросы
У меня старая версия Go, всё это будет работать?
Достаточно ли одного Client.Timeout вместо всех этих таймаутов?
Почему нельзя просто ретраить любой запрос при ошибке?
Что делать, если запросы иногда зависают, хотя таймауты выставлены?
Нужно ли создавать отдельный клиент на каждый внешний API?
Как понять, что стоит добавить circuit breaker?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.