Таймауты, ретраи и 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 Таймаут-контекст и бюджет времени на запрос
Дедлайн — это не «таймаут на один вызов», а общий бюджет запроса. Верхний слой задаёт его один раз, нижние слои вычитают из него своё время:
goctx, 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и бизнес-ошибки не лечатся повтором.
Пауза между попытками растёт экспоненциально, но без джиттера все клиенты синхронно бьют по сервису после сбоя — получается «стадо». Поэтому к паузе добавляют случайность:
godelay := 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 и типа операции.
💻 Практика
- Проверьте бюджет. Внешний вызов спит случайно 100–900 мс. Сначала общий дедлайн 2 с без таймаута попытки — покажите, как одна медленная попытка съедает бюджет. Затем добавьте попытку по 500 мс и считайте, сколько успешных ответов дошло.
- Задвойте платёж и исправьте.
POSTбез ключа идемпотентности с потерей ответа после успешной обработки. Воспроизведите два списания, затем добавьте ключ и проверьте, что сервер отдаёт тот же результат на повтор. - Ограничьте hedge. Основной вызов: p50 40 мс, p99 800 мс. Запускайте копию через 100 мс, но не более чем для 5 % запросов. Сравните p99 и дополнительный QPS с базовой линией и без cap.
- Запустите сниппет модуля: ретрай с паузами 1 и 2 тика
успешен на третьей попытке (
9тиков), затем hedge: основная копия финиширует на5, копия — на4, поэтому берётся копия; отдельная строка напоминает про цену трафика в хвосте.
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Задавать общий бюджет запроса и таймаут отдельной попытки
- Передавать
ctxво все дочерние вызовы и отменять таймеры - Повторять только идемпотентные операции или использовать ключ
- Добавлять экспоненциальную паузу с джиттером и капом
- Считать бюджет ретраев по времени и доле трафика
- Включать hedged requests с порогом и ограничением доли
- Выбирать fail fast или ожидание осознанно
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 10
Таймауты, ретраи и hedge защищают исходящие вызовы. Но тот же набор проблем ждёт на входящей стороне: медленный клиент может держать горутину и соединение, а сервер без таймаутов и лимитов in-flight копит их до OOM. Дальше — HTTP-сервер под конкурентной нагрузкой: таймауты чтения и записи, лимит тела, семафор на хендлеры и стриминг ответов.