Опирается на правила: R-RES-TO-1R-RES-TO-3 и R-RES-TO-X1R-RES-TO-X3 из Resilience Style Guide → раздел 3. Timeouts.

Важно знать

  • Иерархия строгая: connectTimeout < readTimeout < callTimeout. Ни одна ∞ не допустима.
  • connectTimeout — TCP-handshake. net.Dialer.Timeout через Transport.DialContext. Локальный DC: 2s, через интернет: 5s.
  • readTimeout — ожидание заголовков ответа (Transport.ResponseHeaderTimeout). 10s быстрые API, 30s тяжёлые, 60s максимум для sync.
  • callTimeout — полный cap на весь запрос (http.Client.Timeout). Всегда ≥ connectTimeout + readTimeout + 1s buffer.
  • Timeouts — per-system через типизированный конфиг с envconfig-тегами (SBER_CONNECT_TIMEOUT и т.д.).
  • При входящем контексте с дедлайном — capTimeout сжимает таймаут до min(callTimeout, remainingBudget - 100ms).
  • &http.Client{} без Timeout и без DialContext — дефолт ∞. Зависание = горутина навсегда.

Без явных timeouts любой outbound HTTP может зависнуть, если внешняя система медленно умирает — TCP-соединение установилось, но байты не приходят. Классический сценарий: весь пул горутин висит в read, новые запросы не обрабатываются. В Go это решается тремя уровнями на *http.Transport + http.Client.Timeout. Раскрытие раздела 3 контракта R-RES-TO-*.

Иерархия трёх уровней

R-RES-TO-1: в Go net/http три поля отвечают за разные фазы:

УровеньGo APIЧто измеряетТиповое значение
connectnet.Dialer{Timeout: …} в Transport.DialContextTCP-handshake + TLS2s локально, 5s через интернет
readTransport.ResponseHeaderTimeoutОжидание первого байта заголовков ответа10s быстрые, 30s тяжёлые, 60s max для sync
callhttp.Client.TimeoutПолный cap: от отправки запроса до конца чтения телаconnectTimeout + readTimeout + 1s

http.Client.Timeout — это cap на весь вызов целиком. Без него теоретически возможен зависший вызов на connectTimeout + N × readTimeout: если сервер успевает слать по одному байту раз в readTimeout - 1 секунд, ResponseHeaderTimeout никогда не сработает.

Per-system конфиг через envconfig

R-RES-TO-2: timeouts — не magic-числа в коде, а конфигурация. В Go-стеке — через envconfig-теги (kelseyhightower/envconfig) или viper. Каждая внешняя система получает собственный конфиг-struct с префиксом.

// adapters/out/sber/config.go
type SberClientConfig struct {
    ConnectTimeout time.Duration `envconfig:"SBER_CONNECT_TIMEOUT" default:"5s"`
    ReadTimeout    time.Duration `envconfig:"SBER_READ_TIMEOUT"    default:"30s"`
    CallTimeout    time.Duration `envconfig:"SBER_CALL_TIMEOUT"    default:"36s"`
    MaxConcurrent  int           `envconfig:"SBER_MAX_CONCURRENT"  default:"20"`
    BaseURL        string        `envconfig:"SBER_BASE_URL"        required:"true"`
}
// adapters/out/sber/client.go
func newSberHTTPClient(cfg SberClientConfig) *http.Client {
    return &http.Client{
        Timeout: cfg.CallTimeout,
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: cfg.ConnectTimeout,
            }).DialContext,
            ResponseHeaderTimeout: cfg.ReadTimeout,
            MaxIdleConnsPerHost:   cfg.MaxConcurrent + 2,
            IdleConnTimeout:       90 * time.Second,
        },
    }
}

Почему default-теги вместо YAML-файла: Go-сервисы часто конфигурируются через environment variables (K8s ConfigMap/Secret). envconfig-теги дают валидацию на старте, обязательные поля помечаются required:"true", отклонения от типовых значений видны в самой структуре.

Отдельный пример — OdnaKassa с нестандартным readTimeout:

// adapters/out/odnakassa/config.go
type OdnaKassaClientConfig struct {
    ConnectTimeout time.Duration `envconfig:"ODNAKASSA_CONNECT_TIMEOUT" default:"5s"`
    ReadTimeout    time.Duration `envconfig:"ODNAKASSA_READ_TIMEOUT"    default:"60s"` // большие multiset-ответы
    CallTimeout    time.Duration `envconfig:"ODNAKASSA_CALL_TIMEOUT"    default:"66s"`
    MaxConcurrent  int           `envconfig:"ODNAKASSA_MAX_CONCURRENT"  default:"30"`
    BaseURL        string        `envconfig:"ODNAKASSA_BASE_URL"        required:"true"`
}

Отклонение от типовых значений должно быть прокомментировано рядом — чтобы следующий, кто откроет конфиг, понял, почему ReadTimeout вдвое длиннее обычного.

Уважение TimeBudget при входящем контексте

R-RES-TO-3: если входящий запрос несёт дедлайн в context.Context (например, от upstream через traceparent + X-Time-Budget), исходящий таймаут должен сжаться до оставшегося бюджета.

// adapters/out/internal/timeout.go
func capTimeout(ctx context.Context, callTimeout time.Duration) (context.Context, context.CancelFunc) {
    deadline, ok := ctx.Deadline()
    if !ok {
        return context.WithTimeout(ctx, callTimeout)
    }
    remaining := time.Until(deadline) - 100*time.Millisecond
    if remaining < callTimeout {
        callTimeout = remaining
    }
    return context.WithTimeout(ctx, callTimeout)
}

Использование в адаптере:

// adapters/out/sber/sber_adapter.go
func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
    defer cancel()
    return a.doRegister(callCtx, order)
}

Зачем: если upstream уже ожидает ответ максимум 2 секунды, а наш outbound может ждать 30 секунд — это deadline-violation. Upstream давно закрыл соединение, а сервис ещё держит ресурсы. Сжатие timeout под бюджет даёт fail-fast: лучше вернуть 504 сейчас, чем зря держать горутины.

При remaining < 0 (бюджет уже исчерпан) — context.WithTimeout с отрицательной длительностью немедленно отменён, doRegister вернёт context.DeadlineExceeded.

Антипаттерн: http.Client без Timeout и Transport

R-RES-TO-X1: дефолтный &http.Client{} имеет Timeout: 0, что означает ∞. http.DefaultTransport не имеет ResponseHeaderTimeout (он равен 0 = ∞), а DialContext использует глобальные дефолты.

// ПЛОХО — дефолт ∞ на всех уровнях
var client = &http.Client{}

func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/register", body)
    resp, err := client.Do(req)  // если Sber завис — горутина навсегда
    // ...
}

Какое-то время это «работает» — пока Sber отвечает быстро. Первый же медленный ответ блокирует горутину. При нагрузке 50 RPS и среднем зависании 30 секунд — через минуту сотни горутин заняты ожиданием. Сервис перестаёт отвечать.

Антипаттерн: нарушение иерархии

R-RES-TO-X2: http.Client.Timeout < Transport.ResponseHeaderTimeout — внутреннее противоречие.

// ПЛОХО — CallTimeout < ReadTimeout
cfg := SberClientConfig{
    ConnectTimeout: 5 * time.Second,
    ReadTimeout:    30 * time.Second,
    CallTimeout:    20 * time.Second, // меньше ReadTimeout!
}

http.Client.Timeout: 20s сработает раньше, чем ResponseHeaderTimeout: 30s. ResponseHeaderTimeout в таком конфиге никогда не сработает. Поведение непредсказуемо: метрики путаются (ошибка придёт как context.DeadlineExceeded от Client.Timeout, а не от Transport), error-handling разных уровней не согласован.

Корректная формула: CallTimeout ≥ ConnectTimeout + ReadTimeout + 1s.

Антипаттерн: readTimeout > 60s для sync-вызова из HTTP-handler'а

R-RES-TO-X3: если для вызова внешней системы нужен readTimeout больше 60 секунд — это признак того, что вызов должен быть асинхронным.

// ПЛОХО — sync-вызов с ReadTimeout 5 минут
type ReportClientConfig struct {
    ReadTimeout time.Duration `envconfig:"REPORT_READ_TIMEOUT" default:"300s"` // 5 минут
    CallTimeout time.Duration `envconfig:"REPORT_CALL_TIMEOUT" default:"310s"`
}

func (h *OrderHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
    report, err := h.reportAdapter.Generate(r.Context(), orderID) // блокирует goroutine 5 минут
    // ...
}

Что не так:

  • Горутина chi-handler'а заблокирована на 5 минут. При конкурентных запросах сервис быстро исчерпывает пул.
  • Upstream-клиент (браузер, API-gateway) скорее всего имеет свой timeout <60s — ответ придёт уже некому.
  • Внешней системе, которой нужно 5 минут, нужен async-паттерн.

Корректно — перевести в task-queue: POST /orders/{id}/reports202 Accepted + task_id, scheduler poll'ит результат через *_task-таблицу. Подробнее — в статье Async и polling.

Что запрещено

АнтипаттернПравилоЧто взамен
&http.Client{} без Timeout и Transport (дефолт ∞)R-RES-TO-X1Явные DialContext, ResponseHeaderTimeout, Timeout
http.Client.Timeout < Transport.ResponseHeaderTimeoutR-RES-TO-X2CallTimeout ≥ ConnectTimeout + ReadTimeout + 1s
ReadTimeout > 60s для sync-вызова из HTTP-handler'аR-RES-TO-X3Task-queue / async-паттерн
Таймауты magic-числами прямо в кодеR-RES-TO-2envconfig-теги с default: per-system
Один общий *http.Client на Sber + OdnaKassaR-RES-ISO-X1Отдельный *http.Client + *http.Transport на каждую систему
Игнорирование дедлайна входящего контекстаR-RES-TO-3capTimeout(ctx, cfg.CallTimeout) сжимает до remaining

Куда дальше

  • Per-system isolation — отдельный *http.Client на каждую внешнюю систему.
  • Circuit Breaker — gobreaker засчитывает timeout-вызовы в failure rate; slow-call threshold через context-timeout.
  • Bulkhead — semaphore.NewWeighted ограничивает одновременные вызовы до исчерпания пула.
  • Конфигурация — декларативный envconfig для всех resilience-параметров.
  • Async и polling — когда readTimeout > 60s: task-queue вместо sync-вызова.
  • Health checks — probe-timeout отдельный, с TTL-кешем.
  • Observability — метрики и OTel-span на adapter-методах.
  • Fallback — деградация при timeout-ошибках через errors.As.