Создание HTTP-клиентов может показаться простым делом, пока в три часа ночи ваш сервис не начнёт перегружать внешний API, который не отвечает, исчерпает лимиты запросов и приведёт к полному сбою. Мы все бывали в таких ситуациях. Или, может быть, вы ещё не сталкивались с этим — примите это как дружеское предупреждение от того, кто уже прошёл через это.
Разница между обычным HTTP-клиентом и клиентом, готовым к использованию в продакшене, часто сводится к двум обманчиво простым концепциям: повторным попыткам и автоматическим выключателям (circuit breakers). Они не выглядят впечатляющими, но спасут вас, когда всё пойдёт не так.
Почему ваш простой HTTP-клиент потерпит неудачу
Давайте будем честными. Написать HTTP-клиент на Go — это до смешного просто. Стандартная библиотека практически всё делает за вас:
resp, err := http.Get("https://api.example.com/data")
Красиво. Элегантно. Совершенно недостаточно для реального мира.
В тот момент, когда внешний сервис даёт сбой — тайм-аут сети, временный перегруз, исчерпание пула соединений с базой данных — ваш код терпит неудачу. Без повторных попыток, без льготного периода, просто немедленный сбой. И если вы повторно обращаетесь к этому сервису в цикле? Вы только что стали участником атаки типа «отказ в обслуживании».
Здесь на помощь приходят паттерны устойчивости. Они определяют разницу между сервисом, который работает 95% времени, и сервисом, который работает 99,99% времени.
Понимание архитектуры
Прежде чем мы углубимся в код, позвольте мне описать, что мы создаём:
Поток элегантен: проверьте состояние автоматического выключателя, попробуйте выполнить запрос с логикой повторных попыток, экспоненциально откатитесь при сбое и в конечном итоге либо добьётесь успеха, либо изящно потерпите неудачу. Без исчерпания ресурсов, без проблемы «thundering herd».
Создание основы
Давайте начнём с прочной основы. Мы создадим структуру, которая инкапсулирует наш HTTP-клиент с возможностями устойчивости:
package httpclient
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
type Config struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
Timeout time.Duration
CircuitThreshold int
CircuitTimeout time.Duration
}
type ResilientClient struct {
client *http.Client
config Config
circuitBreaker *CircuitBreaker
}
func NewResilientClient(config Config) *ResilientClient {
if config.MaxRetries == 0 {
config.MaxRetries = 3
}
if config.InitialBackoff == 0 {
config.InitialBackoff = 100 * time.Millisecond
}
if config.MaxBackoff == 0 {
config.MaxBackoff = 30 * time.Second
}
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.CircuitThreshold == 0 {
config.CircuitThreshold = 5
}
if config.CircuitTimeout == 0 {
config.CircuitTimeout = 30 * time.Second
}
httpClient := &http.Client{
Timeout: config.Timeout,
}
return &ResilientClient{
client: httpClient,
config: config,
circuitBreaker: NewCircuitBreaker(config.CircuitThreshold, config.CircuitTimeout),
}
}
Обратите внимание, как мы предоставляем разумные значения по умолчанию. Нет ничего хуже, чем понять на полпути отладки, что вы неправильно настроили таймауты на отрицательное значение или что-то столь же глупое.
Паттерн автоматического выключателя
Автоматический выключатель — это ваша защитная арматура. Думайте об этом как о панели выключателей в вашем доме — когда ток слишком сильный, он срабатывает и предотвращает возгорание. В нашем случае, когда внешний сервис выходит из строя, автоматический выключатель прекращает отправку ему запросов.
package httpclient
import (
"sync"
"time"
)
type CircuitBreakerState int
const (
StateClosed CircuitBreakerState = iota
StateOpen
StateHalfOpen
)
type CircuitBreaker struct {
state CircuitBreakerState
failureCount int
lastFailureTime time.Time
threshold int
timeout time.Duration
mu sync.RWMutex
}
func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
state: StateClosed,
threshold: threshold,
timeout: timeout,
}
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()
// Если цепь открыта, проверьте, следует ли перейти в полуоткрытый режим
if cb.state == StateOpen {
if time.Since(cb.lastFailureTime) > cb.timeout {
cb.state = StateHalfOpen
cb.failureCount = 0
} else {
return ErrCircuitOpen
}
}
// Выполнить функцию
err := fn()
if err != nil {
cb.failureCount++
cb.lastFailureTime = time.Now()
if cb.failureCount >= cb.threshold {
cb.state = StateOpen
}
return err
}
// Успех — сбросить цепь
if cb.state == StateHalfOpen {
cb.state = StateClosed
}
cb.failureCount = 0
return nil
}
func (cb *CircuitBreaker) State() CircuitBreakerState {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
var ErrCircuitOpen = fmt.Errorf("автоматический выключатель открыт")
Здесь становится важна потокобезопасность. Мы используем мьютекс, потому что несколько горутин могут одновременно обращаться к этому автоматическому выключателю. Государ machine имеет три состояния:
- Закрыто: нормальная работа, запросы проходят через
- Открыто: сервис сбоит, запросы отклоняются немедленно
- Полуоткрыто: мы проверяем, восстановился ли сервис Это предотвращает непрерывную бомбардировку вашего сервиса неработающим внешним API. Когда цепь открывается, вы экономите полосу пропускания и даёте другому сервису время на восстановление.
Реализация логики повторных попыток с экспоненциальным откатом
Логика повторных попыток — это не просто «попробовать ещё раз». Бомбардировка сервиса, который восстанавливается, подобна агрессивному встряхиванию застрявшего торгового автомата — это только усугубляет ситуацию. Экспоненциальный откат с джиттером — это цивилизованный подход:
package httpclient
import (
"context"
"io"
"math"
"math/rand"
"net/http"
"time"
)
func (rc *ResilientClient) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
var lastErr error
backoff := rc.config.InitialBackoff
for attempt := 0; attempt <= rc.config.MaxRetries; attempt++ {
// Проверить отмену контекста
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Проверить автоматический выключатель
err := rc.circuitBreaker.Call(func() error {
resp, err := rc.client.Do(req)
if err != nil {
lastErr = err
return err
}
// Обрабатывать 5xx ошибки как подлежащие повторной попытке
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("ошибка сервера: %d", resp.StatusCode)
return lastErr
}
// Успех
return nil
})
if err == nil {
// Успешно получили ответ
resp, _ := rc.client.Do(req)
return resp, nil
