HTTP клиент на Go: делаем запросы правильно (продвинутый уровень)
Встроенный пакет net/http в Go предоставляет мощные, но часто недооцененные возможности для создания надежных HTTP-клиентов. На начальном уровне разработчики используют http.Get или http.Post, что удобно для быстрых прототипов, но совершенно непригодно для продакшена. В этой статье мы разберем продвинутые техники настройки HTTP-клиента на Go: тонкая настройка таймаутов, управление пулом соединений, создание middleware-цепочек, реализация ретраев и грамотная обработка ошибок. Вы научитесь писать клиентский код, который выдерживает высокие нагрузки и устойчив к сетевым сбоям.
1. Настройка таймаутов и пула соединений
Базовая ошибка — использование http.DefaultClient. У него нет таймаутов, и он может висеть бесконечно. Для продакшена всегда создавайте собственный экземпляр http.Client с тонкой настройкой http.Transport.
package main
import (
"net/http"
"time"
)
func NewClient() *http.Client {
transport := &http.Transport{
MaxIdleConns: 100, // Максимум idle-соединений во всем пуле
MaxConnsPerHost: 10, // Максимум соединений к одному хосту
MaxIdleConnsPerHost: 5, // Максимум idle-соединений к одному хосту
IdleConnTimeout: 90 * time.Second, // Время жизни idle-соединения
DisableCompression: false, // Включаем сжатие (gzip)
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second, // Общий таймаут на весь запрос
}
return client
}
Разберем ключевые параметры:
- Timeout — общий лимит времени на выполнение запроса (включая чтение тела ответа). Устанавливайте его в зависимости от ожидаемой скорости API (обычно 10-30 секунд).
- MaxIdleConns — количество keep-alive соединений, которые хранятся в пуле. Для высоконагруженных систем увеличьте до 200-500.
- MaxConnsPerHost — ограничивает количество одновременных соединений к одному хосту. Предотвращает перегрузку сервера.
Также рекомендуется установить DialContext с собственным таймаутом на установку соединения:
transport.DialContext = (&net.Dialer{
Timeout: 5 * time.Second, // Таймаут на TCP handshake
KeepAlive: 30 * time.Second, // Keep-alive для поддержания соединения
}).DialContext
2. Создание middleware-цепочек для HTTP-клиента
В Go нет встроенной middleware для клиента, но мы можем легко реализовать паттерн "декоратор" через обертку http.RoundTripper. Это позволяет добавлять логирование, метрики, аутентификацию и другие сквозные функции.
type LoggingRoundTripper struct {
Proxied http.RoundTripper
}
func (l *LoggingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := l.Proxied.RoundTrip(r)
duration := time.Since(start)
log.Printf("[%s] %s %s - %d (%v)",
r.Method, r.URL.String(),
r.Host, resp.StatusCode, duration,
)
return resp, err
}
// Использование:
transport := &LoggingRoundTripper{
Proxied: http.DefaultTransport,
}
client := &http.Client{Transport: transport}
Можно создать цепочку middleware, например, для добавления заголовков аутентификации:
type AuthRoundTripper struct {
Proxied http.RoundTripper
Token string
}
func (a *AuthRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+a.Token)
return a.Proxied.RoundTrip(r)
}
// Цепочка: Auth → Logging → Transport
client := &http.Client{
Transport: &LoggingRoundTripper{
Proxied: &AuthRoundTripper{
Proxied: http.DefaultTransport,
Token: os.Getenv("API_TOKEN"),
},
},
}
3. Реализация ретраев и обработка ошибок
Сетевые сбои неизбежны. Продвинутый клиент должен автоматически повторять запросы при временных ошибках. Важно отличать временные ошибки (таймауты, 5xx) от постоянных (4xx, неверный URL).
func DoWithRetry(client *http.Client, req *http.Request, maxRetries int) (*http.Response, error) {
var resp *http.Response
var err error
for i := 0; i <= maxRetries; i++ {
// Создаем новый body для каждого ретрая (тело запроса может быть прочитано)
if req.Body != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, err = client.Do(req)
if err != nil {
// Проверяем, временная ли ошибка
if isTemporaryError(err) {
waitTime := time.Duration(math.Pow(2, float64(i))) * time.Second
log.Printf("Retry %d after %v: %v", i+1, waitTime, err)
time.Sleep(waitTime)
continue
}
return nil, err // Постоянная ошибка, не ретраим
}
// Проверяем статус-код
if resp.StatusCode >= 500 {
resp.Body.Close()
if i == maxRetries {
return nil, fmt.Errorf("server error after %d retries: %d", maxRetries, resp.StatusCode)
}
waitTime := time.Duration(math.Pow(2, float64(i))) * time.Second
log.Printf("Retry %d after %v (status %d)", i+1, waitTime, resp.StatusCode)
time.Sleep(waitTime)
continue
}
return resp, nil // Успех
}
return nil, fmt.Errorf("unexpected retry exit")
}
func isTemporaryError(err error) bool {
netErr, ok := err.(net.Error)
return ok && netErr.Temporary()
}
Ключевые моменты:
- Экспоненциальная задержка (backoff): 1с, 2с, 4с и т.д. Это снижает нагрузку на сервер.
- Проверка временных ошибок: используйте
net.Error.Temporary(). - Сброс тела запроса: для ретраев нужно