15 мар. 2025 г.·6 мин

Таймауты контекста в Go для API: от HTTP‑хендлеров до SQL

Таймауты контекста в Go помогают передавать дедлайны от HTTP‑хендлеров до SQL‑вызовов, предотвращать зависшие запросы и сохранять стабильность сервисов под нагрузкой.

Таймауты контекста в Go для API: от HTTP‑хендлеров до SQL

Почему запросы «застревают» (и почему это опасно под нагрузкой)

Запрос «застрял», когда он ждёт чего‑то, что не возвращается: медленный запрос в базу, заблокированное соединение из пула, сбой DNS или внешний сервис, который принял вызов, но не ответил.

Симптомы просты: некоторые запросы длятся бесконечно, и за ними накапливаются следующие. Часто вы увидите рост памяти, увеличение числа горутин и очередь открытых соединений, которая никак не уменьшается.

Под нагрузкой зависшие запросы вредят вдвойне. Они заняты, удерживают ограниченные ресурсы вроде соединений к базе и блокировок. Это делает обычно быстрые запросы медленнее, что создаёт больше наложений и ещё больше ожиданий.

Ретраи и всплески трафика усугубляют спираль. Клиент таймаутится и повторяет запрос, пока оригинальный всё ещё выполняется, так вы платите за два запроса. Умножьте это на множество клиентов во время кратковременной заминки — и вы можете перегрузить базу или достичь лимитов соединений, даже если средний трафик нормальный.

Таймаут — это просто обещание: «мы не будем ждать дольше X». Он помогает быстро проваливаться и освобождать ресурсы, но не заставляет работу завершиться раньше.

Он также не гарантирует мгновенной остановки работы. Например, база может продолжить выполнение, внешний сервис может игнорировать отмену, или ваш код может быть небезопасен при отмене.

Что даёт таймаут на практике: хендлер может прекратить ожидание, вернуть понятную ошибку и освободить удерживаемые ресурсы. Ограниченное время ожидания не даст нескольким медленным вызовам превратиться в полную недоступность.

Цель при работе с таймаутами контекста в Go — один общий дедлайн от края до глубочайшего вызова. Установите его один раз на границе HTTP, передавайте тот же контекст через код сервиса и используйте его в вызовах database/sql, чтобы база тоже знала, когда прекращать ожидание.

Контекст в Go простыми словами

context.Context — это небольшой объект, который вы передаёте по коду, чтобы описать текущее выполнение. Он отвечает на вопросы: "Активен ли ещё этот запрос?", "Когда нам нужно сдаться?", и "Какие служебные значения связаны с этим запросом?".

Большая выгода в том, что одно решение на краю системы (HTTP‑хендлер) может защитить каждый последующий шаг, если вы постоянно передаёте тот же контекст.

Что несёт контекст

Контекст — не место для бизнес‑данных. Он для сигналов управления и небольшого набора данных, связанных с запросом: отмена, дедлайн/таймаут и небольшая метаинформация вроде request ID для логов.

Разница между таймаутом и отменой проста: таймаут — одна из причин отмены. Если вы задали 2‑секундный таймаут, контекст будет отменён по истечении 2 секунд. Но контекст также может быть отменён досрочно, если пользователь закрыл вкладку, балансировщик разорвал соединение или код сам решил остановить запрос.

Контекст передаётся через параметры функций явно, обычно первым: func DoThing(ctx context.Context, ...). В этом и смысл: его трудно «забыть», когда он виден в каждой подписи.

Когда дедлайн истёк, всё, что следит за этим контекстом, должно быстро остановиться. Например, SQL‑запрос с QueryContext должен вернуть ошибку вроде context deadline exceeded, и ваш хендлер сможет ответить таймаутом вместо того, чтобы зависнуть до исчерпания воркеров сервера.

Хорошая ментальная модель: один запрос — один контекст, который передают везде. Если запрос умирает, работа должна умирать вместе с ним.

Устанавливаем понятный дедлайн на границе HTTP

Если вы хотите, чтобы таймауты работали end‑to‑end, решите, где начинается отсчёт времени. Самое безопасное место — прямо на HTTP‑границе, чтобы каждый последующий вызов (бизнес‑логика, SQL, внешние сервисы) унаследовал тот же дедлайн.

Его можно установить в нескольких местах. Таймауты на уровне сервера — хорошая базовая защита от медленных клиентов. Middleware отлично подходят для единообразия в наборе маршрутов. Установка внутри хендлера тоже приемлема, когда вы хотите явный и локальный контроль.

Для большинства API пер‑запросные таймауты в middleware или хендлере проще всего понимать. Делайте их реалистичными: пользователи предпочитают быстрый и понятный отказ, чем висящий запрос. Многие команды используют более короткие бюджеты для чтений (1–2 с) и чуть большие для записей (3–10 с), в зависимости от назначения эндпоинта.

Вот простой шаблон хендлера:

func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusGatewayTimeout)
        return
    }

    json.NewEncoder(w).Encode(report)
}

Две простые рекомендации поддерживают эффективность:

  • Всегда вызывайте cancel(), чтобы таймеры и ресурсы освобождались быстро.
  • Никогда не заменяйте контекст запроса на context.Background() или context.TODO() внутри хендлера. Это ломает цепочку, и вызовы к базе или внешним сервисам могут выполняться вечно, даже после того, как клиент ушёл.

Пропагирование контекста по коду

После того как вы задали дедлайн на границе HTTP, главная работа — убедиться, что тот же дедлайн доходит до каждого слоя, который может блокироваться. Идея: один часовой механизм, общий для хендлера, сервисного кода и всего, что работает с сетью или диском.

Простое правило делает всё согласованным: каждая функция, которая может ждать, должна принимать context.Context первым параметром. Это очевидно при вызовах и становится частью привычки.

Практичный паттерн сигнатур

Предпочитайте подписи вида DoThing(ctx context.Context, ...) для сервисов и репозиториев. Избегайте прятать контекст внутри структур или пересоздавать его с context.Background() в нижних слоях — это тихо обрывает дедлайн вызывающей стороны.

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
        // map context errors to a clear client response elsewhere
        http.Error(w, err.Error(), http.StatusRequestTimeout)
        return
    }
}

func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
    // parsing or validation can still respect cancellation
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    return s.repo.InsertOrder(ctx, /* data */)
}

Чистая обработка ранних выходов

Считайте ctx.Done() обычным управляющим путём. Две полезные привычки:

  • Проверяйте ctx.Err() перед началом дорогой работы и после длинных циклов.
  • Пробрасывайте ctx.Err() вверх без изменения, чтобы хендлер мог быстро ответить и не тратить ресурсы впустую.

Когда каждый слой получает тот же ctx, один таймаут может одновременно прекратить парсинг, бизнес‑логику и ожидание в базе.

Применение дедлайнов к database/sql

Сделайте ретраи безопаснее под нагрузкой
Стройте эндпоинты, которые быстро падают и освобождают ресурсы при соблюдённых дедлайнах.
Создать API

Как только ваш HTTP‑хендлер выставил дедлайн, убедитесь, что работа с базой действительно его слушает. Для database/sql это означает использование методов с контекстом везде. Если вы вызываете Query() или Exec() без контекста, ваш API может продолжать ждать медленный запрос даже после того, как клиент сдался.

Используйте последовательно: db.QueryContext, db.QueryRowContext, db.ExecContext и db.PrepareContext (а затем QueryContext/ExecContext у возвращённого statement).

func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
	row := s.db.QueryRowContext(ctx,
		`SELECT id, email FROM users WHERE id = $1`, id,
	)
	var u User
	if err := row.Scan(&u.ID, &u.Email); err != nil {
		return nil, err
	}
	return &u, nil
}

func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE users SET email = $1 WHERE id = $2`, email, id,
	)
	return err
}

Два момента легко пропустить.

Во‑первых, ваш SQL‑драйвер должен поддерживать отмену по контексту. Многие поддерживают, но проверьте свою стэк тестированием намеренно медленного запроса и убедитесь, что он быстро прерывается при истечении дедлайна.

Во‑вторых, подумайте о таймауте на стороне базы как о предохранителе. Например, Postgres умеет накладывать statement timeout. Это защитит базу, даже если где‑то в приложении забыли передать ctx.

Когда операция остановилась из‑за таймаута, обрабатывайте это отдельно от обычной SQL‑ошибки. Проверяйте errors.Is(err, context.DeadlineExceeded) и errors.Is(err, context.Canceled) и возвращайте понятный ответ (например, 504), а не трактуйте это как «база упала». Если вы генерируете Go‑бэкенды (например, с AppMaster), разделение этих путей ошибок упрощает логику и ретраи.

Внешние вызовы: HTTP‑клиенты, кеши и другие сервисы

Даже если хендлер и SQL‑вызовы уважают контекст, запрос всё ещё может висеть, если внешний вызов ждёт бесконечно. Под нагрузкой несколько зависших горутин накапливаются, съедают пулы соединений и превращают небольшую задержку в полный outage. Решение — единообразная передача контекста и жёсткий запасной предел.

Исходящие HTTP

При вызове другого API создавайте запрос через тот же контекст, чтобы дедлайн и отмена автоматически пошли дальше.

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)

Не полагайтесь только на контекст. Также настройте http.Client и транспорт, чтобы вы были защищены, если где‑то случайно используют background‑контекст, или если DNS/TLS/idle‑соединения зависают. Задайте http.Client.Timeout как верхний предел для всего вызова, установите таймауты транспорта (dial, TLS handshake, response header) и переиспользуйте один клиент вместо создания нового на каждый запрос.

Кеши и очереди

Кеши, брокеры сообщений и RPC‑клиенты часто имеют собственные точки ожидания: получение соединения, ожидание ответа, блокировка на заполненной очереди или ожидание блокировки. Убедитесь, что эти операции принимают ctx, и используйте таймауты на уровне библиотеки, где это возможно.

Практическое правило: если у вас осталось 800 мс, не начинайте внешний вызов, который может занять 2 с. Пропустите его, деградируйте поведение или верните частичный ответ.

Решите заранее, что значит таймаут для вашего API. Иногда правильный ответ — быстрый отказ. Иногда — частичные данные для необязательных полей. Иногда — устаревшие данные из кеша с явной пометкой.

Если вы строите Go‑бэкенды (включая генерируемые, как в AppMaster), это разница между «таймауты есть» и «таймауты последовательно защищают систему» при всплесках трафика.

Пошагово: рефакторим API под end‑to‑end таймауты

Создавайте мобильные приложения с вашим API
Делайте нативные iOS и Android‑приложения, которые общаются с бэкендом с предсказуемыми таймаутами.
Создать мобильное приложение

Рефакторинг ради таймаутов сводится к одной привычке: передавайте один и тот же context.Context от HTTP‑границы до каждого вызова, который может блокироваться.

Практичный порядок работы сверху вниз:

  • Измените хендлеры и ключевые методы сервиса, чтобы они принимали ctx context.Context.
  • Обновите все вызовы к БД на QueryContext/ExecContext.
  • Сделайте то же самое для внешних вызовов (HTTP, кеши, очереди). Если библиотека не принимает ctx, оберните её или замените.
  • Решите, кто владеет таймаутами. Частое правило: хендлер устанавливает общий дедлайн; нижние уровни лишь сужают его при необходимости.
  • Делайте ошибки предсказуемыми на краю: мапьте context.DeadlineExceeded и context.Canceled в понятные HTTP‑ответы.

Вот желаемая форма через слои:

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    _ = json.NewEncoder(w).Encode(order)
}

func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
    row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
    // scan...
}

Значения таймаутов должны быть скучными и согласованными. Если у хендлера 2 секунды всего, держите DB‑запросы менее 1 секунды, чтобы оставить место для кодирования JSON и прочей работы.

Чтобы доказать, что всё работает, добавьте тест, который форсирует таймаут. Один простой подход — фейковый репозиторий, метод которого блокируется до ctx.Done() и затем возвращает ctx.Err(). Тест должен утверждать, что хендлер быстро возвращает 504, а не после долгой задержки.

Если вы используете генератор для Go‑бэкендов (например, AppMaster генерирует Go‑сервисы), правило то же: один контекст запроса, просквозь все слои, с ясным владением дедлайном.

Наблюдаемость: как доказать, что таймауты работают

Создавайте API с понятными таймаутами
Создайте бэкенд на Go в AppMaster и сохраняйте дедлайны от хендлера до SQL.
Попробовать AppMaster

Таймауты помогают только если вы их видите. Цель проста: у каждого запроса есть дедлайн, и при провале видно, куда ушло время.

Начните с логов, которые безопасны и полезны. Вместо дампа тела запроса логируйте достаточно, чтобы связать события и найти медленные места: request ID (или trace ID), установлен ли дедлайн и сколько времени осталось в ключевых точках, имя операции (хендлер, имя SQL‑запроса, исходящий вызов), и категория результата (ok, timeout, canceled, другая ошибка).

Добавьте несколько целевых метрик, чтобы поведение под нагрузкой было очевидным:

  • Количество таймаутов по endpoint и зависимостям
  • Задержки запросов (p50/p95/p99)
  • В‑flight запросы
  • Задержки SQL‑запросов (p95/p99)
  • Ошибки по типам

При обработке ошибок ставьте правильные теги. context.DeadlineExceeded обычно означает, что вы исчерпали бюджет. context.Canceled часто означает, что клиент ушёл или сначала сработал внешний таймаут. Разделяйте их, потому что методы их устранения разные.

Трейсинг: находите узкие места

Спаны трассировки должны следовать тому же контексту от HTTP‑хендлера в database/sql‑вызовы вроде QueryContext. Например, запрос таймаутится на 2 с, и трейс показывает 1.8 с ожидания соединения к БД — это указывает на размер пула или медленные транзакции, а не на текст запроса.

Если вы строите внутренние дашборды (таймауты по роутам, медленные запросы), безкодовые инструменты вроде AppMaster помогут быстро это запустить, не делая наблюдаемость отдельным проектом.

Частые ошибки, которые лишают смысла таймауты

Большинство багов «иногда всё ещё висит» происходят из нескольких мелких ошибок.

  • Сброс часов в середине выполнения. Хендлер ставит 2с дедлайн, но репозиторий создаёт новый контекст с собственным таймаутом (или без него). Теперь база может продолжать выполняться после ухода клиента. Передавайте входящий ctx и ужесточайте дедлайн только при явной необходимости.
  • Запуск горутин, которые никогда не останавливаются. Если вы запускаете работу с context.Background() (или вообще без контекста), она будет выполняться даже после отмены запроса. Передавайте request‑ctx в горутины и делайте select на ctx.Done().
  • Слишком короткие дедлайны для реального трафика. Таймаут в 50 мс может работать на ноутбуке, но провалиться в проде при небольшом всплеске, вызвав ретраи, рост нагрузки и мини‑аутейдж. Выбирайте таймауты по реальной латентности с запасом.
  • Сокрытие реальной ошибки. Превращая context.DeadlineExceeded в общий 500, вы усложняете отладку и поведение клиентов. Мапьте его в понятный ответ и логируйте разницу между "отменено клиентом" и "истёк дедлайн".
  • Оставление ресурсов открытыми при ранних выходах. Если вы уходите раньше, не забудьте defer rows.Close() и вызвать cancel() для context.WithTimeout. Утечки rows или висящая работа могут исчерпать соединения под нагрузкой.

Простой пример: эндпоинт запускает тяжёлый отчёт. Если пользователь закрыл вкладку, контекст хендлера отменяется. Если ваш SQL‑вызов использовал новый background‑контекст, запрос всё ещё выполняется, занимая соединение и замедляя всех. При передаче того же ctx в QueryContext вызов прерывается, и система быстрее восстанавливается.

Быстрый чеклист для надёжных таймаутов

Начать с рабочего шаблона
Запускайте внутренний инструмент или API и быстро итерайте по мере изменения требований.
Начать

Таймауты помогают, только если они последовательны. Одна пропущенная передача контекста может удержать горутину, съесть соединение к БД и замедлить следующие запросы.

  • Установите один понятный дедлайн на краю (обычно в HTTP‑хендлере). Всё внутри запроса должно его наследовать.
  • Передавайте один и тот же ctx через сервис и репозитории. Избегайте context.Background() в коде запросов.
  • Используйте методы БД с контекстом везде: QueryContext, QueryRowContext, ExecContext.
  • Присоединяйте тот же ctx к исходящим вызовам (HTTP, кеши, очереди). Если создаёте дочерний контекст, делайте его короче, а не длиннее.
  • Обрабатывайте отмены и таймауты последовательно: возвращайте чистую ошибку, останавливайте работу и избегайте внутренних циклов ретраев в отменённом запросе.

После этого проверьте поведение под давлением. Таймаут, который срабатывает, но не освобождает ресурсы быстро, всё ещё вреден.

Дашборды должны делать таймауты очевидными, а не терять их в усреднённой метрике. Отслеживайте: таймауты запросов и таймауты БД отдельно, перцентильные задержки (p95, p99), статистику пула БД (in‑use connections, wait count, wait duration) и разбиение причин ошибок (context deadline exceeded vs другие).

Если вы строите внутренние инструменты на платформе вроде AppMaster, то те же правила применимы к любым Go‑сервисам, которые вы к ней подключаете: определите дедлайны на границе, пропагируйте их и подтвердите метриками, что «застрявшие» запросы стали быстрыми отказами, а не медленными накапливающимися задачами.

Сценарий и следующие шаги

Частое место выгоды — эндпоинт поиска. Представьте GET /search?q=printer, который замедляется, когда база занята тяжёлым отчётом. Без дедлайна каждый входящий запрос может сидеть, ожидая долгого SQL‑запроса. Под нагрузкой такие зависшие запросы накапливаются, занимают горутины и соединения, и API кажется замороженным.

С понятным дедлайном в хендлере и тем же ctx, переданным в репозиторий, система перестаёт ждать по истечении бюджета. Когда дедлайн наступает, драйвер базы отменяет запрос (если поддерживается), хендлер возвращает ответ, и сервер может продолжать обслуживать новые запросы вместо бесконечного ожидания.

Пользовательский опыт лучше даже при ошибках. Вместо 30–120 секунд крутящегося запроса и хитрой ошибки, клиент получает быстрый предсказуемый отказ (часто 504 или 503 с кратким сообщением вроде "request timed out"). Важнее, система быстрее восстанавливается, потому что новые запросы не блокируются старыми.

Следующие шаги, чтобы это закрепить по всем эндпоинтам и командам:

  • Выберите стандартные таймауты по типам эндпоинтов (поиск vs записи vs экспорт).
  • Требуйте QueryContext и ExecContext в code review.
  • Делайте ошибки таймаута явными на краю (понятный статус и простое сообщение).
  • Добавьте метрики по таймаутам и отменам, чтобы замечать регрессии.
  • Напишите один helper для создания контекстов и логирования, чтобы все хендлеры вели себя одинаково.

Если вы строите сервисы и внутренние инструменты с AppMaster, вы можете применять эти правила таймаутов последовательно для генерируемых Go‑бэкендов, интеграций API и дашбордов в одном месте. AppMaster доступен на appmaster.io (no-code, с генерацией реального исходного кода на Go), поэтому это может быть практичным решением, когда вы хотите согласованную обработку запросов и наблюдаемость без ручной сборки каждой админки.

Вопросы и ответы

Что значит, что запрос «застрял» в Go API?

Запрос «зависает», когда он ждёт чего‑то, что не отвечает: медленный SQL‑запрос, заблокированное соединение из пула, проблемы с DNS или внешний сервис, который принимает вызов, но не отвечает. Под нагрузкой такие зависшие запросы накапливаются, занимают воркеры и соединения, и небольшая задержка превращается в широкую недоступность.

Где задавать таймаут: в middleware, в хендлере или глубже в коде?

Устанавливайте общий дедлайн на границе HTTP и передавайте тот же ctx в каждый слой, который может блокироваться. Именно общий дедлайн предотвращает ситуацию, когда несколько медленных операций удерживают ресурсы достаточно долго, чтобы вызвать лавинообразное увеличение задержек.

Зачем вызывать `cancel()`, если таймаут всё равно сработает?

Используйте ctx, cancel := context.WithTimeout(r.Context(), d) и всегда defer cancel() в хендлере (или middleware). Вызов cancel освобождает таймеры и помогает быстрее прекратить ожидание, если запрос закончился раньше.

Какая самая большая ошибка, из‑за которой таймауты перестают работать?

Не заменяйте контекст на context.Background() или context.TODO() в коде запроса — это ломает отмену и дедлайны. Если вы теряете контекст запроса, дальнейшая работа (SQL, исходящие HTTP‑запросы) может продолжить выполняться даже после того, как клиент отключился.

Как отличать и обрабатывать `context deadline exceeded` и `context canceled`?

context.DeadlineExceeded и context.Canceled — это нормальные исходы управления потоком; пробрасывайте их вверх без изменения. На краю карты обрабатывайте их отдельно и выдавайте понятные ответы (часто 504 для таймаутов), чтобы клиенты не начинали слепо ретраить на случайные 500.

Какие вызовы `database/sql` должны использовать контекст?

Используйте методы с контекстом везде: QueryContext, QueryRowContext, ExecContext, PrepareContext. Если вы вызываете Query() или Exec() без контекста, хендлер может истечь по таймауту, но вызов к базе всё ещё будет блокировать горутину и держать соединение.

Отменяет ли контекст реально выполняющийся PostgreSQL‑запрос?

Многие драйверы поддерживают отмену PostgreSQL‑запросов по контексту, но обязательно проверьте это в вашей стеке, запустив намеренно медленный запрос и удостоверившись, что он быстро прерывается после истечения дедлайна. Также разумно иметь серверный statement_timeout в Postgres как страховку на случай пропущенного ctx.

Как применить тот же дедлайн к исходящим HTTP‑вызовам?

Создавайте исходящие HTTP‑запросы через http.NewRequestWithContext(ctx, ...), чтобы тот же дедлайн и отмена распространялись автоматически. Дополнительно настройте http.Client.Timeout и таймауты транспорта (dial, TLS handshake, response header) как жёсткий верхний предел, потому что контексты не защитят вас от пониженного уровня зависаний или если где‑то случайно используют context.Background().

Должны ли нижние уровни (repo/services) сами задавать свои таймауты?

Не создавайте в нижних слоях новые контексты, которые удлиняют бюджет запроса; дочерние таймауты должны быть короче, а не длиннее. Если у запроса осталось мало времени, пропустите необязательные внешние вызовы, верните частичный ответ или быстро упадите с понятной ошибкой.

Что стоит мониторить, чтобы доказать, что end‑to‑end таймауты работают?

Отслеживайте таймауты и отмены по endpoint’ам и зависимостям отдельно, а также перцентильные задержки и количество одновременных запросов. В трассировках используйте один и тот же контекст от хендлера до QueryContext, чтобы видеть, где ушло время: ожидание соединения к БД, выполнение запроса или ожидание внешнего сервиса.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster