Создание HTTP-клиентов может показаться простым делом, пока в три часа ночи ваш сервис не начнёт перегружать внешний API, который не отвечает, исчерпает лимиты запросов и приведёт к полному сбою. Мы все бывали в таких ситуациях. Или, может быть, вы ещё не сталкивались с этим — примите это как дружеское предупреждение от того, кто уже прошёл через это.

Разница между обычным HTTP-клиентом и клиентом, готовым к использованию в продакшене, часто сводится к двум обманчиво простым концепциям: повторным попыткам и автоматическим выключателям (circuit breakers). Они не выглядят впечатляющими, но спасут вас, когда всё пойдёт не так.

Почему ваш простой HTTP-клиент потерпит неудачу

Давайте будем честными. Написать HTTP-клиент на Go — это до смешного просто. Стандартная библиотека практически всё делает за вас:

resp, err := http.Get("https://api.example.com/data")

Красиво. Элегантно. Совершенно недостаточно для реального мира.

В тот момент, когда внешний сервис даёт сбой — тайм-аут сети, временный перегруз, исчерпание пула соединений с базой данных — ваш код терпит неудачу. Без повторных попыток, без льготного периода, просто немедленный сбой. И если вы повторно обращаетесь к этому сервису в цикле? Вы только что стали участником атаки типа «отказ в обслуживании».

Здесь на помощь приходят паттерны устойчивости. Они определяют разницу между сервисом, который работает 95% времени, и сервисом, который работает 99,99% времени.

Понимание архитектуры

Прежде чем мы углубимся в код, позвольте мне описать, что мы создаём:

graph TD Client["Запрос клиента"] CB{"Автоматический выключатель открыт?"} Retry{"Счётчик повторных попыток"} Request["Отправить HTTP-запрос"] Success{"Успех?"} Backoff["Экспоненциальный откат"] FailedReturn["Вернуть ошибку"] SuccessReturn["Вернуть ответ"] Client --> CB CB -->|Открыто| FailedReturn CB -->|Закрыто| Retry Retry -->|Превышено число повторных попыток| FailedReturn Retry -->|Оставшиеся попытки| Request Request --> Success Success -->|Нет| Backoff Backoff --> Retry Success -->|Да| SuccessReturn

Поток элегантен: проверьте состояние автоматического выключателя, попробуйте выполнить запрос с логикой повторных попыток, экспоненциально откатитесь при сбое и в конечном итоге либо добьётесь успеха, либо изящно потерпите неудачу. Без исчерпания ресурсов, без проблемы «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