
Когда один сервис ждёт ответа от другого «сколько потребуется», очередь запросов быстро забивает пул соединений, каскадом срываются тайм-ауты, а клиенты видят пиковую задержку и ошибки. Правильные таймауты, управляемые повторы (retry) и Circuit Breaker переводят хаос в предсказуемое поведение: мы или успеваем в оговорённый срок, или быстро и контролируемо деградируем (например, отвечаем кэшем или «позже попробуем»). В итоге SLA/аптайм ровнее, инцидентов меньше, а серверов — не больше, чем нужно.
Таймаут — это дедлайн для операции. Его надо задавать в каждом сетевом вызове и для всей цепочки целиком.
Виды таймаутов:
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"net"
"net/http"
"time"
)
func httpClient() *http.Client {
// Тонкие настройки: отдельные таймауты на этапы соединения
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 300 * time.Millisecond, // connect timeout
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 500 * time.Millisecond,
ResponseHeaderTimeout: 700 * time.Millisecond, // ждать заголовка ответа
ExpectContinueTimeout: 100 * time.Millisecond,
}
return &http.Client{
Transport: transport,
Timeout: 1200 * time.Millisecond, // общий таймаут клиента как последний рубеж
}
}
// deadlineContext создаёт контекст с дедлайном относительно "сейчас".
func deadlineContext(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, d)
}
func main() {
client := httpClient()
ctx, cancel := deadlineContext(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/api", nil)
// Пробрасываем дедлайн дальше по цепочке (в миллисекундах epoch)
deadline, _ := ctx.Deadline()
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(deadline.UnixMilli()))
req.Header.Set("X-Request-Deadline", fmt.Sprintf("%d", binary.BigEndian.Uint64(b)))
resp, err := client.Do(req)
if err != nil {
fmt.Println("ошибка запроса:", err)
return
}
defer resp.Body.Close()
fmt.Println("статус:", resp.Status)
}
Примечание по gRPC: обязательно задавайте context.WithTimeout/WithDeadline на каждый вызов, а на клиенте — параметры keepalive и per-RPC timeout.
Повторы полезны при временных сбоях (всплески задержек, случайные таймауты, редкие 502/503). Но они опасны, если:
Правила:
package retry
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"math"
"net/http"
"time"
)
// Jitter в диапазоне [0.5, 1.5]
func jitter() float64 {
var b [8]byte
_, _ = rand.Read(b[:])
u := binary.LittleEndian.Uint64(b[:])
return 0.5 + float64(u%1000)/1000.0 // 0.5..1.499
}
type ShouldRetry func(resp *http.Response, err error) bool
type Options struct {
MaxRetries int
BaseDelay time.Duration
MaxDelay time.Duration
// Доля общего дедлайна, которую готовы отдать на повторы (0..1)
RetryBudget float64
}
func Do(ctx context.Context, client *http.Client, req *http.Request, should ShouldRetry, opt Options) (*http.Response, error) {
if opt.BaseDelay <= 0 {
opt.BaseDelay = 50 * time.Millisecond
}
if opt.MaxDelay <= 0 {
opt.MaxDelay = 800 * time.Millisecond
}
if opt.MaxRetries < 0 {
opt.MaxRetries = 0
}
if opt.RetryBudget <= 0 || opt.RetryBudget > 1 {
opt.RetryBudget = 0.3
}
var start time.Time
if deadline, ok := ctx.Deadline(); ok {
start = time.Now()
// ограничим бюджет повторов
budget := time.Until(deadline)
budget = time.Duration(float64(budget) * opt.RetryBudget)
ctx2, cancel := context.WithTimeout(ctx, budget)
defer cancel()
ctx = ctx2
}
backoff := opt.BaseDelay
attempt := 0
for {
attempt++
// Клонируем запрос с новым контекстом на каждый повтор
cloned := req.Clone(ctx)
resp, err := client.Do(cloned)
if !should(resp, err) {
return resp, err
}
if attempt > opt.MaxRetries {
if err == nil {
return resp, nil
}
return nil, err
}
// Ждем с экспоненциальным ростом и джиттером
wait := time.Duration(float64(backoff) * jitter())
if wait > opt.MaxDelay {
wait = opt.MaxDelay
}
select {
case <-time.After(wait):
// ok
case <-ctx.Done():
if err == nil {
return nil, ctx.Err()
}
return nil, errors.Join(err, ctx.Err())
}
// Увеличиваем паузу
backoff = time.Duration(math.Min(float64(backoff)*2, float64(opt.MaxDelay)))
}
}
// Пример should-функции
func transient(resp *http.Response, err error) bool {
if err != nil {
// таймауты, сброс соединения — попробуем ещё
return true
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
return true
}
if resp.StatusCode == http.StatusTooManyRequests { // 429
return true
}
return false
}
// node >= 18 (встроенный fetch)
import { setTimeout as delay } from 'node:timers/promises'
function jitter() {
return 0.5 + Math.random() // 0.5..1.5
}
export async function fetchWithRetry(url, options = {}) {
const {
maxRetries = 3,
baseDelayMs = 80,
maxDelayMs = 800,
retryBudgetMs,
shouldRetry = (res, err) => {
if (err) return true
const sc = res.status
return sc >= 500 || sc === 429
},
signal,
} = options
const controller = new AbortController()
const signals = [controller.signal]
if (signal) {
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
}
const start = Date.now()
let attempt = 0
let backoff = baseDelayMs
while (true) {
attempt += 1
try {
const res = await fetch(url, { ...options, signal: controller.signal })
if (!shouldRetry(res, null) || attempt > maxRetries) return res
} catch (err) {
if (!shouldRetry(null, err) || attempt > maxRetries) throw err
}
if (retryBudgetMs && Date.now() - start + backoff > retryBudgetMs) {
// бюджет исчерпан — выходим
if (options.throwOnBudgetExceeded) throw new Error('retry budget exceeded')
return fetch(url, { ...options, signal: controller.signal })
}
const wait = Math.min(Math.floor(backoff * jitter()), maxDelayMs)
await delay(wait, undefined, { signal: controller.signal })
backoff = Math.min(backoff * 2, maxDelayMs)
}
}
Circuit Breaker (далее — «брейкер») следит за ошибками и задержками вызовов к зависимости. Если доля неуспехов превысила порог, брейкер «открывается» и сразу отклоняет новые запросы без похода к проблемной зависимости. Через короткое время он переходит в «полуоткрытое» состояние, пробует немного трафика и либо закрывается (всё ок), либо снова открывается.
Это спасает от каскадных сбоев и экономит ресурсы: вместо тысяч медленных попыток — быстрый отказ и, по возможности, локальный фолбэк (например, устаревшие данные из кэша или запись задачи на последующую обработку).
# установка зависимости
go get github.com/sony/gobreaker
package breaker
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/sony/gobreaker"
)
type Client struct {
bc *gobreaker.CircuitBreaker
httpCl *http.Client
}
func New() *Client {
st := gobreaker.Settings{
Name: "provider-api",
MaxRequests: 5, // в полуоткрытом состоянии
Interval: 30 * time.Second, // окно для сброса счётчиков
Timeout: 5 * time.Second, // время открытого состояния
ReadyToTrip: func(counts gobreaker.Counts) bool {
// открываемся, если больше 50% неудач и >20 запросов в окне
total := counts.Requests
fail := counts.TotalFailures()
return total >= 20 && float64(fail)/float64(total) > 0.5
},
}
bc := gobreaker.NewCircuitBreaker(st)
return &Client{bc: bc, httpCl: &http.Client{Timeout: 1500 * time.Millisecond}}
}
func (c *Client) Get(ctx context.Context, url string) (*http.Response, error) {
res, err := c.bc.Execute(func() (interface{}, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := c.httpCl.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
}
return resp, nil
})
if err != nil {
// быстрый фолбэк при открытом брейкере
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
return nil, fmt.Errorf("fast-fail: %w", err)
}
return nil, err
}
return res.(*http.Response), nil
}
Важно: не вкладывайте внутрь брейкера собственные повторы зависимости, иначе умножите нагрузку. Повторы управляем в одном месте, ближе к клиенту.
Чтобы каждый следующий сервис знал, сколько времени осталось, передавайте дедлайн в заголовке, например X-Request-Deadline как метку времени в миллисекундах. На входе проверяйте, сколько осталось, и урезайте свои таймауты, чтобы успеть вернуть ответ, а не оборваться посередине.
package deadline
import (
"context"
"net/http"
"strconv"
"time"
)
const HeaderDeadline = "X-Request-Deadline"
func WithDeadline(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.Header.Get(HeaderDeadline); v != "" {
if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
dl := time.UnixMilli(ms)
if time.Until(dl) <= 0 {
http.Error(w, "deadline exceeded", http.StatusGatewayTimeout)
return
}
ctx, cancel := context.WithDeadline(r.Context(), dl)
defer cancel()
r = r.WithContext(ctx)
}
}
next.ServeHTTP(w, r)
})
}
Главные метрики:
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
reqLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_client_request_duration_seconds",
Help: "Время ответа внешних запросов",
Buckets: prometheus.DefBuckets, // 0.005..10с
},
[]string{"dep", "code"},
)
retriesTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_client_retries_total", Help: "Число повторов"},
[]string{"dep", "result"}, // result: success|giveup
)
breakerState = prometheus.NewGaugeVec(
prometheus.GaugeOpts{Name: "circuit_breaker_state", Help: "Состояние брейкера: 0 closed, 1 open, 0.5 half-open"},
[]string{"dep"},
)
)
func init() {
prometheus.MustRegister(reqLatency, retriesTotal, breakerState)
}
func Handler() http.Handler { return promhttp.Handler() }
groups:
- name: sla-reliability
rules:
- alert: HighErrorRateUpstream
expr: sum by(dep) (rate(http_client_requests_errors_total[5m])) / sum by(dep) (rate(http_client_requests_total[5m])) > 0.1
for: 10m
labels:
severity: page
annotations:
summary: "Высокая доля ошибок к зависимости {{ $labels.dep }}"
- alert: BreakerOpenTooLong
expr: circuit_breaker_state{dep!=""} == 1
for: 5m
labels:
severity: page
annotations:
summary: "Брейкер открыт более 5 минут: {{ $labels.dep }}"
- alert: LatencyP99Spiked
expr: histogram_quantile(0.99, sum by (le, dep) (rate(http_client_request_duration_seconds_bucket[5m]))) > 1.5
for: 10m
labels:
severity: warn
annotations:
summary: "Всплеск p99 к {{ $labels.dep }}"
Итог: набор простых инженерных практик превращает случайные провалы в предсказуемое поведение. Это напрямую влияет на деньги — чем стабильнее и быстрее вы отвечаете, тем меньше отток, больше конверсий и ниже счёт за железо.