Таймауты, ретраи и hedged requests

Перед началом: модули 1–8 (контекст, select, лимиты, rate limiting). Инструменты: Go 1.22+, context, net/http, math/rand/v2, опционально x/sync/singleflight. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: медленный хвост, ретраи и задвоенный платёж

Внешний сервис отвечает за 40 мс в медиане, но p99 — 800 мс и выше. Сервис вызывал его без общего дедлайна: медленные ответы держали горутины и соединения, а новые запросы продолжали приходить. Когда клиент обрывал соединение, обработчик не всегда это замечал и продолжал ждать. Через десять минут горутин стало втрое больше нормы.

На этом фоне включили «надёжные» ретраи: три попытки на каждый вызов. Но операция была неидемпотентной — платёж без ключа, — и после потерянного ответа первая попытка успела списать деньги, вторая списала ещё раз. Медленный хвост и ретраи без границ сделали ровно то, чего не должно было случиться.

📖 Теория

9.1 Таймаут-контекст и бюджет времени на запрос

Дедлайн — это не «таймаут на один вызов», а общий бюджет запроса. Верхний слой задаёт его один раз, нижние слои вычитают из него своё время:

go
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

cancel обязателен даже при успехе: иначе таймер и дочерний контекст живут до срабатывания. Дальше ctx передаётся в HTTP-клиент, БД и все дочерние вызовы; дочерние дедлайны не могут быть больше родительского.

Практическая схема: общий бюджет (например, 2 с) и таймаут попытки (например, 500 мс). Попытка получает min(500мс, остаток бюджета). Если осталось 100 мс, новая попытка почти бессмысленна: лучше вернуть ошибку сразу, чем начать вызов, который не успеет.

Не путайте уровни: http.Client.Timeout покрывает весь запрос включая чтение тела, Transport-таймауты — отдельные фазы (DialContext, TLS, ResponseHeaderTimeout), а контекст отменяет всё вместе. Без контекста отмена клиента не дойдёт до транспорта.

9.2 Retry: экспонента, джиттер, только идемпотентное

Ретрай — это повторная попытка получить результат, а не «сделать операцию ещё раз». Повторять безопасно, если:

  • операция идемпотентна (GET, PUT, DELETE по спецификации);
  • либо у запроса есть ключ идемпотентности, и сервер умеет отбрасывать дубликаты;
  • ошибка временная: таймаут, обрыв соединения, 5xx, 429 (с уважением к Retry-After). 400, 401, 403, 404 и бизнес-ошибки не лечатся повтором.

Пауза между попытками растёт экспоненциально, но без джиттера все клиенты синхронно бьют по сервису после сбоя — получается «стадо». Поэтому к паузе добавляют случайность:

go
delay := base
for attempt := 1; attempt <= max; attempt++ {
    err := do(ctx)
    if err == nil || !retryable(err) {
        return err
    }
    jitter := time.Duration(rand.Int64N(int64(delay/2) + 1))
    pause := delay/2 + jitter
    select {
    case <-time.After(pause):
    case <-ctx.Done():
        return ctx.Err()
    }
    delay = min(delay*2, maxDelay)
}

time.After в цикле создаёт таймер; для горячего кода лучше time.NewTimer с остановкой. Главное — select с ctx.Done(): сон не должен пережить дедлайн.

9.3 Бюджет ретраев, а не «три попытки»

«Три попытки» — не стратегия, потому что не учитывает ни время, ни состояние зависимости, ни общий объём ретраев. Если каждая попытка идёт 500 мс, три попытки — это 1,5 с без учёта пауз; при p99 в 800 мс бюджет 2 с закончится после второй.

Правильнее считать бюджет:

  • время: ретраи прекращаются, когда исчерпан дедлайн запроса;
  • доля: на ретраи уходит не больше X % общего трафика (retry budget); при деградации зависимости ретраи не удваивают нагрузку;
  • состояние: circuit breaker (модуль 12) вообще запрещает вызовы, пока зависимость не ожила.

Без бюджета ретраи — усилитель отказа: 100 rps × 3 попытки = 300 rps в лежащий сервис, и ему ещё труднее подняться. Ретраи — часть общей системы защиты, а не локальная настройка клиента.

9.4 Hedged requests: вторая копия по таймеру

Идея: если основная копия запроса не ответила за, скажем, p95 (100 мс), отправляем вторую копию в другой инстанс и берём первый успешный ответ, а проигравшую копию отменяем. Хвост сокращается: медленный инстанс больше не определяет p99.

Цена:

  • трафик: каждая «замедлившаяся» часть запроса оплачивается дважды; при p95-триггере это единицы процентов, но при неверном пороге — десятки;
  • нагрузка: hedge нельзя включать, когда зависимость уже перегружена, — вторая копия добьёт её;
  • идемпотентность: обе копии должны быть безопасны, иначе получим два платежа.

Реализация: две горутины пишут в буферизованный канал (минимум на две копии), select ждёт первый результат, cancel() отменяет вторую. Порог запуска и долю hedge-запросов ограничивают явно.

9.5 Fail fast vs ждать

Ждать имеет смысл, только если дедлайн ещё позволяет и есть шанс на успех. Если зависимость уже вернула серию отказов, контекст истёк или очередь на входе переполнена, ожидание — это перевод ресурсов в заведомо проигрышную ставку.

  • Fail fast для интерактивных запросов: 503 с Retry-After быстрее, чем 30-секундное ожидание.
  • Ждать для фоновых задач, у которых нет пользователя и есть бюджет времени; но и там ожидание должно быть ограничено.
  • Не ждать после ctx.Done(): работа уже никому не нужна.

Ключевое правило: ошибка должна нести причину (timeout, unavailable, rate limited), чтобы вызывающий код мог принять решение, а не повторять всё подряд.

9.6 Компромиссы

  • Таймаут короткий vs длинный: короткий освобождает горутины, но режет легитимно медленные операции; длинный повышает успешность, но копит ожидающих и маскирует деградацию.
  • Ретраи vs задержка: каждая попытка увеличивает p99 и нагрузку; для интерактивного запроса часто лучше один быстрый отказ, чем три попытки.
  • Джиттер полный vs частичный: полный лучше разводит клиентов, но даёт менее предсказуемые паузы; частичный проще для диагностики.
  • Hedge vs бюджет: hedge снижает хвост, но платит трафиком и рискует усилить перегрузку; без ограничения доли он опасен.
  • Fail fast vs успешность: быстрый отказ защищает систему, но снижает число успешных ответов; выбор зависит от SLA и типа операции.

💻 Практика

  1. Проверьте бюджет. Внешний вызов спит случайно 100–900 мс. Сначала общий дедлайн 2 с без таймаута попытки — покажите, как одна медленная попытка съедает бюджет. Затем добавьте попытку по 500 мс и считайте, сколько успешных ответов дошло.
  2. Задвойте платёж и исправьте. POST без ключа идемпотентности с потерей ответа после успешной обработки. Воспроизведите два списания, затем добавьте ключ и проверьте, что сервер отдаёт тот же результат на повтор.
  3. Ограничьте hedge. Основной вызов: p50 40 мс, p99 800 мс. Запускайте копию через 100 мс, но не более чем для 5 % запросов. Сравните p99 и дополнительный QPS с базовой линией и без cap.
  4. Запустите сниппет модуля: ретрай с паузами 1 и 2 тика успешен на третьей попытке (9 тиков), затем hedge: основная копия финиширует на 5, копия — на 4, поэтому берётся копия; отдельная строка напоминает про цену трафика в хвосте.

🧩 Quiz

1. Почему «три попытки» без бюджета — плохая стратегия?
2. Какие ошибки имеет смысл повторять ретраем?
3. Чем платят hedged requests?
4. Когда ожидание ответа становится вредным?
5. Что именно отменяет context.WithTimeout по истечении времени?

✅ Итоги модуля

Студент должен:

  • Задавать общий бюджет запроса и таймаут отдельной попытки
  • Передавать ctx во все дочерние вызовы и отменять таймеры
  • Повторять только идемпотентные операции или использовать ключ
  • Добавлять экспоненциальную паузу с джиттером и капом
  • Считать бюджет ретраев по времени и доле трафика
  • Включать hedged requests с порогом и ограничением доли
  • Выбирать fail fast или ожидание осознанно
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 10

Таймауты, ретраи и hedge защищают исходящие вызовы. Но тот же набор проблем ждёт на входящей стороне: медленный клиент может держать горутину и соединение, а сервер без таймаутов и лимитов in-flight копит их до OOM. Дальше — HTTP-сервер под конкурентной нагрузкой: таймауты чтения и записи, лимит тела, семафор на хендлеры и стриминг ответов.