Rate limiting в Go: типичные ошибки при работе с golang.org/x/time/rate
Один общий rate.Limiter на всех клиентов, блокирующий Wait без контекста, лимитер под каждый запрос — четыре ошибки, из-за которых ограничение частоты в Go либо не работает, либо роняет сервис.

Rate limiting в Go выглядит обманчиво просто: берёшь golang.org/x/time/rate, создаёшь лимитер, вызываешь Allow() — готово. Проблемы начинаются в проде: сервис пропускает всплески в десять раз выше настроенного лимита, или, наоборот, начинает возвращать 429 совершенно легитимным запросам, или горутины намертво зависают в Wait(). Разберём четыре ошибки, которые встречаются чаще всего, и цену каждой.
Материал написан для Go 1.22+ и пакета golang.org/x/time/rate (модуль golang.org/x/time, актуальная версия — сверьте в go.mod вашего проекта). Предполагается, что вы уже пишете на Go и понимаете, что такое горутина и контекст.
Как вообще работает rate.Limiter
Прежде чем ловить ошибки, зафиксируем механизм. rate.Limiter — это реализация token bucket. У него два параметра:
- r — скорость пополнения токенов (тип
rate.Limit, событий в секунду); - b — размер ведра, burst: сколько токенов можно накопить и потратить разом.
Создаётся так: rate.NewLimiter(rate.Limit(100), 200) — 100 событий в секунду при возможности всплеска до 200. Ключевая деталь, которую пропускают: burst и rate — разные вещи. Если поставить burst равным rate, вы фактически запрещаете любые всплески, а реальный трафик почти всегда неравномерный.
У лимитера три способа спросить разрешение:
Allow()— вернётtrue/falseпрямо сейчас, не блокируя;Wait(ctx)— заблокирует горутину, пока токен не появится или контекст не отменится;Reserve()— вернётReservationс временем ожидания, решение принимаете вы.
Ошибка 1. Новый лимитер на каждый запрос
Классика в HTTP-хендлере:
// ПЛОХО: лимитер создаётся заново на каждый вызов
func handler(w http.ResponseWriter, r *http.Request) {
limiter := rate.NewLimiter(rate.Limit(10), 10)
if !limiter.Allow() {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// обработка
}
Лимитер хранит состояние: сколько токенов осталось и когда пополнялись. Создавая его внутри хендлера, вы каждый раз получаете полное ведро. Ограничение не работает вообще — каждый запрос видит свежий лимитер с полным burst. В логах всё выглядит нормально, 429 не отдаётся никогда, а бэкенд за вашим сервисом ложится под нагрузкой.
Лимитер должен пережить множество запросов. Создавайте его один раз при старте:
var apiLimiter = rate.NewLimiter(rate.Limit(10), 10)
func handler(w http.ResponseWriter, r *http.Request) {
if !apiLimiter.Allow() {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// обработка
}
Ошибка 2. Один лимитер на всех вместо лимитера на клиента
Обратная крайность. Глобальный лимитер из примера выше ограничивает суммарный поток по всему сервису. Если задача — не дать одному клиенту завалить API, а мешать в одно ведро всех, то один активный клиент со всплеском выест токены, и легитимные запросы остальных получат 429.
Для ограничения per-client нужна карта лимитеров по ключу — IP, токену, идентификатору API-ключа:
import (
"sync"
"golang.org/x/time/rate"
)
type clientLimiters struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
r rate.Limit
b int
}
func (c *clientLimiters) get(key string) *rate.Limiter {
c.mu.Lock()
defer c.mu.Unlock()
lim, ok := c.limiters[key]
if !ok {
lim = rate.NewLimiter(c.r, c.b)
c.limiters[key] = lim
}
return lim
}
Здесь прячется утечка памяти: карта растёт с каждым новым ключом и никогда не чистится. Для IP-адресов это особенно опасно — их множество почти бесконечно. Нужна периодическая уборка неактивных записей: храните рядом с лимитером время последнего обращения и удаляйте по TTL из фоновой горутины. Простой map без чистки на публичном эндпоинте съест память за часы.
Глобальный лимитер защищает ваш бэкенд от суммарной перегрузки. Per-client лимитер защищает одних клиентов от других. Это разные задачи, и почти всегда вам нужны оба уровня, а не один.
Ошибка 3. Wait без контекста с таймаутом
Метод Wait блокирует до появления токена. Если передать ему context.Background(), горутина будет ждать столько, сколько потребуется — а при низком rate и высокой нагрузке это секунды и десятки секунд:
// ОПАСНО: клиент уже отвалился, а горутина всё ещё ждёт токен
func handler(w http.ResponseWriter, r *http.Request) {
if err := apiLimiter.Wait(context.Background()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// обработка
}
Под нагрузкой это даёт лавину: запросы копятся в очереди ожидания, каждый держит горутину и стек, клиенты по своему таймауту уже ушли, а сервер продолжает копить заблокированные горутины. Память и число горутин растут, пока сервис не встанет.
Всегда передавайте контекст запроса — в HTTP-хендлере это r.Context(), который отменяется, когда клиент разрывает соединение:
func handler(w http.ResponseWriter, r *http.Request) {
if err := apiLimiter.Wait(r.Context()); err != nil {
// клиент ушёл или контекст истёк — не тратим ресурсы
return
}
// обработка
}
Есть ещё тонкость: Wait вернёт ошибку сразу, если запрошенное число токенов больше burst — такое ожидание не завершится никогда, и пакет это распознаёт. Но с дефолтным WaitN(ctx, 1) эта ситуация не возникает.
Ошибка 4. Reserve без Cancel при отказе
Reserve() резервирует токен и возвращает Reservation. Токен считается потраченным в момент резерва. Если вы посмотрели на Delay(), решили не ждать и просто вернули 429 — токен всё равно списан из ведра и будет пополняться по общим правилам:
// ПЛОХО: отказали клиенту, но токен списан впустую
r := apiLimiter.Reserve()
if r.Delay() > time.Second {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return // токен потерян, ведро истощается быстрее ожидаемого
}
Если вы решили не использовать резерв, обязательно вызовите Cancel() — он вернёт токен в ведро (насколько это возможно с учётом уже прошедшего времени):
reservation := apiLimiter.Reserve()
if !reservation.OK() || reservation.Delay() > time.Second {
reservation.Cancel() // возвращаем токен
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
time.Sleep(reservation.Delay())
// обработка
Для большинства случаев Allow() и Wait() проще и безопаснее. Reserve() берите, только когда нужно самому управлять ожиданием — например, ставить задачу в свою очередь с известной задержкой.
Что выбрать под задачу
| Метод | Поведение | Когда использовать |
|---|---|---|
Allow() | Не блокирует, сразу да/нет | HTTP: отдать 429 при превышении |
Wait(ctx) | Блокирует до токена или отмены | Клиент к внешнему API, где ждать допустимо |
Reserve() | Резервирует, отдаёт задержку | Своя очередь с планированием задержек |
Простой ориентир: на входящем трафике почти всегда Allow() — ждать чужой запрос вы не обязаны, лучше быстро отказать. На исходящем трафике к чужому API, который сам вводит лимиты, — Wait(ctx), чтобы не долбить и не ловить бан, но обязательно с контекстом.
Что проверить в своём коде и в проде
- Лимитер создаётся один раз, а не в хендлере — grep по
rate.NewLimiterвнутри функций-обработчиков. - Per-client карта лимитеров имеет фоновую уборку по TTL, иначе следите за графиком памяти.
- Все
Wait()получаютr.Context()или контекст с таймаутом, а неBackground(). - Метрика: доля запросов, получивших отказ по лимиту. Если она внезапно скачет — либо DDoS, либо неверно выбранный burst.
- Метрика числа горутин. Рост под нагрузкой при использовании
Wait— сигнал ошибки 3.
Дальше стоит посмотреть на распределённый rate limiting: golang.org/x/time/rate работает в памяти одного процесса. Если у вас несколько инстансов за балансировщиком, суммарный лимит будет кратно выше настроенного на каждом. Тогда лимит выносят в общее хранилище — Redis с алгоритмом sliding window или token bucket на стороне хранилища. Это отдельная тема, здесь мы разбирали только локальный лимитер.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Package rate — golang.org/x/time/rate, официальная документация
Частые вопросы
У меня старая версия golang.org/x/time — методы отличаются?
Почему мой лимитер пропускает больше запросов, чем настроено?
Что будет, если Wait получит контекст без таймаута под большой нагрузкой?
Можно ли менять rate у лимитера на лету?
Подходит ли этот лимитер для нескольких инстансов сервиса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.