ТакПростоТакПросто.ai
ЦеныДля бизнесаОбразованиеДля инвесторов
ВойтиНачать

Продукт

ЦеныДля бизнесаДля инвесторов

Ресурсы

Связаться с намиПоддержкаОбразованиеБлог

Правовая информация

Политика конфиденциальностиУсловия использованияБезопасностьПолитика допустимого использованияСообщить о нарушении
ТакПросто.ai

© 2026 ТакПросто.ai. Все права защищены.

Главная›Блог›Таймауты и отмена запросов в Go: правила для context и ретраев
28 сент. 2025 г.·6 мин

Таймауты и отмена запросов в Go: правила для context и ретраев

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

Таймауты и отмена запросов в Go: правила для context и ретраев

Почему без таймаутов сервис начинает зависать

Зависшая операция - это запрос, который не завершился и не был отменен. Он может ждать ответ внешнего 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

Так проще логировать причину и включать ретраи только там, где они уместны.

База данных: таймауты запросов и безопасные транзакции

Внедрить таймауты в клиенты
Добавьте обертки для HTTP и SQL вызовов с контекстом и понятными ошибками.
Сгенерировать код

С базой данных проблема обычно такая же: один зависший запрос держит соединение, а дальше начинает ждать весь сервис. В 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-вызовов и операций в БД

Настроить дедлайны по шагам
Разложите таймауты по слоям в Planning mode и зафиксируйте правила для команды.
Спланировать

Представьте сервис оформления заказа. Он принимает запрос, создает заказ в 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).

Содержание
Почему без таймаутов сервис начинает зависатьContext в Go простыми словами: отмена и дедлайныГде ставить таймауты: слои и границы ответственностиПошаговая схема: как протянуть context через весь запросВнешние HTTP-вызовы: таймауты и отмена без сюрпризовБаза данных: таймауты запросов и безопасные транзакцииРетраи: как повторять запросы и не устроить штормКак понять, что все работает: метрики и логи таймаутовЧастые ошибки, из-за которых отмена не срабатываетКороткий чеклист: минимальные правила на каждый сервисПример из жизни: цепочка из HTTP-вызовов и операций в БДСледующие шаги: как внедрить правила в проект без болиFAQ
Поделиться
ТакПросто.ai
Создайте свое приложение с ТакПросто сегодня!

Лучший способ понять возможности ТакПросто — попробовать самому.

Начать бесплатноЗаказать демо