Опирается на правила:
R-RES-TO-1…R-RES-TO-3иR-RES-TO-X1…R-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 | Что измеряет | Типовое значение |
|---|---|---|---|
| connect | net.Dialer{Timeout: …} в Transport.DialContext | TCP-handshake + TLS | 2s локально, 5s через интернет |
| read | Transport.ResponseHeaderTimeout | Ожидание первого байта заголовков ответа | 10s быстрые, 30s тяжёлые, 60s max для sync |
| call | http.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}/reports → 202 Accepted + task_id, scheduler poll'ит результат через *_task-таблицу. Подробнее — в статье Async и polling.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
&http.Client{} без Timeout и Transport (дефолт ∞) | R-RES-TO-X1 | Явные DialContext, ResponseHeaderTimeout, Timeout |
http.Client.Timeout < Transport.ResponseHeaderTimeout | R-RES-TO-X2 | CallTimeout ≥ ConnectTimeout + ReadTimeout + 1s |
ReadTimeout > 60s для sync-вызова из HTTP-handler'а | R-RES-TO-X3 | Task-queue / async-паттерн |
| Таймауты magic-числами прямо в коде | R-RES-TO-2 | envconfig-теги с default: per-system |
Один общий *http.Client на Sber + OdnaKassa | R-RES-ISO-X1 | Отдельный *http.Client + *http.Transport на каждую систему |
| Игнорирование дедлайна входящего контекста | R-RES-TO-3 | capTimeout(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.