Таймауты через context в Go не дают медленным запросам блокировать горутины, соединения и память. Узнайте о передаче дедлайнов, отмене и безопасных настройках по умолчанию.

Один медленный запрос редко бывает просто «медленным». Пока он ждёт, он держит горутину, занимает память для буферов и объектов ответа и часто удерживает соединение к базе данных или слот в пуле. Когда таких медленных запросов становится много, ваш API перестаёт выполнять полезную работу, потому что ограниченные ресурсы заняты ожиданием.
Последствия обычно видны в трёх местах. Горутин становится больше, накладные расходы планировщика растут, и задержки ухудшаются для всех. Пулы соединений к базе исчерпываются, так что даже быстрые запросы начинают ждать за медленными. Память растёт из‑за данных в полёте и частично сформированных ответов, что увеличивает работу сборщика мусора.
Добавление новых серверов часто не решает проблему. Если каждый экземпляр упирается в одно и то же: маленький пул БД, медленный апстрим, общие лимиты скорости — вы просто перемещаете очередь и платите больше, а ошибки всё равно растут.
Представьте хендлер, который делает распараллеливание: загружает пользователя из PostgreSQL, вызывает сервис платежей, затем вызывает сервис рекомендаций. Если вызов рекомендаций подвисает и ничего не отменяет его, запрос никогда не завершится. Соединение к БД может вернуться в пул, но горутина и ресурсы HTTP‑клиента останутся заняты. Умножьте это на сотни запросов — получите медленный коллапс.
Цель проста: задать явный лимит по времени, прекратить работу по его истечении, освободить ресурсы и вернуть предсказуемую ошибку. Таймауты через context в Go дают каждому шагу дедлайн, чтобы работа останавливалась, когда пользователь уже не ждёт.
context.Context — это небольшой объект, который вы передаёте по цепочке вызовов, чтобы все слои договорились об одном: когда этот запрос должен остановиться. Таймауты — обычный способ не допустить, чтобы одна медленная зависимость связала ваш сервер.
Контекст может нести три вида информации: дедлайн (когда нужно остановиться), сигнал отмены (кто‑то решил остановиться раньше) и несколько значений, привязанных к запросу (используйте экономно и не для больших объёмов данных).
Отмена не волшебство. Контекст предоставляет канал Done(). Когда он закрывается, запрос отменён или время вышло. Код, который уважает контекст, проверяет Done() (часто через select) и возвращает ошибку раньше. Также можно проверить ctx.Err(), чтобы понять причину завершения — обычно context.Canceled или context.DeadlineExceeded.
Используйте context.WithTimeout, когда нужно «остановиться через X секунд». context.WithDeadline — когда уже известна точная граница по времени. context.WithCancel подходит, когда родительское условие должно преждевременно остановить работу (клиент отключился, пользователь ушёл, у вас уже есть ответ).
Когда контекст отменяют, правильное поведение простое, но важное: прекратить работу, перестать ждать медленного ввода‑вывода и вернуть ясную ошибку. Если хендлер ждёт результата запроса к базе и контекст завершился, быстро верните управление и позвольте драйверу базы отменить запрос, если он поддерживает контекст.
Самое безопасное место для остановки медленных запросов — граница, где трафик входит в ваш сервис. Если запрос должен таймаутиться, вы хотите, чтобы это происходило предсказуемо и рано, а не после того, как он занял горутины, соединения с БД и память.
Начните с края (балансировщик нагрузки, API‑шлюз, обратный прокси) и задайте жёсткий лимит на то, сколько может жить любой запрос. Это защищает ваш Go‑сервис даже если где‑то внутри забыли установить таймаут.
Внутри Go‑сервера настройте HTTP‑таймауты, чтобы сервер не ждал вечно медленного клиента или зависшего ответа. Минимум — таймауты на чтение заголовков, чтение тела запроса, запись ответа и хранение простых (idle) соединений.
Выберите дефолтный бюджет запроса, соответствующий вашему продукту. Для многих API 1–3 секунды — разумная стартовая точка для типичных запросов, с более высоким лимитом для известных медленных операций (экспортов и т. п.). Точное число важнее последовательности: измеряйте и имейте правило для исключений.
Потоки (streaming) требуют особого внимания. Легко случайно сделать бесконечный стрим, где сервер держит соединение открытым и пишет крошечные чанки бесконечно или вообще долго не отправляет первый байт. Решите заранее, действительно ли эндпоинт должен быть стримом. Если нет — ограничьте общее время и время до первого байта.
Как только на границе есть ясный дедлайн, гораздо проще протащить его по всему запросу.
Самое простое место для старта — HTTP‑хендлер. Именно туда входит один запрос, поэтому это естественное место задать жёсткий лимит.
Создайте новый контекст с дедлайном и обязательно отменяйте его. Затем передавайте этот контекст во всё, что может блокировать: работу с БД, HTTP‑вызовы или долгие вычисления.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Хорошее правило: если функция может ждать ввода‑вывода, она должна принимать context.Context. Сохраняйте читабельность хендлеров, вынося детали в небольшие вспомогательные функции вроде loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo должен использовать QueryRowContext/ExecContext
}
Если дедлайн вышел (или клиент отключился), остановите работу и верните дружелюбный ответ. Частая стратегия — маппить context.DeadlineExceeded в 504 Gateway Timeout, а context.Canceled — как «клиент ушёл» (часто без тела ответа).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Клиент ушёл. Не делаем больше работы.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Этот шаблон предотвращает накопления. Как только таймер сработает, каждая функция, уважающая контекст, по цепочке получает тот же сигнал остановки и может быстро выйти.
Как только у хендлера есть контекст с дедлайном, главное правило простое: используйте этот же ctx при вызове базы. Это позволяет таймаутам останавливать реальную работу, а не только мешать хендлеру ждать.
С database/sql отдавайте предпочтение методам с контекстом:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Если бюджет хендлера 2 секунды, базе следует дать только часть этого времени. Оставьте запас на JSON‑кодирование, другие зависимости и обработку ошибок. Простая отправная точка — давать Postgres 30–60% от общего бюджета. При 2 секундах это будет примерно 800ms–1.2s.
Когда контекст отменяют, драйвер просит Postgres остановить запрос. Обычно соединение возвращается в пул и может быть переиспользовано. Если отмена случилась в неблагоприятный сетевой момент, драйвер может выбросить это соединение и открыть новое позже. В любом случае вы избегаете горутины, которая ждёт вечно.
При проверке ошибок различайте таймауты и реальные сбои БД. Если errors.Is(err, context.DeadlineExceeded), время вышло — возвращайте таймаут. Если errors.Is(err, context.Canceled), клиент ушёл — останавливайте тихо. Остальные ошибки — обычные проблемы запроса (плохой SQL, отсутствующая строка, права доступа).
Если у вашего хендлера есть дедлайн, исходящие HTTP‑вызовы тоже должны его уважать. Иначе клиент уйдёт, а сервер будет продолжать ждать медленный апстрим и удерживать горутины, сокеты и память.
Стройте исходящие запросы с родительским контекстом, чтобы отмена шла автоматически:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Добавляем небольшой лимит на вызов, но никогда не превышаем родительский дедлайн.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // всегда закрывать, даже при не‑200
return io.ReadAll(resp.Body)
}
Этот пер‑вызовный таймаут — страховка. Родительский дедлайн остаётся главным. Один общий таймер для запроса и дополнительные небольшие крышки для рискованных шагов.
Также настройте таймауты на уровне транспорта. Контекст отменяет запрос, но таймауты транспорта защищают от медленных рукопожатий и серверов, которые не присылают заголовки.
Одна деталь, которая подводит команды: тело ответа нужно закрывать на каждом пути. Если вы возвращаете раньше (проверка кода состояния, ошибка JSON‑декодирования, таймаут контекста), всё равно закройте тело. Утечки тел могут незаметно исчерпать соединения в пуле и привести к «случайным» всплескам задержек.
Конкретный сценарий: ваш API вызывает провайдера платежей. Клиент таймаутится через 2 секунды, а апстрим висит 30 секунд. Без отмены запросов и таймаутов транспорта вы платите за эти 30 секунд ожидания для каждого оставленного запроса.
Один запрос обычно задействует несколько медленных вещей: работа хендлера, запрос в БД и один или больше внешних API. Если давать каждой части щедрый таймаут, общее время незаметно растёт, пока пользователи не начнут жаловаться, а сервер не начнёт собирать очередь.
Бюджетирование — простейшая мера. Задайте один родительский дедлайн для всего запроса, затем выделяйте каждой зависимости меньшую долю. Дочерние дедлайны должны наступать раньше родительского, чтобы вы быстро падали и успели вернуть чистую ошибку.
Практические правила, которые работают в реальных сервисах:
Избегайте наложения таймаутов, которые конфликтуют друг с другом. Если у хендлера контекст на 2 секунды, а HTTP‑клиент настроен на 10 секунд, всё в порядке, но это путано. Обратная ситуация — клиент может обрубить запрос раньше по не связанным причинам.
Для фоновой работы (аудит‑логи, метрики, письма) не переиспользуйте контекст запроса. Создавайте отдельный контекст с собственным коротким таймаутом, чтобы отмена клиентом не убивала важную очистку.
Большинство багов с таймаутами не в хендлере. Они появляются на одном‑двух уровнях ниже, где дедлайн тихо теряется. Если вы ставите таймауты на границе, но игнорируете их в середине, вы всё ещё получите горутины, запросы к БД или HTTP‑вызовы, которые продолжают работать после ухода клиента.
Наиболее частые паттерны просты:
context.Background() (или TODO). Это разрывает связь работы с отменой клиента и дедлайном хендлера.ctx.Done(). Запрос отменён, а код продолжает ждать.context.WithTimeout. В итоге получается много таймеров и путаница в дедлайнах.ctx к блокирующим вызовам (БД, исходящие HTTP, публикации сообщений). Таймаут хендлера бессилен, если зависимость его игнорирует.Классическая ошибка: вы ставите 2‑секундный таймаут в хендлере, а репозиторий использует context.Background() для запроса к базе. Под нагрузкой медленный запрос продолжает выполняться даже после того, как клиент ушёл, и очередь растёт.
Почините основы: передавайте ctx первым аргументом по стеку вызовов. Внутри долгой работы добавьте быстрые проверки вроде select { case <-ctx.Done(): return ctx.Err() default: }. Маpьте context.DeadlineExceeded в ответ таймаута (часто 504), а context.Canceled — в ответ, указывающий на отключение клиента (часто 408 или 499 в зависимости от конвенций).
Таймауты помогают только если вы видите, как они работают, и подтверждаете, что система корректно восстанавливается. Когда что‑то медлит, запрос должен остановиться, ресурсы освободиться, и API оставаться отзывчивым.
Для каждого запроса логируйте небольшой одинаковый набор полей, чтобы сравнивать нормальные запросы и таймауты. Включайте дедлайн контекста (если есть) и что завершило работу.
Полезные поля: дедлайн (или «none»), общее время выполнения, причина отмены (таймаут vs отмена клиентом), короткая метка операции ("db.query users", "http.call billing") и request ID.
Минимальная схема выглядит так:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Логи помогают разбирать отдельные запросы. Метрики показывают тренды.
Отслеживайте несколько сигналов, которые обычно первыми показывают проблемы: число таймаутов по маршруту и зависимости, количество одновременных запросов (in‑flight — должно стабилизироваться под нагрузкой), время ожидания в пуле БД и перцентильные задержки (p95/p99) раздельно по успешным и таймаутным запросам.
Сделайте задержки предсказуемыми. Добавьте в отладке задержку в одном хендлере, замедлите запрос к БД намеренно или заверните внешний вызов тестовым сервером, который «спит». Проверьте два момента: вы видите ошибку таймаута, и работа действительно останавливается вскоре после отмены.
Небольшой нагрузочный тест тоже полезен. Запустите 20–50 одновременных запросов на 30–60 секунд с одной принудительно медленной зависимостью. Количество горутин и in‑flight запросов должно вырасти и потом стабилизироваться. Если они продолжают расти, кто‑то игнорирует отмену контекста.
Таймауты помогают только если их везде применили там, где запрос может ждать. Перед деплоем пробегитесь по коду и убедитесь, что правила соблюдены во всех хендлерах.
context.DeadlineExceeded и context.Canceled.http.NewRequestWithContext (или req = req.WithContext(ctx)), а клиент/транспорт настроены таймаутами (dial, TLS, заголовки ответа). В продакшен избегайте полагаться на http.DefaultClient.Небольшая «драил» на медленную зависимость перед релизом стоит потраченного времени. Добавьте искусственную задержку 2 секунды в один SQL‑запрос и проверьте три вещи: хендлер возвращает вовремя, DB‑вызов действительно останавливается (а не только хендлер перестаёт ждать), и в логах явно видно, что это был таймаут БД.
Представьте эндпоинт GET /v1/account/summary. Одна операция пользователя инициирует три вещи: запрос в PostgreSQL (счёт и недавняя активность) и два внешних HTTP‑вызова (проверка статуса биллинга и lookup для обогащения профиля).
Дайте всему запросу жёсткий бюджет 2 секунды. Без бюджета одна медленная зависимость может держать горутины, соединения БД и память занятыми, пока ваш API не начнёт таймаутиться повсеместно.
Простое распределение: 800ms для БД, 600ms для внешнего вызова A и 600ms для внешнего вызова B.
Когда вы знаете общий дедлайн, пробрасывайте его дальше. Каждой зависимости даёте свой меньший таймаут, но она по‑прежнему наследует отмену от родителя.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Если внешняя B замедлится и займёт 2.5 секунды, ваш хендлер должен перестать ждать на 600ms, отменить выполняющуюся работу и вернуть явный ответ о таймауте. Клиент увидит быстрый провал, а не застрявший спиннер.
В логах должно быть видно, что использовало бюджет: БД отработала быстро, внешний A вернулся успешно, внешний B достиг своего лимита и вернул context deadline exceeded.
Когда один реальный эндпоинт хорошо работает с таймаутами и отменой, оформите это как повторяемый паттерн. Применяйте end‑to‑end: дедлайн в хендлере, вызовы в БД и исходящие HTTP. Затем копируйте структуру в следующие эндпоинты.
Вы будете двигаться быстрее, если централизуете скучные части: helper для граничного таймаута, обёртки, которые гарантируют передачу ctx в БД и HTTP, и единый маппинг ошибок и формат логов.
Если хотите быстро прототипировать этот паттерн, Koder.ai (koder.ai) может сгенерировать Go‑хендлеры и сервисные вызовы по чату, и вы можете экспортировать исходники, чтобы применить свои middleware и бюджетные правила. Цель — последовательность: медленные вызовы останавливаются рано, ошибки выглядят одинаково, и отладка не зависит от того, кто писал эндпоинт.
Медленный запрос удерживает ограниченные ресурсы, пока ждёт: горутину, память для буферов и объектов ответа, а часто и соединение с базой данных или соединение HTTP‑клиента. Когда одновременно накапливается несколько таких запросов, образуются очереди, общая задержка растёт, и сервис может начать падать, даже если каждый запрос в отдельности когда‑то завершается.
Установите явный дедлайн на границе запроса (proxy/gateway и в самом Go‑сервисе), создайте в хендлере контекст с таймаутом и передавайте этот ctx во все блокирующие вызовы (база данных и внешние HTTP). Когда дедлайн наступает, быстро возвращайте согласованную ошибку таймаута и останавливайте выполняющуюся работу, которая поддерживает отмену.
Используйте context.WithTimeout(parent, d), когда нужно «остановиться через заданную длительность» — это чаще всего в хендлерах. Применяйте context.WithDeadline(parent, t), если у вас уже есть фиксированное время окончания. context.WithCancel(parent) годится, когда какая‑то внутренняя логика должна заранее остановить работу (например, «уже получили ответ» или «клиент отключился»).
Всегда вызывайте функцию cancel — обычно делают defer cancel() сразу после создания дочернего контекста. Отмена освобождает таймер и даёт ясный сигнал остановки дочерним операциям, особенно в путях, которые возвращают результат до наступления дедлайна.
Создайте контекст запроса один раз в хендлере и передавайте его дальше как первый аргумент в функции, которые могут блокировать. Быстрая проверка — поискать context.Background() или context.TODO() в коде обработки запросов: такие вызовы часто разрывают цепочку отмены и дедлайнов.
Используйте методы с поддержкой контекста, такие как QueryContext, QueryRowContext и ExecContext (или их аналоги в вашем драйвере). Когда контекст завершится, драйвер попытается попросить PostgreSQL отменить запрос, чтобы вы не продолжали расходовать время и соединения после завершения запроса.
Прикрепите родительский контекст к исходящему запросу через http.NewRequestWithContext(ctx, ...), а также настройте таймауты клиента/транспорта, чтобы защититься при установке соединения, TLS‑рукопожатии и ожидании заголовков ответа. Вне зависимости от статуса ответа всегда закрывайте тело ответа, чтобы соединения возвращались в пул.
Сначала выберите общий бюджет времени для запроса, затем выделите каждой зависимости меньшую часть, оставив небольшой буфер на обработку хендлера и формирование ответа. Если у родительского контекста осталось мало времени, не запускайте дорогие операции, которые не успеют завершиться до дедлайна.
Обычно context.DeadlineExceeded маппят на 504 Gateway Timeout с коротким текстом вроде «request timed out». context.Canceled чаще означает, что клиент отключился; часто лучше просто прекратить работу и не писать тело ответа, чтобы не тратить ресурсы зря.
Чаще всего разработчики теряют дедлайны, когда: заменяют контекст запроса на context.Background(), начинают ретрая или sleep без проверки ctx.Done(), не передают ctx в блокирующие вызовы или создают много несогласованных таймаутов. Все это приводит к тому, что части кода продолжают работать после того, как клиент уже ушёл.