Содержание урока · 14 разделов
- fiber rate limit: модель «ключ — окно — потолок»
- Анатомия Config: от Max до DisableValueRedaction
- Конфиг limiter.Config целиком
- FixedWindow: алгоритм дефолта по строкам исходника
- SlidingWindow: как Fiber взвешивает прошлое окно
- 429 и Retry-After: контракт с клиентом по буквам
- Keying: кто считывается — того и ограничивают
- Распределённый лимит: Storage и Redis
- Обзор алгоритмов: fixed, sliding log, token bucket
- Чек-лист продакшн-лимитера
- Прогон: curl-сценарии и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
fiber rate limit: модель «ключ — окно — потолок»#
Любой rate limiter в мире описывается тремя сущностями, и лимитер fiber не исключение. Первая — ключ: на кого считается лимит. На один IP, на один API-ключ, на одну сессию, на пару IP и логина — выбор ключа и есть главный дизайн-решение, всё остальное механика. Вторая — окно: отрезок времени, на котором ведётся счёт, в limiter это поле Expiration. Третья — потолок: сколько запросов с одним ключом разрешено в окне, поле Max. Всё, что делает middleware, умещается в одну фразу: по ключу достать счётчик, увеличить, сравнить с потолком и решить — пустить запрос дальше по стеку или ответить 429.
В цепочке слой ничем не выделяется: limiter.New(config ...Config) возвращает fiber.Handler — ту же фабрику из седьмого урока в промышленном исполнении, с конфигом-структурой и ConfigDefault, как у cors и logger из шестого. Его можно повесить третьим аргументом на один маршрут, на группу app.Group("/api", limiter.New(...)) или на весь корень через Use. Единственное содержательное отличие от стража requireAPIKey в том, что лимитеру нужно хранить состояние между запросами: где-то должна жить та самая считалка по ключам. По умолчанию это память процесса — внутренний storage Fiber; для распределённого лимита на несколько инстансов состояние выносится в Storage, и там открывается дорога Redis — к этому придём отдельным блоком.
Одна оговорка честности сразу, чтобы дальше не возвращаться. Ограничение частоты — защита прикладного уровня: она лечит брутфорс по конкретному эндпоинту, назойливый скрейпинг и случайные петли в чужих клиентах. Масштабную DDoS-атаку с десятков тысяч адресов лимитер на прикладных ключах не остановит — на таких объёмах запросы должен отсекать периметр сети или CDN, а не Go-процесс. Ограничитель — про гигиену и обходительность, про то, чтобы один клиент не съедал общий ресурс; он же первым сообщает вежливому клиенту, как вести себя дальше, — заголовком Retry-After.
Канонический минимум: слой на API-ветку
GOpackage main
import (
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
// Task — форма данных из девятого урока
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
func main() {
app := fiber.New()
// слой лимитера на всю API-ветку: 100 запросов в минуту на ключ
api := app.Group("/api", limiter.New(limiter.Config{
Max: 100, // потолок запросов в окне
Expiration: 1 * time.Minute, // окно — фиксированная минута
}))
// обычный маршрут: он ничего не знает о лимитере
api.Get("/tasks", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": []Task{
{ID: 1, Title: "починить шлюз"},
}})
})
_ = app.Listen(":8080") // http://localhost:8080/api/tasks
}Пакет живёт в ядре: github.com/gofiber/fiber/v3/middleware/limiter, отдельный go get не нужен — зависимость уже есть вместе с модулем fiber/v3. Регистрация через второй аргумент app.Group — тот же рисунок, что и с общими слоями версий API из пятнадцатого урока: слой встаёт на всю группу, и каждый маршрут ветки, включая будущие, попадает под лимит. Двух полей достаточно: Max — потолок, Expiration — длина окна. Всё остальное конфиг добирает из ConfigDefault, и вот что важно прочувствовать заранее: дефолтов у limiter много, и некоторые из них неочевидны — какой алгоритм выбран по умолчанию, что происходит при Max: 0 и кто такой KeyGenerator. Этим и займёмся.
Проверьте себя: дефолты limiter.New()
Коллега написал limiter.New() без аргументов на весь корень приложения и уверяет: «по умолчанию Fiber ставит скользящее окно — так честнее». Что отвечает исходник config.go?
Анатомия Config: от Max до DisableValueRedaction#
Структура limiter.Config в config.go — двенадцать полей, и читать её стоит не по алфавиту, а по жизненному циклу запроса. Потолок: Max — «Max number of recent connections during Expiration seconds before sending a 429 response» с дефолтом 5 — и MaxFunc, генератор потолка на конкретный запрос, полезный, когда тариф зависит от клиента: бесплатному ключу — 10 в минуту, платному — 1000. Окно: Expiration — «the time on how long to keep records of requests in memory», дефолт 1 * time.Minute, и ExpirationFunc — окно на конкретный запрос. Ключ: KeyGenerator — «allows you to generate custom keys, by default c.IP() is used». Ответ при исчерпании: LimitReached — «is called when a request hits the limit», дефолт — c.SendStatus(fiber.StatusTooManyRequests), голый 429 без тела.
Дальше — служебное, но от того не менее важное. Next — пропустить слой без счёта, стандартное поле всех middleware Fiber. SkipFailedRequests — «requests with StatusCode >= 400 won't be counted» — и SkipSuccessfulRequests — «requests with StatusCode < 400 won't be counted»: два флага, позволяющие считать только успешные или только провальные запросы; именно на них строится лимит брутфорс-защиты. DisableHeaders убирает из ответа и X-RateLimit-*, и Retry-After. DisableValueRedaction — любопытная новинка v3: по умолчанию middleware маскирует ключи в логах и сообщениях об ошибках словом [redacted], чтобы IP-адреса и API-ключи не утекали в журналы; флаг выключает маскировку. Storage — внешнее хранилище состояния, дефолт — «an in memory store for this process only».
И два генератора ведут себя хитрее, чем кажутся. В configDefault нормализация такая: cfg.Max <= 0 заменяется на 5, cfg.Expiration <= 0 — на минуту, и только потом MaxFunc и ExpirationFunc, если их не задали, получают замыкания на уже выправленные значения. Отсюда асимметрия, которую стоит запомнить до интерактива ниже: написать в конфиге Max: 0 — значит получить 5, поле нормализуется; а написать собственную MaxFunc, возвращающую ноль, — значит честно отключить слой, потому что в хендлере прописано «Don't execute middleware if Next returns true or if the max is 0» — нулевой потолок пропускает запрос без всякого счёта.
Конфиг limiter.Config целиком#
| Поле | Дефолт | Смысл и подводный камень |
|---|---|---|
| Max | 5 (defaultLimiterMax) | потолок запросов в окне; значение ≤ 0 configDefault заменяет на 5 — выключить слой этим полем нельзя |
| Expiration | 1 * time.Minute | длина окна; ≤ 0 — дефолт; суб-секундное окно поллится до целой секунды |
| MaxFunc | замыкание на cfg.Max | потолок на конкретный запрос; вернёт 0 — слой пропустит запрос без счёта |
| ExpirationFunc | замыкание на cfg.Expiration | окно на конкретный запрос: тарификация по клиенту |
| KeyGenerator | func(c) { return c.IP() } | ключ бакета; свой ключ — своя считалка; о подделываемом IP см. блок keying |
| LimitReached | c.SendStatus(fiber.StatusTooManyRequests) | ответ при исчерпании лимита; Retry-After к этому моменту уже поставлен |
| Storage | nil — память процесса | внешнее хранилище для распределённого лимита: Redis и другие |
| LimiterMiddleware | FixedWindow{} | стратегия; вторая готовая — SlidingWindow{}; интерфейс Handler из limiter.go |
| Next | nil | пропустить слой без счёта — стандарт всех middleware Fiber |
| SkipFailedRequests | false | не считать ответы со статусом ≥ 400; статус берётся с учётом *fiber.Error |
| SkipSuccessfulRequests | false | не считать ответы со статусом < 400; основа брутфорс-лимита на /api/login |
| DisableHeaders | false | убрать X-RateLimit-Limit/Remaining/Reset и Retry-After из ответов |
| DisableValueRedaction | false | выключить маскировку ключей в логах: иначе в журнале [redacted], а не IP |
FixedWindow: алгоритм дефолта по строкам исходника#
Ответ на вопрос «фиксированное окно или скользящее?» ищем в limiter_fixed.go, и начинается он с честного объявления: FixedWindow implements a fixed-window rate limiting strategy. Состояние — структура item из manager.go с тремя полями: currHits (счётчик текущего окна), prevHits (счётчик предыдущего — нужен только скользящему алгоритму) и exp (момент истечения окна в целых секундах). Хендлер FixedWindow.New работает так. MaxFunc даёт maxRequests — комментарий исходника напоминает: «if no generator was provided the default value returned is 5». KeyGenerator даёт ключ. Под мьютексом из бакета достаётся item, и дальше два ветвления: если e.exp == 0 — бакет новый, ставим e.exp = ts + expiration; если ts >= e.exp — окно истекло, счётчик обнуляется и окно продлевается: e.currHits = 0; e.exp = ts + expiration. Затем e.currHits++, remaining := maxRequests - e.currHits и manager.set — счётчик сохраняется обратно с TTL окна.
Дальше развилка, ради которой всё затевалось: if remaining < 0 — лимит исчерпан, и слой делает две вещи: ставит заголовок Retry-After со значением resetInSec (сколько секунд до сброса окна) и возвращает cfg.LimitReached(c) — дефолтно голый 429. Обратите внимание, чего на этом пути НЕТ: вызова c.Next(). Запрос не доходит до хендлера вовсе, и заголовки X-RateLimit-* на отказ тоже не ставятся — они выставляются позже, в ветке успеха, после c.Next(). А в комментарии про зажатие remaining спрятан честный факт: blocked requests persist their increment — заблокированные запросы продолжают увеличивать счётчик, окно с торчащими в него отказами сбросится тем же exp, что и без них.
Два технических факта про время. Первое: все вычисления идут в целых секундах, и функция windowSeconds поясняет, что положительное суб-секундное окно поллится до одной секунды — «a positive value below one second is floored to one second», а нулевое и отрицательное падает к дефолту. Второе: текущая секунда берётся не из time.Now() на каждый запрос, а из кэша utils.Timestamp(), который обновляет фоновый тикер раз в секунду — utils.StartTimeStampUpdater() вызывается при создании хендлера; комментарий currentSecond объясняет это желанием держать горячий путь «allocation- and syscall-free». Побочный эффект: граница окна может сдвинуться на секунду от заявленной, для лимитера это несущественно, но знать механику полезно.
Прогноз: чем кончится MaxFunc с нулём
Прочитайте код и выберите, что он выведет:
// тарифный план «без лимита»: потолок из внешнего конфига
planMax := 0
app.Use("/api", limiter.New(limiter.Config{
MaxFunc: func(c fiber.Ctx) int {
return planMax
},
Expiration: 1 * time.Minute,
}))
// 10-й запрос за минуту на /api/tasksSlidingWindow: как Fiber взвешивает прошлое окно#
Стратегия SlidingWindow из limiter_sliding.go объявляет себя так же честно — SlidingWindow implements the sliding-window rate limiting strategy, — но это не «журнал последних запросов» (sliding log), а взвешенная пара фиксированных окон, классический sliding window counter. У item есть второй счётчик prevHits, и функция rotateWindow перекладывает историю на границе окон: если ts >= e.exp, то elapsed := ts - e.exp; когда elapsed >= expiration — прошлое окно стало совсем древним, оба счётчика в ноль и окно заново; иначе — prevHits = e.currHits, currHits = 0, exp += expiration: текущее окно стало предыдущим.
Смысл стратегии — в двух строках с комментариями исходника: weight = time until current window reset / total window length — доля ещё не истёкшего текущего окна; и rate = request count in previous window - weight + request count in current window, что в коде превращается в rate := int(math.Ceil(float64(e.prevHits)*weight)) + e.currHits. Идея: разрешённая нагрузка оценивается не только по текущему окну, но и по хвосту предыдущего, взвешенному по тому, насколько он ещё «нависает» над настоящим моментом. Наплыв из прошлого окна делает rate выше, и потолок срабатывает раньше, чем в фиксированном окне — знаменитый всплеск 2×Max на стыке окон у fixed здесь сглаживается: клиент, выстреливший Max запросами в последнюю секунду окна и ещё Max в первую секунду следующего, во втором окне упрётся в rate с весом почти единица.
И одна тонкость, которую легко спалить на нагрузочном тесте: TTL, с которым item сохраняется в storage, у sliding-стратегии не равен длине окна. Исходник ставит ttlDuration(resetInSec, expiration) — resetInSec + expiration — и ASCII-рисунок в комментарии объясняет: «Because we don't want to garbage collect in the middle of a window we add the expiration to the duration. Otherwise, after the end of "sample window", attackers could launch a new request with the full window length». Запись должна пережить и текущее, и следующее окно, потому что prevHits понадобится при оценке rate; сборка мусора раньше — и атакующий стартует с чистого листа в полный лимит.
Проверьте себя: всплеск на стыке окон
Max = 100, окно 60 секунд. Клиент выстрелил 100 запросами в последнюю секунду минуты и ещё 100 в первую секунду следующей. Какая стратегия и почему пропустит второй залп?
429 и Retry-After: контракт с клиентом по буквам#
Код отказа в этой истории — 429 Too Many Requests, и его паспорт лежит в constants.go ядра: StatusTooManyRequests = 429 с комментарием «RFC 6585, 4» — четвёртая секция «Additional HTTP Status Codes», где код и определён; рядом живёт готовая ошибка ErrTooManyRequests = NewError(StatusTooManyRequests). Дефолтный LimitReached отвечает именно c.SendStatus(fiber.StatusTooManyRequests): голый статус без тела — лаконично, но клиенту вашего API, возможно, привычнее конверт {error, code} из четырнадцатого урока, и LimitReached — то самое место, где его подставляют руками.
Заголовок Retry-After — вторая половина контракта: клиент читает секунды и знает, когда можно вернуться, не долбя вслепую. Ставится он в обеих стратегиях одинаково — до вызова LimitReached: c.Set(fiber.HeaderRetryAfter, utils.FormatUint(resetInSec)), где resetInSec = e.exp - ts, целое число секунд до сброса окна; HTTP разрешает в этом заголовке и дату, но limiter шлёт именно delay-seconds. И вот анекдотический факт, добытый чтением исходников, а не байкой: комментарий над этой строчкой в limiter_fixed.go и limiter_sliding.go отсылает к «https://tools.ietf.org/html/rfc6584» — это давняя опечатка Fiber, тянущаяся ещё из монолитного limiter.go ранних v2: RFC 6584 называется «Simple Authentication for ALC and NORM» и к частоте запросов отношения не имеет. Правильные документы — RFC 6585 для 429 и RFC 7231 для семантики Retry-After; что забавно, constants.go ядра ссылается на 6585-й верно. Урок здесь простой: даже отличный код проверяется по первоисточникам, включая ссылки в комментариях.
Отдельно зафиксируем распределение заголовков, на котором сгорают готовые интеграционные тесты. На отказе (remaining < 0) клиент получает только Retry-After — заголовки X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset ставятся позже, в ветке успеха, после возврата c.Next(); на 429 их не будет. На успехе — наоборот: три X-RateLimit- и никакого Retry-After. DisableHeaders: true убирает и то, и другое. Наконец, флаги SkipFailedRequests и SkipSuccessfulRequests определяют итоговый статус честно: getEffectiveStatusCode из limiter.go берёт код из ошибки fiber.Error, если она вернулась, иначе статус ответа — так что fiber.NewError из глубины хендлера учитывается при решении «считать ли запрос».
Прогноз: что вернёт шестой запрос в окне
Прочитайте код и выберите, что он выведет:
// Max: 5, Expiration: 1 * time.Minute, всё остальное — дефолты
// окно только открылось, клиент уже сделал 5 успешных запросов
// на GET /api/tasks. Теперь шестой:Keying: кто считывается — того и ограничивают#
Ключ — самое недооценённое решение в rate limiting, и лимитер fiber даёт нам честный материал для разговора. Дефолт: KeyGenerator возвращает c.IP() — считалка на адрес клиента. Для внутреннего API за reverse-proxy с известными адресами это нормально. Но manager.go прямо предупреждает о подводном камне: с app.Config.TrustProxy и ProxyHeader настроенными, а EnableIPValidation оставленным по умолчанию выключенным, c.IP() — это «the raw ProxyHeader value verbatim: an attacker-controlled string with no length limit». Переведём: клиент, шлющий заголовок X-Forwarded-For с новым выдуманным адресом на каждый запрос, получает новый ключ на каждый запрос — и ограничение обходит целиком, попутно раздувая хранилище бакетов. Вывод-инструкция: если верите прокси-заголовкам — включайте валидацию IP и перечисляйте доверенные прокси; если не верите — стройте ключ из того, что клиент подделать не может, из аутентифицированной идентичности.
Вторая защита в manager.go — от раздувания хранилища: константа maxKeyLength = 192. Ключ длиннее — заменяется на «sha256:» + hex-дайджест SHA-256 от всей строки. И выбор именно хэша, а не обрезки, объяснён в комментарии дословно: «Hashing rather than truncating or rejecting keeps distinct oversized inputs mapped to distinct buckets and keeps the request rate limited rather than either exempted or itself denied» — разные длинные ключи попадают в разные бакеты и остаются ограниченными, а не освобождёнными от лимита или заблокированными навечно. Туда же относится DisableValueRedaction из config.go: по умолчанию ключи в логах и сообщениях об ошибках маскируются — вместо IP в журнале будет redactedKey = "[redacted]".
Практический keying для API — по аутентифицированному ключу: KeyGenerator: func(c fiber.Ctx) string { return c.Get("X-API-Key") }. Каждый ключ — свой бакет: платный тариф и бесплатный не делят потолок, а долбящий один и тот же ключ бот не растянет лимит на соседей. Это прямое продолжение requireAPIKey из седьмого урока: страж проверяет ключ и пускает дальше с 401 на чужих, лимитер считает по тому же заголовку и отвечает 429 при исчерпании. Порядок слоёв на маршруте — лимитер раньше стража: исчерпанный клиент должен получать 429 до всякой проверки ключа, а не расходовать ресурсы валидации.
Для брутфорса на /api/login обе крайности плохи: ключ по IP наказывает офис за одного провинившегося, ключ по логину позволяет ботнету перебирать по одному логину с каждого адреса. Рабочий компромисс — составной ключ: пара IP и переданного логина, и для форм пригодится c.FormValue("email") прямо внутри KeyGenerator. Окно на такие случаи берут длинное — минуты, а не секунды.
Практика: лимит на /api/login против брутфорса
GOpackage main
import (
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
)
// loginLimiter — страж /api/login: считаем только неудачные попытки
var loginLimiter = limiter.New(limiter.Config{
Max: 5, // пять попыток...
Expiration: 15 * time.Minute, // ...за четверть часа на ключ
KeyGenerator: func(c fiber.Ctx) string {
// свой бакет на адрес клиента; для сервиса логинов
// честнее составной ключ: c.IP() + логин из формы
return c.IP()
},
// успешный вход (200) не расходует лимит: считаем только промахи
SkipSuccessfulRequests: true,
// конверт ошибок из четырнадцатого урока вместо голого статуса
LimitReached: func(c fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "слишком много попыток входа",
"code": fiber.StatusTooManyRequests,
})
},
})
func main() {
app := fiber.New()
app.Post("/api/login", loginLimiter, func(c fiber.Ctx) error {
// проверка пары логин/пароль: успех — 200, провал — 401
return c.SendStatus(fiber.StatusUnauthorized)
})
_ = app.Listen(":8080")
}Три решения в этом конфиге — это три абзаца урока, собранные в практику. SkipSuccessfulRequests: true — запросы со статусом < 400 не считаются: успешный вход не портит клиенту лимит, а счётчик растёт только на 401 от неверной пары. Ключ — c.IP() как основа: для настоящего сервиса логинов составьте пару с логином из формы, как разбирали в блоке keying. LimitReached возвращает JSON-конверт {error, code} — и здесь есть тонкость, которую легко проверить curl-ом: Retry-After ставится ДО вызова LimitReached, поэтому ваш JSON-ответ уйдёт к клиенту с этим заголовком — интерфейс честный, а не только статус. Пятая неудачная попытка в окне вернёт 429, и шестая тоже — помните про blocked requests persist their increment: отказы пишутся в счётчик, окно живёт свои 15 минут.
Проверьте себя: какой флаг для брутфорса
На /api/login настроен лимитер. Требование: попытки входа с верной парой не должны расходовать лимит — считается только перебор. Какое поле конфига и почему?
Распределённый лимит: Storage и Redis#
До сих пор считалка жила в памяти процесса — и на одном инстансе это честно работает. Но как только сервис размножается за балансировщиком, возникает классическая ловушка: Max: 100 на инстанс при трёх инстансах — это до 300 запросов в минуту с клиента, и каждый перезапуск обнуляет счётчики. Решение прописано в конфиге полем Storage — «Store is used to store the state of the middleware», дефолт «an in memory store for this process only». Интерфейс fiber.Storage из storage_interface.go ядра минимален и читается как чек-лист: Get и GetWithContext — «nil, nil is returned when the key does not exist»; Set и SetWithContext — «stores the given value for the given key with an expiration value, 0 means no expiration», пустые ключ и значение игнорируются без ошибки; Delete/DeleteWithContext, Reset/ResetWithContext — полная чистка — и Close, останавливающий сборщики мусора и соединения. Всего девять методов, и их достаточно, чтобы лимитер не знал, где живёт состояние.
Подмена прозрачна благодаря manager.go: при Storage == nil используется внутренний memory.Storage и sync.Pool бакетов; при заданном Storage каждый get превращает байты в item через UnmarshalMsg, каждый set — item в байты через MarshalMsg. Это msgpack-сериализация, сгенерированная утилитой msgp (файл manager_msgp.go), — компактный формат для внешнего хранилища. TTL записи ставится окном — у fixed это Expiration, у sliding resetInSec + expiration, поэтому просроченные бакеты убирает сам storage.
Самый ходовой Storage — Redis: пакет github.com/gofiber/storage/redis/v3 из монорепозитория gofiber/storage, и это ровно тот же интерфейс. С Redis счётчики общие для всех инстансов: лимит становится глобальным, перезапуск процесса счёт не теряет. Цена тоже понятна и должна звучать честно: каждый запрос к лимитеру превращается в два сетевых обращения к Redis — get и set под мьютексом, — и если Redis ляжет, выбор «fail open или fail closed» придётся делать осознанно, изучив поведение конкретной реализации storage. Поэтому не ставьте распределённый лимит раньше времени: memory-дефолт честен на одном инстансе, Redis — когда инстансов реально несколько.
Обзор алгоритмов: fixed, sliding log, token bucket#
За кадром лимитера Fiber остался мир алгоритмов, и обзор нужен хотя бы для того, чтобы выбирать осознанно. Fixed window — дефолт v3: один счётчик на окно, минимум памяти и операций, предсказуемое поведение storage; цена — всплеск до 2×Max на стыке окон, который мы разобрали на квизе. Sliding window counter — опциональный SlidingWindow{}: взвешенная пара окон, сглаживание стыка за счёт чуть более дорогой арифметики и удвоенного TTL записи. Sliding log — журнал таймстампов последних Max запросов: самая честная точность (окно скользит непрерывно), но и самая дорогая память — O(Max) на каждый ключ; в комплекте Fiber этой стратегии нет, писать её придётся самому на том же интерфейсе Handler. Token bucket — «ведро с токенами»: токены капают с фиксированной скоростью, запрос тратит токен, пустое ведро — 429; алгоритм допускает контролируемые всплески (полное ведро) и ограничивает среднюю скорость — это другой контракт, чем «N за окно», и именно он нужен, когда важна скорость, а не период.
С token bucket у нас уже есть знакомство: девятый урок курса go-kit — «Rate limiting», juju/ratelimit, token bucket на endpoint — собирал именно ведро, и ключевые слова того урока — «go kit rate limit» и «лимитер go kit». Сравните картины: у juju/ratelimit ведро — это объект в вашей горутине, скорость и объём настраиваются свободно; у limiter fiber — окно и потолок в конфиге, а состояние по ключам ведёт middleware. Для endpoint-ограничения с burst-трафиком ведро выразительнее; для «не дай клиенту больше N за минуту на весь API» — окно проще и прозрачнее в мониторинге.
Как выбирать на практике. Публичный API с документируемыми квотами — fixed или sliding из лимитера: заголовки X-RateLimit-* сразу показывают клиенту, сколько осталось. Логин и восстановление пароля — окно из минут, счёт только промахов, ключ с идентичностью. Тяжёлый эндпоинт (генерация отчёта, отправка письма) — token bucket с небольшой скоростью пополнения, чтобы всплеск из полного ведра был редкой привилегией, а не нормой. И помните: лимитеры на разных уровнях (маршрут, группа, весь сервис) — это разные слои с разными ключами, они не конфликтуют, а ограничивают независимо.
Соберите конфиг брутфорс-лимита
Восстановите конфиг: пять попыток за пятнадцать минут на IP, успешный вход не считается, отказ — 429 с конвертом ошибок.
Соберите путь запроса через FixedWindow
Разложите по порядку, что происходит с запросом в слое фиксированного окна — от входа в middleware до заголовков ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист продакшн-лимитера#
- Слой ставьте как можно раньше к входу (на группу или корень): 429 должен возвращаться до дорогой работы хендлеров
- Для /api/login — SkipSuccessfulRequests: true и окно из минут: считаются только провальные попытки
- Не полагайтесь на c.IP() слепо: с TrustProxy и ProxyHeader без EnableIPValidation это подделываемый заголовок — включайте валидацию или стройте ключ из идентичности
- Ключ длиннее 192 байт хэшируется в sha256: — это защита хранилища, а не особенность маршрутизации
- Несколько инстансов — общий Storage (Redis из gofiber/storage/redis/v3): иначе лимит умножается на число процессов
- Expirations меньше секунды не ставьте: windowSeconds поллит окно до целой секунды
- Выключать слой — через MaxFunc с нулём или Next: поле Max: 0 configDefault чинит до 5
- Оставляйте X-RateLimit-* и Retry-After включёнными: вежливые клиенты самоуправляются по ним
- Кастомный LimitReached — место для конверта {error, code}: Retry-After при этом сохраняется
- Помните границы ответственности: лимитер — не защита от масштабного DDoS, для него нужен периметр сети
Прогон: curl-сценарии и что дальше#
Соберите практику канона целиком: инстанс на :8080, ErrorHandler с конвертом {error, code} из четырнадцатого урока, слои helmet, cors и logger из шестого, лимитер на группу /api с потолком 100 в минуту и отдельный loginLimiter на /api/login. Клиент — curl, и сценарии короткие. Первый: семь GET /api/tasks подряд — шестой и седьмый вернут 429 с заголовком Retry-After и без X-RateLimit-*; curl -i покажет и то, и другое. Второй: посмотрите заголовки успешного ответа — X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, и Remaining уменьшается с каждым запросом. Третий: пять POST /api/login с неверной парой — шестой получит JSON {"error":"слишком много попыток входа","code":429} и Retry-After от вашего LimitReached. Четвёртый: successful-логин (заглушите проверку пары на true) не уменьшает Remaining — SkipSuccessfulRequests в действии. Пятый: подмените KeyGenerator на c.Get("X-API-Key") и убедитесь, что два разных ключа получают независимые Remaining.
Честное слово про инструментарий: песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; лимитер проверяется локально, где вашему curl виден Retry-After целиком.
Материал держится на четырёх опорах: конфиг и дефолты из config.go, два алгоритма из limiter_fixed.go и limiter_sliding.go, состояние и защита ключей из manager.go, контракт Storage из storage_interface.go ядра fiber v3.5.0. Полный план курса — на странице курса Fiber; фабрики middleware и requireAPIKey — фундамент keying, а апгрейд-рукопожатие WebSocket — сценарий, с которого урок начался: вечный клиент, долбящий дорогой GET, теперь встретит 429 с Retry-After.
Дальше — восемнадцатый урок: кэширование ответов, cache middleware, TTL и инвалидация. Родство с сегодняшней темой прямое: кэш — тоже middleware с состоянием между запросами, тоже Storage на внешний Redis при нескольких инстансах и тоже поле Next для исключений; мы уже видели его config.Next в шестнадцатом уроке, когда учили пропускать ws-пути мимо кэша. Лимитер считает запросы, кэш запоминает ответы — вместе они составляют пару «не работать зря»: сначала 429 тем, кто слишком часто, потом отдача из памяти тем, чей ответ уже посчитан.
Словарь урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Rate limiting — прикладная защита от брутфорса, скрейпинга и мелких DoS-петель: модель «ключ — окно — потолок»; limiter fiber — фабрика limiter.New(config ...Config) fiber.Handler из комплекта ядра v3.5.0 (middleware/limiter), в отличие от websocket — не contrib.
- Дефолты: алгоритм — FixedWindow{} («Default: a new Fixed Window Rate Limiter»), Max 5, Expiration 1 * time.Minute, KeyGenerator — c.IP(), LimitReached — c.SendStatus(fiber.StatusTooManyRequests); SlidingWindow{} — опциональная стратегия полем LimiterMiddleware.
- FixedWindow: item{currHits, prevHits, exp}, ts >= exp — счётчик в ноль и окно продлевается; remaining < 0 — Retry-After (секунды) + LimitReached без c.Next(); заголовки X-RateLimit-Limit/Remaining/Reset ставятся только после успешного c.Next(); blocked requests persist their increment.
- SlidingWindow: rate = ceil(prevHits × weight) + currHits, weight = resetInSec / Expiration; TTL = resetInSec + Expiration, чтобы после конца «sample window» атакующий не стартовал с полным окном; sliding log в Fiber не реализован.
- 429 — RFC 6585 §4 (constants.go: StatusTooManyRequests = 429 // RFC 6585, 4); ссылка на RFC 6584 в комментарии limiter — опечатка; SkipFailedRequests/SkipSuccessfulRequests считают статус через getEffectiveStatusCode с учётом *fiber.Error.
- Keying: c.IP() с TrustProxy и ProxyHeader без EnableIPValidation — подделываемый заголовок; ключ длиннее 192 байт → «sha256:» + дайджест; ключи в логах маскируются [redacted]; по API-ключу — связка с requireAPIKey из седьмого урока, лимитер раньше стража.
- Распределённый лимит: Storage — девять методов fiber.Storage, дефолт — память процесса, состояние msgp-байтами; Redis — github.com/gofiber/storage/redis/v3 для нескольких инстансов ценой get+set на каждый запрос. Практика: /api/login — SkipSuccessfulRequests: true, окно 15 минут, LimitReached с конвертом {error, code}.
Домашнее задание
Соберите брутфорс-страж канона целиком и проверьте его curl-ом. Во-первых, loginLimiter из практики: Max 5, Expiration 15 минут, SkipSuccessfulRequests: true, LimitReached с конвертом {error, code}; сделайте пять POST /api/login с неверной парой и убедитесь, что шестой вернул 429 с Retry-After и вашим JSON; затем успешный логин — и Remaining не тронулся. Во-вторых, включите X-RateLimit-*: сделайте серию GET /api/tasks и проследите curl -i, как Remaining уменьшается, а на 429 он исчезает из ответа — объясните себе, почему. В-третьих, поменяйте KeyGenerator на c.Get("X-API-Key") и докажите двумя ключами, что бакеты независимы; затем заведите в KeyGenerator составной ключ c.IP() + c.FormValue("email") для логина и проверьте, что перебор одного логина с двух адресов считается вдвое быстрее. В-четвёртых, переключите стратегию на limiter.SlidingWindow{} и сравните поведение на стыке окон: сто запросов в последнюю секунду минуты и сто в первую секунду следующей — у fixed второй залп пройдёт, у sliding упрётся в rate с весом prevHits. В-пятых, поставьте лимитер с MaxFunc, читающим тариф из map[apiKey]int, и удостоверьтесь, что MaxFunc с нулём отключает счёт для выбранного ключа, а поле Max: 0 не отключает ничего — оно становится пятёркой.
Личные заметкиПусто
Вопросы и ответы по уроку
На 429 нет X-RateLimit-Remaining — это баг или так задумано?
Так задумано, и это видно по пути исполнения limiter_fixed.go: заголовки X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset выставляются после c.Next(), в ветке успеха, а на пути отказа (remaining < 0) слой ставит только Retry-After и сразу возвращает LimitReached(c) — до выставления X-RateLimit-* дело не доходит. Клиент на отказе знает одно: сколько секунд ждать (Retry-After). Если вашим интеграционным тестам нужен Remaining на 429, допишите его в собственном LimitReached — resetInSec вычисляется из окна, а Retry-After к этому моменту уже поставлен и вашим JSON-ответом сохранится.
Fixed или Sliding: что ставить на /api/login?
Для логина важнее ключ и флаги, чем стратегия: окно из минут, SkipSuccessfulRequests: true, ключ с идентичностью (IP + логин). Разница стратегий проявляется на стыке окон: fixed позволяет до 2×Max в две соседние секунды границы — для перебора паролей это лишние попытки, sliding сглаживает всплеск весом prevHits и потому чуть честнее против ботнета, бьющего ритмично. Но sliding дороже: удвоенный TTL записи и лишняя арифметика на каждом запросе. Разумная последовательность: начать с fixed, посмотреть на реальные атаки в логах и переходить на sliding, только если всплески на стыке наблюдаются.
Три инстанса за балансировщиком: что сделать с лимитером и чего он всё равно не защитит?
Вынести состояние из памяти процессов: поле Storage принимает fiber.Storage, и типовой выбор — Redis, github.com/gofiber/storage/redis/v3. С общим хранилищем потолок Max становится глобальным на все инстансы (без него — умножается на число процессов), а счёт переживает перезапуски; механика — msgp-байты через MarshalMsg/UnmarshalMsg и два обращения к Redis на каждый запрос. Чего это не решит: масштабную DDoS-атаку с десятков тысяч адресов — там нужен периметр сети или CDN, а не Go-процесс; и не решит зависимость от Redis — хранилище становится частью пути каждого запроса, его отказоустойчивость надо продумывать отдельно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT