Содержание урока · 11 разделов
- middleware.Timeout: дедлайн для каждого хендлера
- Хендлер не прерывают силой: r.Context().Done()
- Почему долгая горутина дорабатывает до конца
- Throttle и ThrottleBacklog: мгновенный отказ или очередь
- Куда ставить предохранители в цепочке
- Таймауты go: внешний барьер http.Server
- Механизмы защиты: что делает и что отвечает
- Минимальный набор предохранителей сервиса
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Хендлеры из прошлых уроков отвечали мгновенно: карта заметок в памяти, никакой сети, никакой базы. В реальном сервисе самое дорогое место — запрос к БД, и когда она залипает, каждый входящий запрос прирастает одной висящей горутиной. Пятьдесят запросов — пятьдесят горутин, триста — триста, и память процесса растёт быстрее, чем дежурный успевает открыть дашборд. От этой болезни два лекарства, и оба — middleware из пакета chi/middleware. middleware.Timeout ставит дедлайн на каждый хендлер и по истечении возвращает клиенту 504. middleware.Throttle считает одновременные запросы и всё сверх лимита встречает 503 сразу, без ожидания. В этом уроке — механика дедлайна: почему хендлер не прерывают силой и что значит следить за r.Context().Done(); жёсткий и мягкий лимит параллелизма; место предохранителей в цепочке и их связь с таймаутами http.Server.
middleware.Timeout: дедлайн для каждого хендлера#
middleware.Timeout(duration) оборачивает всю дальнейшую цепочку таймером: middleware запускает её в отдельной горутине и сам ждёт — либо хендлер закончится, либо истечёт срок. Во втором случае клиенту уходит 504 Gateway Timeout, а контекст запроса гаснет через context.WithTimeout на r.Context(). Код ответа выбран не случайно: 504 по спецификации HTTP означает, что время ожидания истекло — ровно то, что произошло; 503 в chi уже занят Throttle-ом, и смешивать эти два отказа не нужно: мониторинг по ним различает перегрузку и дедлайн. Уровень важен не меньше кода: Timeout ставится одним r.Use и выдаёт дедлайн каждому хендлеру ниже по цепочке, а не выборочно. В связке chi timeout throttle это первое кольцо защиты — второе про количество, о нём ниже. Словарь тот же, что в уроке про context.Context: Done(), Err(), Deadline() — обычные таймеры контекста, только включённые на весь роутер.
Timeout в цепочке
GOpackage main
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(2 * time.Second)) // дедлайн на каждый хендлер
r.Get("/report", reportHandler)
http.ListenAndServe(":8080", r)
}Одна строка r.Use(middleware.Timeout(2 * time.Second)) выдаёт каждому хендлеру ниже двухсекундный дедлайн. По истечении chi отдаёт 504 Gateway Timeout и отменяет контекст запроса — при этом сам хендлер, как увидим дальше, не прерывается силой. Ставить Timeout имеет смысл после Recoverer: слой оборачивает всё ниже по цепочке, и никакой маршрут не останется без дедлайна. Проверить код можно только локальным Go: песочница сайта TCP-сервер не поднимает.
Хендлер не прерывают силой: r.Context().Done()#
Главное, что нужно понять про chi timeout: дедлайн не убивает хендлер. В Go нет механизма выстрелить горутину насквозь — runtime не отнимает управление. Когда дедлайн истёк, middleware просто пишет клиенту 504, а ваш хендлер продолжает жить в своей горутине и делает свою работу, пусть её результат уже никому не достанется. Middleware лишь включает сигнал: канал r.Context().Done() закрывается, Err() начинает возвращать context.DeadlineExceeded. Хендлер обязан сам следить за этим сигналом — через select вокруг долгих операций — и, что важнее, прокидывать контекст вниз: в запросы к БД и внешним API. Тогда дедлайн доезжает до самого медленного места, запрос отменяется там, и горутина освобождается по-настоящему. Хендлер без этой дисциплины формально «поддерживает» таймауты, а фактически работает до конца — правильная форма выглядит так.
Хендлер, который слушает ctx.Done()
GOtype reportResult struct {
Rows []string
}
func reportHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
done := make(chan reportResult, 1)
go func() {
res := buildReport() // медленная работа: десятки секунд
done <- res
}()
select {
case <-ctx.Done():
// дедлайн middleware.Timeout истёк или клиент закрыл соединение
log.Println("запрос брошен:", ctx.Err())
return
case res := <-done:
json.NewEncoder(w).Encode(res)
}
}Классическая форма хендлера, дружащего с дедлайном: работа уходит в горутину, а хендлер стоит в select между ctx.Done() и каналом с результатом. Как только Timeout отменяет контекст, Done() закрывается и хендлер возвращается — клиенту между тем уже улетел 504. Канал сделан буферизованным на 1: если хендлер ушёл в ветку отмены, горутина с результатом не зависнет на отправке, а сложит значение в буфер и спокойно завершится.
Проверка: код ответа по дедлайну
Дедлайн middleware.Timeout истёк. Какой код ответа получит клиент?
Почему долгая горутина дорабатывает до конца#
Пусть хендлер отработал по-честному: увидел закрытый Done() и вернулся. Но внутри него была запущена горутина с buildReport, а она контекста не получает и про 504 не знает. Результат её труда уже никому не нужен, но она доработает до конца — потратит процессор, память и соединение с базой. Это не баг, а цена модели Go: горутину нельзя убить со стороны, с ней можно только договориться через канал или контекст. Отсюда правило: каждую запущенную горутину снабжайте контекстом — buildReport(ctx) вместо buildReport() — и внутри неё тем же select следите за Done(). Второй нюанс: если к моменту пробуждения select готово несколько случаев — и Done() закрыт, и результат уже лежит в канале, — Go выбирает среди готовых случайно, поэтому в ветке с результатом первым делом проверяйте ctx.Err(). Больше про устройство горутин — в уроке про горутины.
Типичный производственный инцидент: ночью БД залипает на реконструкции индекса, таймауты в коде не настроены, и каждый запрос к API прирастает горутиной, которая стоит в ожидании соединения. Пятьдесят запросов — пятьдесят горутин, каждая тянет буферы и выделенную память. Через час дежурный видит ползущий вверх график RSS, потом OOM-киллера в dmesg, потом пустой монитор вместо сайта. Перезапуск возвращает сервис — но при залипшей базе сценарий повторится, и так до утра. Предохранители из этого урока рвут цепочку раньше: Timeout задаёт каждому запросу срок, Throttle — предел по количеству, и до OOM дело не доходит.
Throttle и ThrottleBacklog: мгновенный отказ или очередь#
Второй предохранитель смотрит не на время, а на количество. middleware.Throttle(n) держит счётчик одновременных запросов: пока работают не больше n — всё проходит, как только n-е место занято, следующий запрос получает 503 Service Unavailable мгновенно, не вставая в очередь. Это предохранитель от толпы: толпа всё равно не обслужится, и честнее отказать сразу, чем копить висящие соединения. Мягкая версия — middleware.ThrottleBacklog(limit, backlog, timeout): до limit запросов работают, до backlog ждут в очереди не дольше timeout, переполнение очереди или просрочка ожидания дают тот же 503. Разница для клиента ощутимая: короткий всплеск ThrottleBacklog переживёт ожиданием, и лишь настоящий затор кончается отказом. Оба middleware — из того же пакета chi/middleware, что Logger и Recoverer из урока про встроенные слои, и оба возвращают именно 503, а не 504.
Throttle и ThrottleBacklog в коде
GOr := chi.NewRouter()
// жёсткий лимит: работают до 10 запросов одновременно,
// 11-й и далее сразу получают 503 Service Unavailable
r.Use(middleware.Throttle(10))
// мягкая альтернатива: 20 работают, до 50 ждут в очереди
// не дольше 5 секунд; переполнение очереди и просрочка
// ожидания тоже дают 503
// r.Use(middleware.ThrottleBacklog(20, 50, 5*time.Second))
r.Get("/report", reportHandler)Два предохранителя одного семейства. Throttle(10) — мгновенный отказ: запрос либо сразу идёт в работу, либо сразу получает 503, никакой очереди. ThrottleBacklog(limit, backlog, timeout) добавляет буфер ожидания: краткий всплеск из пяти лишних запросов успеет дождаться освободившихся слотов, а долгий затор всё равно кончится 503. Включать оба сразу не надо — внешний Throttle перехватит запросы раньше, чем очередь увидит их. Запустите локальным Go и снимите нагрузку утилитой вроде hey: статусы видно в логе.
Что напечатает select с истёкшим дедлайном?
Прочитайте код и выберите, что он выведет:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
ch := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
ch <- "готово"
}()
select {
case res := <-ch:
fmt.Println(res)
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}Проверка: Throttle под нагрузкой
Сервис обёрнут в middleware.Throttle(10), и одновременно пришло 25 запросов. Что получит запрос номер 15?
Соберите предохранители
Впишите имена двух middleware: один отвечает за дедлайн и 504, второй — за лимит параллелизма и 503.
Куда ставить предохранители в цепочке#
Порядок регистрации решает, что увидит предохранитель. Правило простое: страховки — раньше, бизнес-логика — позже. Recoverer ставят первым, чтобы паника в любом слое ниже была поймана; Timeout и Throttle следуют сразу за ним и перед собственной бизнес-логикой. Логика такая: Throttle должен считать запросы до того, как они потратят силы на декодирование тела или проверку токена, — иначе толпа всё равно выполнит дорогую часть работы. А Timeout обязан отсчитывать дедлайн с самого начала пути, а не с середины цепочки. В боевой конфигурации предохранители занимают место между служебными слоями вроде RequestID и Logger и регистрацией маршрутов — причём служебные полезно иметь раньше: тогда даже отклонённый по 503 запрос оставит в логах строку с идентификатором, и по ней найдут инцидент.
Соберите боевую цепочку
Разложите слои в правильном порядке: идентификатор запроса, лог, страховка от паник, предохранители — и маршруты в самом конце.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Таймауты go: внешний барьер http.Server#
Middleware работают внутри вашего кода, но у сервера есть собственный заслон. У http.Server есть ReadTimeout, WriteTimeout и IdleTimeout: первый ограничивает время на чтение запроса — от accept до полного тела, второй — время на запись ответа, третий держит паузу между keep-alive запросами. Это внешний барьер против внутреннего: Throttle и Timeout защищают ваш бизнес-код, а серверные таймауты — само соединение, включая клиентов, которые открыли соединение и молчат, как slowloris. Настраиваются они при создании сервера, а не через middleware: srv := &http.Server{ReadTimeout: 5 time.Second, WriteTimeout: 10 time.Second} вместо голого http.ListenAndServe. Правильная сборка — оба уровня сразу: сервер обрезает протокольные хвосты и медленных клиентов, middleware управляет дедлайнами конкретных хендлеров. Термины вроде OOM, keep-alive и slowloris разобраны в глоссарии.
Механизмы защиты: что делает и что отвечает#
| Механизм | Что делает | Код ответа |
|---|---|---|
| middleware.Timeout(d) | дедлайн на каждый хендлер; по истечении отменяет контекст запроса | 504 Gateway Timeout |
| middleware.Throttle(n) | пропускает не больше n одновременных запросов, лишние — сразу | 503 Service Unavailable |
| middleware.ThrottleBacklog(limit, backlog, timeout) | сверх limit держит очередь ожидания до backlog позиций и не дольше timeout | 503 Service Unavailable |
| ReadTimeout / WriteTimeout у http.Server | внешний барьер: обрезает медленных клиентов и долгую запись на уровне соединения | соединение обрывается |
Минимальный набор предохранителей сервиса#
- Recoverer раньше всех: паника в одном хендлере не должна ронять процесс
- Timeout на весь роутер: у каждого хендлера есть срок
- Throttle или ThrottleBacklog на тяжёлые маршруты: толпа не должна давить горутинами
- ReadTimeout и WriteTimeout у http.Server: внешний барьер против медленных клиентов
- Прокидывайте r.Context() в БД и внешние API: дедлайн должен доезжать до самого медленного места
Пользователь нажал кнопку. Что полезнее: две секунды спиннера и 504 с шансом, что запрос всё же успел, — или мгновенный 503 с честным «сейчас не справляемся»? Моё мнение: для интерактивных страниц быстрый отказ честнее — ожидание перед той же ошибкой ценности не добавляет, а 503 хотя бы даёт клиенту повод повторить попытку позже. Но для фоновых вызовов, где клиент умеет ждать, дедлайн с 504 оправдан: вдруг успеет. Выбор зависит от того, кто и зачем стучится в ваш API.
Карточки: таймауты и троттлинг
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- middleware.Timeout(d) даёт каждому хендлеру дедлайн и возвращает 504 Gateway Timeout.
- Хендлер не прерывают силой: за отменой следят через r.Context().Done(), иначе горутина доработает до конца.
- Throttle(n) — мгновенный отказ 503 сверх n одновременных; ThrottleBacklog добавляет очередь ожидания с таймаутом.
- Порядок цепочки: Recoverer раньше, предохранители до бизнес-логики, маршруты в конце.
- Таймауты http.Server — внешний барьер: обрезают медленных клиентов на уровне соединения.
- Без таймаутов медленная БД копит висящие горутины — память растёт до OOM.
Домашнее задание
Возьмите сервис из прошлых уроков и доведите предохранители до конца: повесьте middleware.Timeout(2 * time.Second) и middleware.Throttle(5), добавьте WriteTimeout при создании http.Server. Напишите хендлер, который следит за r.Context().Done() через select, и проверьте curl-ом: запрос к медленному маршруту должен возвращать 504, а двадцать параллельных — частично 503.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему chi.Timeout возвращает именно 504, а не 503?
504 Gateway Timeout по спецификации HTTP означает, что срок ожидания истёк, — ровно то, что произошло. Код 503 зарезервирован в chi под Throttle: перегрузка и дедлайн — разные события, и мониторинг различает их по кодам ответов. Смешивать их не стоит.
Хендлер продолжает работать после 504 — это баг?
Нет, это устройство Go: горутину нельзя прервать силой, middleware лишь отменяет контекст. Чтобы работа реально останавливалась, следите за r.Context().Done() в select и прокидывайте контекст в запросы к БД и внешним сервисам.
Какое значение дедлайна ставить в Timeout?
Чуть больше самого медленного нормального запроса: для веб-API обычно две-пять секунд. И помните: увеличенный дедлайн не ускоряет запрос, а лишь дольше держит ресурсы — лечить стоит медленную БД, а не растить таймаут.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- CORS: чиним браузерные запросы
- Централизованные ошибки: errorHandler и NotFound
- Graceful shutdown: выключаемся вежливо
- Валидация параметров и тел запросов
- Собственный middleware: таймер, лог, авторизация
- JSON API: парсим и отдаём JSON
- context.Context: прокидываем данные в хендлеры
- Wildcards: маршруты со звёздочкой
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter