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

Зависшая операция - это запрос, который не завершился и не был отменен. Он может ждать ответ внешнего API, упереться в сетевую проблему или «повиснуть» на блокировке в базе. Пока он жив, он держит ресурсы. Если таких запросов становится много, сервис выглядит как будто «встал», хотя процесс еще работает.
Проблема в том, что зависшие операции копятся тихо. Один долгий HTTP-вызов удерживает горутину. Один долгий запрос к БД удерживает соединение из пула. Дальше начинается цепная реакция: пул соединений пустеет, новые запросы ждут свободное соединение, очередь растет, а время ответа увеличивается для всех.
«Вечные» запросы опасны не только задержкой. Они съедают лимиты: файловые дескрипторы, память, соединения, слоты воркеров. В итоге даже быстрые операции не могут стартовать, потому что все занято теми, кто ждет непонятно чего. Поэтому таймауты и отмена - это не «оптимизация», а базовая защита сервиса.
Пользователь обычно видит это так: страница или экран «крутится» слишком долго, фронт ловит свой таймаут и показывает ошибку, иногда прилетает 500 (потому что сервер сдался раньше клиента), а повторные попытки пользователя только увеличивают нагрузку.
Простой пример: обработчик принял запрос, сходил в платежный сервис, затем пишет результат в PostgreSQL. Если платежный сервис завис на 2 минуты, а вы не поставили дедлайн, обработчик будет ждать до победного. За эти 2 минуты накапливаются такие же ожидающие запросы, а пул БД постепенно забивается теми, кто уже дошел до записи и тоже ждет. Таймауты и корректная отмена обрывают эту цепочку раньше, чем сервис начнет задыхаться.
context.Context в Go - это способ передать по цепочке вызовов два сигнала: «пора остановиться» и «до какого времени можно работать». Он сам по себе не прерывает код «магией», но дает общий язык, чтобы обработчики, клиенты и драйверы могли завершать работу вовремя.
Отмена и дедлайн похожи, но в жизни отличаются:
Отмена (cancel) - явный сигнал: пользователь закрыл вкладку, запрос больше не нужен, сервис уходит в shutdown, лимит параллелизма превышен и вы решаете оборвать лишнее.
Дедлайн (deadline/timeout) - договоренность по времени: «даю тебе 200 мс, дальше результат не нужен». Он полезен, когда важно не держать соединения и горутины занятыми, даже если внешняя система зависла.
Контекст распространяется сверху вниз: входящий HTTP-запрос получает базовый контекст, вы добавляете к нему дедлайн (или несколько на границах), и передаете этот же ctx ниже - в бизнес-логику, HTTP-клиент, запросы к базе. Хорошее правило: если функция делает I/O или может ждать, она принимает ctx первым параметром.
Если клиент оборвал соединение, ctx.Done() сработает, и ожидание в HTTP и БД должно прекратиться. Здесь важна цель: не «дождаться правильного ответа», а быстро освободить ресурсы - прекратить ожидание, закрыть ответ/строки, остановить ретраи и не начинать новые действия, если результат уже не нужен.
Так контекст превращается в дисциплину: один сигнал сверху, и вся цепочка перестает копить зависшие операции.
Таймауты работают лучше всего, когда у них есть понятные границы. Общий дедлайн задается один раз на входе, а каждый внешний шаг получает свой, более короткий бюджет времени. Так вы быстрее освобождаете ресурсы и не копите ожидания.
Обычно это:
Ответственность удобно делить так: обработчик задает верхний дедлайн, а обертки над клиентами (HTTP, БД) держат разумные дефолты для конкретной операции. При этом верхний context всегда главнее: если общий дедлайн истек, никакой локальный таймаут уже не спасет.
Если вы хотите уложиться в 2 секунды на весь запрос, подшаги должны быть короче: например, 500 мс на внешний HTTP, 300 мс на PostgreSQL и небольшой запас на сериализацию, логику и логи.
Внутри транзакции таймауты обычно должны быть еще жестче. Долгая транзакция держит соединение и блокировки, поэтому лучше быстро отменять и возвращать понятную ошибку, чем ждать до общего дедлайна.
Отмена и дедлайны работают только тогда, когда 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.Client на сервис и задайте ему верхнюю границу по времени. Параллельно настройте таймауты в 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. Важно, чтобы поля были одинаковыми во всех местах, иначе расследование превращается в ручной поиск.
Метрики дают картину быстрее, чем логи. Минимальный набор, который обычно окупается быстро:
Когда метрики показывают рост задержек, трассировка помогает понять, где «застряло»: 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 в середине запроса, только наследуйте ctxBody и rows, а транзакции страхуйте Rollbackctx.Done()У любого запроса должен быть понятный предел по времени, и этот предел должен доходить до каждого внешнего действия (HTTP, БД, очереди). Тогда отмена работает честно, а сервис не копит зависшие горутины, транзакции и соединения.
Проверьте базовые вещи:
ctx и имеет свой таймаут меньше общегоctxresponse body, rows, а транзакции завершаются commit или rollbackОтдельно проверьте наблюдаемость: в логах и метриках должно быть видно, что произошло - таймаут, ручная отмена или другая ошибка. Простое правило для логов: при ошибке пишите тип операции (HTTP или SQL), длительность и ctx.Err().
Представьте сервис оформления заказа. Он принимает запрос, создает заказ в PostgreSQL, затем вызывает платежный сервис, потом сервис склада, и в конце фиксирует итоговый статус. Если не поставить дедлайны на каждом шаге, вы легко получите зависшие горутины, открытые коннекты и очередь запросов, которая растет сама по себе.
Хорошее правило: один дедлайн на весь пользовательский запрос и более короткие таймауты на отдельные шаги. Например, общий дедлайн 2 секунды, а внутри: платеж 800 мс, склад 500 мс, обновление БД 200 мс.
Практичная схема:
pending и фиксируем транзакциюКлючевой момент: не держите транзакцию БД открытой во время внешнего HTTP-вызова. Иначе вы удерживаете блокировки и соединение, а при таймауте получаете проблему, которая мешает другим операциям.
Если один шаг отменился, частичный успех лучше обрабатывать через статусы и компенсацию. Например, платеж прошел, но склад не ответил до дедлайна: заказ остается pending, а фоновая задача пытается дозавершить резерв или делает возврат. Важно, чтобы внешние операции были идемпотентными, иначе ретрай может списать деньги дважды.
Для клиента итог должен быть понятным: «нет товара» - 409 или 400 с ясной причиной, не успели за общий дедлайн - 504, временная ошибка платежа или склада - 503 (ретраить только то, что безопасно повторять).
Цель таймаутов и отмены простая: сервис должен освобождать ресурсы и быстро возвращаться в норму после сбоев у соседей.
Начните с инвентаризации. Выпишите все внешние зависимости: базы данных, очереди, сторонние HTTP API, внутренние сервисы. Для каждой точки определите реальное ожидание по времени: сколько она обычно отвечает и сколько вы готовы ждать в худшем случае. Лучше опираться на логи и метрики (например, p95 плюс небольшой запас), чем брать цифры «с потолка».
Дальше введите стандарт команды: когда таймауты и ретраи живут в одном месте и называются одинаково, ошибок меньше.
context и уважать егоЕсли вы собираете типовые сервисы на Go с PostgreSQL, удобно один раз зафиксировать эти настройки в шаблонах и дальше переиспользовать. В TakProsto (takprosto.ai) это можно заложить на старте в генерируемой обвязке, чтобы одинаковые правила применялись во всех сервисах.
Если операция может ждать, она будет ждать вечно: внешний HTTP может зависнуть, БД может упереться в блокировку, сеть может «потерять» пакет. Такие запросы держат горутины и соединения из пула, и со временем новые запросы начинают ждать ресурсы, из‑за чего сервис выглядит «мертвым», хотя процесс жив.
Это способ передать по цепочке вызовов два сигнала: что работу нужно прекратить, и до какого момента ее можно делать. Сам по себе контекст ничего не «убивает», но дает единое правило для HTTP-клиентов, драйверов БД и вашей бизнес-логики, чтобы они вовремя завершались.
Отмена — это явный сигнал остановиться, например пользователь закрыл вкладку или сервис уходит в shutdown. Дедлайн — это ограничение по времени: «у тебя есть 200 мс, потом результат не нужен». На практике вам почти всегда нужны оба механизма, потому что они защищают ресурсы в разных сценариях.
Ставьте общий дедлайн на входе запроса (в хендлере), а более короткие таймауты — на границах, где вы ждете внешние ресурсы: HTTP к другим сервисам, запросы к БД, операции внутри транзакции, работа воркера. Так вы быстрее освобождаете пул соединений и не копите «вечные» ожидания.
Берите контекст из входящего запроса и не создавайте новый context.Background() внутри цепочки. Передавайте один и тот же ctx вниз во все функции, а для конкретных шагов делайте дочерний контекст с более коротким таймаутом. Это гарантирует, что отмена клиента или общий дедлайн остановят всю цепочку.
Обычно задают общий бюджет на весь пользовательский запрос, а под него нарезают более короткие бюджеты на шаги. Важно оставлять небольшой запас на код вокруг I/O (сериализация, логика, логирование), иначе вы будете получать таймауты «на ровном месте» из‑за накладных расходов.
Нужны два уровня защиты: таймауты у http.Client/Transport и контекст у конкретного запроса через NewRequestWithContext. Даже при корректных таймаутах всегда закрывайте resp.Body, иначе соединения будут залипать в пуле и деградация вернется в другом виде.
Используйте методы с контекстом (QueryContext, ExecContext, BeginTx с ctx) и делайте транзакции короткими. При отмене или дедлайне важно быстро завершать работу: закрывать rows, делать Rollback, не держать транзакцию открытой во время внешних HTTP-вызовов. Дополнительно полезно включить серверный предохранитель на стороне PostgreSQL, например .
Повторяйте только то, что безопасно повторять, и обязательно ограничивайте попытки и общее время. Ретраи должны уважать ctx.Done(): если дедлайн истек или запрос отменен, попытки прекращаются сразу. Для операций, которые нельзя выполнять дважды (например, списание денег), сначала обеспечьте идемпотентность, иначе ретрай создаст дубли.
Чаще всего причина в разрыве цепочки: где-то создали context.Background()/TODO() и «отрубили» дедлайны, или забыли закрыть ресурсы (resp.Body, rows, транзакцию). Вторая частая ошибка — один огромный таймаут на все без отдельных бюджетов на шаги, из‑за чего вы не видите, где именно тормозит система. Лечится дисциплиной: единые правила передачи , короткие таймауты на границах и одинаковое логирование причин ( vs ).
statement_timeoutctxdeadline exceededcanceled