Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +50 XP

Ограничение частоты запросов

Обещание из шестнадцатого урока исполняется: там мы подцепили к канону постоянные ws-соединения и оставили без присмотра вечного клиента, долбящего дорогой апгрейд-рукопожатие по кругу, — и пообещали ему считалку. Тема «fiber rate limit» — это и есть считалка: limiter middleware из комплекта ядра Fiber v3.5.0, тот самый, что упоминался в перечне штатных слоёв ещё в шестнадцатом уроке, — в ядре, а не в contrib, в отличие от websocket. Задачи у него три, и все три вы уже встречали в продакшене, даже если не называли вслух: брутфорс — перебор паролей на /api/login тысячами попыток в минуту; скрейпинг — выкачивание вашего каталога ботами, которые делают по запросу каждые двести миллисекунд; и добрая половина мелких DoS-инцидентов, когда один недописанный цикл в чужом приложении стучится в ваш эндпоинт без пауз. Восьмой урок курса fiber про безопасность заголовков закрывал браузерные риски, здесь риск серверный: без ограничителя каждый маршрут приложения — это открытая труба, по которой любой клиент выкачивает ваши CPU и память с неограниченной скоростью. В этом уроке мы вскроем limiter по живым исходникам ветки main ядра fiber (файлы middleware/limiter: limiter.go, config.go, limiter_fixed.go, limiter_sliding.go, manager.go, плюс storage_interface.go) и честно ответим на вопрос, который часто гуглят мимоходом: по умолчанию алгоритм — фиксированное окно (FixedWindow), а скользящее окно (SlidingWindow) — опциональная стратегия, включаемая одним полем конфига. Разберём конфиг по полям: Max и Expiration, генераторы MaxFunc и ExpirationFunc, KeyGenerator с честной историей про подделываемый IP, LimitReached, SkipFailedRequests и SkipSuccessfulRequests, DisableHeaders и Storage для распределённого лимита. Разберём ответы: 429 Too Many Requests с Retry-After и заголовки X-RateLimit-*, включая забавную находку — ссылку на RFC 6584 в комментарии исходников, тогда как 429 определён в RFC 6585, что подтверждает и комментарий к константе StatusTooManyRequests в constants.go ядра. И соберём практику: лимит на /api/login, который считает только неудачные попытки входа. Связки на месте: keying по API-ключу продолжит requireAPIKey из седьмого урока, а token bucket из алгоритмического обзора — девятый урок курса go-kit.

Содержание урока · 14 разделов

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-ветку

GO
package 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 целиком#

ПолеДефолтСмысл и подводный камень
Max5 (defaultLimiterMax)потолок запросов в окне; значение ≤ 0 configDefault заменяет на 5 — выключить слой этим полем нельзя
Expiration1 * time.Minuteдлина окна; ≤ 0 — дефолт; суб-секундное окно поллится до целой секунды
MaxFuncзамыкание на cfg.Maxпотолок на конкретный запрос; вернёт 0 — слой пропустит запрос без счёта
ExpirationFuncзамыкание на cfg.Expirationокно на конкретный запрос: тарификация по клиенту
KeyGeneratorfunc(c) { return c.IP() }ключ бакета; свой ключ — своя считалка; о подделываемом IP см. блок keying
LimitReachedc.SendStatus(fiber.StatusTooManyRequests)ответ при исчерпании лимита; Retry-After к этому моменту уже поставлен
Storagenil — память процессавнешнее хранилище для распределённого лимита: Redis и другие
LimiterMiddlewareFixedWindow{}стратегия; вторая готовая — SlidingWindow{}; интерфейс Handler из limiter.go
Nextnilпропустить слой без счёта — стандарт всех middleware Fiber
SkipFailedRequestsfalseне считать ответы со статусом ≥ 400; статус берётся с учётом *fiber.Error
SkipSuccessfulRequestsfalseне считать ответы со статусом < 400; основа брутфорс-лимита на /api/login
DisableHeadersfalseубрать X-RateLimit-Limit/Remaining/Reset и Retry-After из ответов
DisableValueRedactionfalseвыключить маскировку ключей в логах: иначе в журнале [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/tasks

SlidingWindow: как 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 против брутфорса

GO
package 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 с конвертом ошибок.

// пять неудачных попыток за пятнадцать минут на адрес клиента loginLimit := limiter.New(limiter.Config{ Max: , Expiration: 15 * , KeyGenerator: func(c fiber.Ctx) string { return () // дефолтный ключ — адрес клиента }, : true, // успешный вход (200) не расходует лимит LimitReached: func(c fiber.Ctx) error { return c.Status().JSON(fiber.Map{ "error": "слишком много попыток входа", "code": 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 тем, кто слишком часто, потом отдача из памяти тем, чей ответ уже посчитан.

Словарь урока

Термин

429 Too Many Requests и Retry-After

нажмите, чтобы перевернуть
Определение

429 определён в RFC 6585 §4 — константа StatusTooManyRequests = 429 // RFC 6585, 4 в constants.go, рядом ErrTooManyRequests. Retry-After ставится до вызова LimitReached и несёт целые секунды до сброса окна (resetInSec). На отказе клиент получает только Retry-After; X-RateLimit-* живут в ветке успеха. Комментарий в исходниках с RFC 6584 — давняя опечатка (6584 — «Simple Authentication for ALC and NORM»).

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 не отключает ничего — оно становится пятёркой.

Личные заметкиПусто
Хранятся только в вашем браузере (Fiber · Ограничение частоты запросов)

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

На 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 — хранилище становится частью пути каждого запроса, его отказоустойчивость надо продумывать отдельно.

Термины урока

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Gin25 уроков~13 чпрактика25 уроков уже доступны

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT