Таймауты и отмена запросов в Go: правила для context и ретраев
Таймауты и отмена запросов в Go: как ставить дедлайны, передавать context в HTTP и БД, и делать ретраи без накопления зависших операций.

Почему без таймаутов сервис начинает зависать
Зависшая операция - это запрос, который не завершился и не был отменен. Он может ждать ответ внешнего API, упереться в сетевую проблему или «повиснуть» на блокировке в базе. Пока он жив, он держит ресурсы. Если таких запросов становится много, сервис выглядит как будто «встал», хотя процесс еще работает.
Проблема в том, что зависшие операции копятся тихо. Один долгий HTTP-вызов удерживает горутину. Один долгий запрос к БД удерживает соединение из пула. Дальше начинается цепная реакция: пул соединений пустеет, новые запросы ждут свободное соединение, очередь растет, а время ответа увеличивается для всех.
«Вечные» запросы опасны не только задержкой. Они съедают лимиты: файловые дескрипторы, память, соединения, слоты воркеров. В итоге даже быстрые операции не могут стартовать, потому что все занято теми, кто ждет непонятно чего. Поэтому таймауты и отмена - это не «оптимизация», а базовая защита сервиса.
Пользователь обычно видит это так: страница или экран «крутится» слишком долго, фронт ловит свой таймаут и показывает ошибку, иногда прилетает 500 (потому что сервер сдался раньше клиента), а повторные попытки пользователя только увеличивают нагрузку.
Простой пример: обработчик принял запрос, сходил в платежный сервис, затем пишет результат в PostgreSQL. Если платежный сервис завис на 2 минуты, а вы не поставили дедлайн, обработчик будет ждать до победного. За эти 2 минуты накапливаются такие же ожидающие запросы, а пул БД постепенно забивается теми, кто уже дошел до записи и тоже ждет. Таймауты и корректная отмена обрывают эту цепочку раньше, чем сервис начнет задыхаться.
Context в Go простыми словами: отмена и дедлайны
context.Context в Go - это способ передать по цепочке вызовов два сигнала: «пора остановиться» и «до какого времени можно работать». Он сам по себе не прерывает код «магией», но дает общий язык, чтобы обработчики, клиенты и драйверы могли завершать работу вовремя.
Отмена и дедлайн похожи, но в жизни отличаются:
Отмена (cancel) - явный сигнал: пользователь закрыл вкладку, запрос больше не нужен, сервис уходит в shutdown, лимит параллелизма превышен и вы решаете оборвать лишнее.
Дедлайн (deadline/timeout) - договоренность по времени: «даю тебе 200 мс, дальше результат не нужен». Он полезен, когда важно не держать соединения и горутины занятыми, даже если внешняя система зависла.
Контекст распространяется сверху вниз: входящий HTTP-запрос получает базовый контекст, вы добавляете к нему дедлайн (или несколько на границах), и передаете этот же ctx ниже - в бизнес-логику, HTTP-клиент, запросы к базе. Хорошее правило: если функция делает I/O или может ждать, она принимает ctx первым параметром.
Если клиент оборвал соединение, ctx.Done() сработает, и ожидание в HTTP и БД должно прекратиться. Здесь важна цель: не «дождаться правильного ответа», а быстро освободить ресурсы - прекратить ожидание, закрыть ответ/строки, остановить ретраи и не начинать новые действия, если результат уже не нужен.
Так контекст превращается в дисциплину: один сигнал сверху, и вся цепочка перестает копить зависшие операции.
Где ставить таймауты: слои и границы ответственности
Таймауты работают лучше всего, когда у них есть понятные границы. Общий дедлайн задается один раз на входе, а каждый внешний шаг получает свой, более короткий бюджет времени. Так вы быстрее освобождаете ресурсы и не копите ожидания.
Правило границ: таймаут ставится там, где вы уходите из процесса
Обычно это:
- входящий запрос в HTTP-сервер (общий дедлайн на всю обработку)
- внешний вызов (HTTP к другому сервису, очередь, стороннее API)
- запрос к базе и любые блокирующие операции внутри транзакции
- фоновые задачи и воркеры (дедлайн на одну единицу работы)
Ответственность удобно делить так: обработчик задает верхний дедлайн, а обертки над клиентами (HTTP, БД) держат разумные дефолты для конкретной операции. При этом верхний context всегда главнее: если общий дедлайн истек, никакой локальный таймаут уже не спасет.
Общий дедлайн и короткие дедлайны на подшаги
Если вы хотите уложиться в 2 секунды на весь запрос, подшаги должны быть короче: например, 500 мс на внешний HTTP, 300 мс на PostgreSQL и небольшой запас на сериализацию, логику и логи.
Внутри транзакции таймауты обычно должны быть еще жестче. Долгая транзакция держит соединение и блокировки, поэтому лучше быстро отменять и возвращать понятную ошибку, чем ждать до общего дедлайна.
Пошаговая схема: как протянуть context через весь запрос
Отмена и дедлайны работают только тогда, когда context.Context проходит через все слои: от обработчика до внешних HTTP-вызовов и базы. Один забытый вызов без ctx - и снова висят горутины и соединения.
Рабочая схема
Логика простая:
- берите
ctxиз входящего запроса и не создавайте новый «пустой» контекст - ставьте общий дедлайн на всю обработку запроса
- передавайте
ctxво все функции, репозитории и клиенты - на отдельные шаги делайте дочерний контекст с меньшим таймаутом
- при отмене или дедлайне сразу завершайте работу и возвращайте понятную ошибку
Вот минимальный скелет, который удобно копировать в сервис:
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
err := h.svc.Do(ctx)
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
func (s *Service) Do(ctx context.Context) error {
dbCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
if err := s.repo.Save(dbCtx); err != nil { return err }
httpCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.client.Call(httpCtx)
}
Если общий дедлайн 2 секунды, то на БД можно дать 300 мс, а на внешний запрос 500 мс. Если внешний вызов «подвис», отмена по httpCtx остановит ожидание, и воркеры не будут заняты бесконечно.
Внешние HTTP-вызовы: таймауты и отмена без сюрпризов
Внешний HTTP-вызов должен завершаться за понятное время. Иначе он держит горутину, соединение и память, очередь растет, а сервис медленно «застывает». Здесь важны два уровня: настройки у клиента и контекст у конкретного запроса.
Сделайте один общий http.Client на сервис и задайте ему верхнюю границу по времени. Параллельно настройте таймауты в Transport, чтобы зависание не пряталось внутри соединения.
Настройка клиента и Transport
Обычно достаточно контролировать connect, TLS-рукопожатие, ожидание заголовков ответа и простой keep-alive соединений.
tr := &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
TLSHandshakeTimeout: 2 * time.Second,
ResponseHeaderTimeout: 3 * time.Second,
IdleConnTimeout: 30 * time.Second,
}
client := &http.Client{
Transport: tr,
Timeout: 5 * time.Second, // общий предел на весь запрос (включая чтение body)
}
Чтобы отмена реально сработала, привяжите context к запросу:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
Тело ответа важно всегда закрывать. Если бросить Body незакрытым, соединения будут «залипать» в пуле.
Как понимать ошибки
Полезно разделять «сеть сломалась» и «мы сами отменили»:
errors.Is(err, context.DeadlineExceeded)илиctx.Err() == context.DeadlineExceeded- вышли за дедлайнerrors.Is(err, context.Canceled)- ручная отмена (например, клиент закрыл соединение)net.ErrorсTimeout() == trueчаще про сетевой таймаут, а не про вашcontext
Так проще логировать причину и включать ретраи только там, где они уместны.
База данных: таймауты запросов и безопасные транзакции
С базой данных проблема обычно такая же: один зависший запрос держит соединение, а дальше начинает ждать весь сервис. В Go это особенно заметно, потому что пул соединений в database/sql конечный. Если несколько запросов зависнут, пул быстро забивается, и даже быстрые операции встают в очередь.
Первое правило: используйте методы с контекстом. Не Query и Exec, а QueryContext и ExecContext. Тогда отмена (например, когда клиент ушел) дойдет до драйвера, и соединение вернется в пул.
Транзакции тоже должны жить по дедлайну. Задайте дедлайн на транзакцию и не делайте внутри запросы без ограничений. Транзакция должна быть короткой, иначе вы держите блокировки и соединение слишком долго.
В PostgreSQL стоит добавить серверный предохранитель statement_timeout. Он полезен даже при context.WithTimeout, потому что не все сетевые сбои гарантируют корректную отмену на стороне сервера, а защита на сервере спасает, если где-то забыли проставить контекст.
Корректное завершение важно так же, как и таймаут. Если пришла отмена, транзакцию нужно откатить, а результаты запроса закрыть.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // безопасно, даже если уже был Commit
rows, err := tx.QueryContext(ctx, `SELECT id FROM items WHERE status=$1`, "new")
if err != nil { return err }
defer rows.Close()
return tx.Commit()
Ретраи: как повторять запросы и не устроить шторм
Ретрай полезен, когда ошибка временная: сеть моргнула, балансировщик вернул 502, база на секунду перегрузилась. Но если повторять все подряд, вы устроите шторм: нагрузка растет, очереди забиваются, таймауты множатся. Повторы должны быть частью общего бюджета времени, а не отдельной бесконечной стратегией.
Когда ретраи уместны
Обычно имеет смысл повторять то, что вероятно пройдет со второй попытки и не сломает данные: сетевые ошибки, HTTP 5xx и 429 (если есть смысл подождать), временные ошибки БД, короткие операции чтения.
А вот ретраи вредны для неидемпотентных действий: списать деньги, создать заказ, отправить письмо. Повтор может выполнить действие дважды. Для таких случаев нужен идемпотентный ключ или защита на уровне БД.
Как повторять безопасно
Делайте паузы между попытками: backoff (пауза растет) и jitter (небольшой случайный разброс), чтобы все инстансы не били в один момент.
Главное правило: ретраи обязаны уважать дедлайн контекста. Бюджет времени задается на всю операцию целиком. Если ctx отменен или дедлайн близко, прекращайте повторы.
Практичная рамка: 2-3 попытки, экспоненциальный backoff с jitter и общий лимит времени (например, 1-2 секунды на все). Если внешняя система не укладывается в этот бюджет, ретраи не спасут - нужно разбираться с причиной.
Как понять, что все работает: метрики и логи таймаутов
Таймауты и отмена считаются «внедренными» не тогда, когда вы добавили context.WithTimeout, а когда вы можете быстро ответить на два вопроса: сколько таймаутов случилось за час и где именно система тормозит.
Начните с логов. На каждом внешнем вызове и запросе в БД фиксируйте дедлайн (или оставшееся время), фактическую длительность и причину остановки: обычная ошибка, context deadline exceeded или context canceled. Важно, чтобы поля были одинаковыми во всех местах, иначе расследование превращается в ручной поиск.
Метрики дают картину быстрее, чем логи. Минимальный набор, который обычно окупается быстро:
- доля таймаутов и отмен (отдельно для HTTP и БД)
- p95/p99 времени ответа по ручкам и внешним зависимостям
- занятость пула соединений к PostgreSQL (в работе, в ожидании)
- длина очередей (если есть воркеры/пулы) и число горутин
- число ретраев и доля запросов, которые «дожали» со второй попытки
Когда метрики показывают рост задержек, трассировка помогает понять, где «застряло»: DNS/TLS, ожидание ответа партнера, ожидание соединения к БД или внутри транзакции.
Продумайте, что возвращать клиенту при остановке. При дедлайне чаще всего уместен 504; если клиент сам отменил запрос - 499 (если вы так договорились) или 408. Главное - не прятать таймаут под 500, иначе статистика ошибок будет лгать.
Алерты лучше разделять: рост timeouts отдельно и рост очередей/ожидания пула БД отдельно. Если резко выросло «ожидание соединения», это часто другая причина, чем медленные запросы.
Частые ошибки, из-за которых отмена не срабатывает
Самый неприятный сценарий выглядит так: клиент уже ушел, а ваш сервис еще минутами держит горутину, соединение с БД и ожидание внешнего API. Чаще всего это не «баг Go», а типовые ошибки в коде.
Ошибки, которые ломают отмену
Самая частая - внутри бизнес-логики создают новый context.Background() или context.TODO(). Вы «обнуляете» цепочку, и дедлайны и cancel() от HTTP-хендлера уже не доходят до БД и внешних вызовов.
Вторая ловушка - один огромный таймаут «на все». Тогда вы не контролируете подшаги (DNS, TCP, чтение ответа, запрос в БД, транзакцию). Когда что-то зависло, вы видите только один общий таймаут и не понимаете, где именно.
Третья группа проблем - ресурсы не освобождаются вовремя: не закрыли resp.Body, не закрыли rows, забыли defer tx.Rollback(). Отмена могла сработать, но соединение останется занятым, и очередь будет расти.
Ретраи тоже умеют «убивать» отмену: бесконечные повторы или повторы без паузы быстро увеличивают нагрузку, и таймауты начинают срабатывать чаще.
Наконец, многие путают таймаут клиента и таймаут сервера. Например, клиент ждет 10 секунд, а прокси держит 60. Получаются двойные ожидания и непредсказуемые обрывы.
Если коротко, проверяйте:
- не создавайте новый
Backgroundв середине запроса, только наследуйтеctx - задавайте отдельные дедлайны на подшаги, а не один «огромный»
- всегда закрывайте
Bodyиrows, а транзакции страхуйтеRollback - ретраи делайте ограниченными и с задержкой, уважайте
ctx.Done() - согласуйте таймауты между клиентом, сервером и внешними зависимостями
Короткий чеклист: минимальные правила на каждый сервис
У любого запроса должен быть понятный предел по времени, и этот предел должен доходить до каждого внешнего действия (HTTP, БД, очереди). Тогда отмена работает честно, а сервис не копит зависшие горутины, транзакции и соединения.
Проверьте базовые вещи:
- на входе есть общий дедлайн на обработку запроса
- каждый внешний вызов принимает
ctxи имеет свой таймаут меньше общего - доступ к базе только через методы с Context
- ретраи ограничены по попыткам и по времени и уважают
ctx - ресурсы всегда закрываются:
response body,rows, а транзакции завершаютсяcommitилиrollback
Отдельно проверьте наблюдаемость: в логах и метриках должно быть видно, что произошло - таймаут, ручная отмена или другая ошибка. Простое правило для логов: при ошибке пишите тип операции (HTTP или SQL), длительность и ctx.Err().
Пример из жизни: цепочка из HTTP-вызовов и операций в БД
Представьте сервис оформления заказа. Он принимает запрос, создает заказ в PostgreSQL, затем вызывает платежный сервис, потом сервис склада, и в конце фиксирует итоговый статус. Если не поставить дедлайны на каждом шаге, вы легко получите зависшие горутины, открытые коннекты и очередь запросов, которая растет сама по себе.
Хорошее правило: один дедлайн на весь пользовательский запрос и более короткие таймауты на отдельные шаги. Например, общий дедлайн 2 секунды, а внутри: платеж 800 мс, склад 500 мс, обновление БД 200 мс.
Практичная схема:
- в хендлере создаем общий контекст с дедлайном и прокидываем его дальше
- в БД быстро создаем заказ со статусом
pendingи фиксируем транзакцию - отдельно вызываем платеж по контексту с коротким таймаутом
- если платеж успешен, вызываем склад (тоже с коротким таймаутом)
- в конце обновляем статус заказа в БД (успех или причина ошибки)
Ключевой момент: не держите транзакцию БД открытой во время внешнего HTTP-вызова. Иначе вы удерживаете блокировки и соединение, а при таймауте получаете проблему, которая мешает другим операциям.
Если один шаг отменился, частичный успех лучше обрабатывать через статусы и компенсацию. Например, платеж прошел, но склад не ответил до дедлайна: заказ остается pending, а фоновая задача пытается дозавершить резерв или делает возврат. Важно, чтобы внешние операции были идемпотентными, иначе ретрай может списать деньги дважды.
Для клиента итог должен быть понятным: «нет товара» - 409 или 400 с ясной причиной, не успели за общий дедлайн - 504, временная ошибка платежа или склада - 503 (ретраить только то, что безопасно повторять).
Следующие шаги: как внедрить правила в проект без боли
Цель таймаутов и отмены простая: сервис должен освобождать ресурсы и быстро возвращаться в норму после сбоев у соседей.
Начните с инвентаризации. Выпишите все внешние зависимости: базы данных, очереди, сторонние HTTP API, внутренние сервисы. Для каждой точки определите реальное ожидание по времени: сколько она обычно отвечает и сколько вы готовы ждать в худшем случае. Лучше опираться на логи и метрики (например, p95 плюс небольшой запас), чем брать цифры «с потолка».
Дальше введите стандарт команды: когда таймауты и ретраи живут в одном месте и называются одинаково, ошибок меньше.
- зафиксируйте дефолты: общий дедлайн запроса, таймауты для HTTP и БД, лимиты на ретраи и backoff
- оформите это как небольшой пакет или конфиг и избегайте «магических чисел» в коде
- добавьте тесты на отмену: короткий дедлайн, искусственная задержка, проверка, что функции быстро выходят
- подключайте наблюдаемость постепенно: сначала счетчики таймаутов и отмен, затем разметку логов по причинам
- правило ревью: любой внешний вызов должен принимать
contextи уважать его
Если вы собираете типовые сервисы на Go с PostgreSQL, удобно один раз зафиксировать эти настройки в шаблонах и дальше переиспользовать. В TakProsto (takprosto.ai) это можно заложить на старте в генерируемой обвязке, чтобы одинаковые правила применялись во всех сервисах.
FAQ
Почему сервис «застывает», если не ставить таймауты?
Если операция может ждать, она будет ждать вечно: внешний HTTP может зависнуть, БД может упереться в блокировку, сеть может «потерять» пакет. Такие запросы держат горутины и соединения из пула, и со временем новые запросы начинают ждать ресурсы, из‑за чего сервис выглядит «мертвым», хотя процесс жив.
Что такое context.Context простыми словами и зачем он нужен?
Это способ передать по цепочке вызовов два сигнала: что работу нужно прекратить, и до какого момента ее можно делать. Сам по себе контекст ничего не «убивает», но дает единое правило для HTTP-клиентов, драйверов БД и вашей бизнес-логики, чтобы они вовремя завершались.
Чем отличается cancel от deadline/timeout в Go?
Отмена — это явный сигнал остановиться, например пользователь закрыл вкладку или сервис уходит в shutdown. Дедлайн — это ограничение по времени: «у тебя есть 200 мс, потом результат не нужен». На практике вам почти всегда нужны оба механизма, потому что они защищают ресурсы в разных сценариях.
Где правильно ставить таймауты: в хендлере, сервисе или репозитории?
Ставьте общий дедлайн на входе запроса (в хендлере), а более короткие таймауты — на границах, где вы ждете внешние ресурсы: HTTP к другим сервисам, запросы к БД, операции внутри транзакции, работа воркера. Так вы быстрее освобождаете пул соединений и не копите «вечные» ожидания.
Как правильно протянуть context через весь запрос, чтобы отмена реально работала?
Берите контекст из входящего запроса и не создавайте новый context.Background() внутри цепочки. Передавайте один и тот же ctx вниз во все функции, а для конкретных шагов делайте дочерний контекст с более коротким таймаутом. Это гарантирует, что отмена клиента или общий дедлайн остановят всю цепочку.
Как выбрать таймауты для подшагов, если общий дедлайн, например, 2 секунды?
Обычно задают общий бюджет на весь пользовательский запрос, а под него нарезают более короткие бюджеты на шаги. Важно оставлять небольшой запас на код вокруг I/O (сериализация, логика, логирование), иначе вы будете получать таймауты «на ровном месте» из‑за накладных расходов.
Как настроить внешний HTTP-вызов в Go, чтобы он не висел и не сжирал соединения?
Нужны два уровня защиты: таймауты у http.Client/Transport и контекст у конкретного запроса через NewRequestWithContext. Даже при корректных таймаутах всегда закрывайте resp.Body, иначе соединения будут залипать в пуле и деградация вернется в другом виде.
Что обязательно сделать для PostgreSQL, чтобы таймауты действительно освобождали пул соединений?
Используйте методы с контекстом (QueryContext, ExecContext, BeginTx с ctx) и делайте транзакции короткими. При отмене или дедлайне важно быстро завершать работу: закрывать rows, делать Rollback, не держать транзакцию открытой во время внешних HTTP-вызовов. Дополнительно полезно включить серверный предохранитель на стороне PostgreSQL, например statement_timeout.
Как делать ретраи и не устроить шторм из повторов?
Повторяйте только то, что безопасно повторять, и обязательно ограничивайте попытки и общее время. Ретраи должны уважать ctx.Done(): если дедлайн истек или запрос отменен, попытки прекращаются сразу. Для операций, которые нельзя выполнять дважды (например, списание денег), сначала обеспечьте идемпотентность, иначе ретрай создаст дубли.
Почему отмена не срабатывает, хотя я поставил context.WithTimeout?
Чаще всего причина в разрыве цепочки: где-то создали context.Background()/TODO() и «отрубили» дедлайны, или забыли закрыть ресурсы (resp.Body, rows, транзакцию). Вторая частая ошибка — один огромный таймаут на все без отдельных бюджетов на шаги, из‑за чего вы не видите, где именно тормозит система. Лечится дисциплиной: единые правила передачи ctx, короткие таймауты на границах и одинаковое логирование причин (deadline exceeded vs canceled).