24 августа 2026 г. · 3 мин чтения
Ограничитель частоты, который не ограничивает
Ограничение частоты запросов писалось на сайте как обычный счётчик в памяти: словарь, ключ вида «действие плюс отпечаток адреса», сброс по времени. Такой код работает, проверяется за минуту и выглядит правильным.
На одном сервере он и есть правильный. Проблема появляется, когда сайт живёт на бессерверной площадке.
Там нет «одного сервера». Каждый запрос попадает в экземпляр функции, которых площадка поднимает столько, сколько считает нужным, и гасит, когда сочтёт нужным. У каждого своя память. Предел «тридцать сообщений в минуту» на деле означает «тридцать на каждый живой экземпляр», а сколько их будет, решает не сайт.
Дальше начинается неприятное. При обычной нагрузке экземпляр один, и ограничитель работает — все проверки проходят. При настоящей нагрузке, то есть ровно тогда, когда он нужен, экземпляров становится много, и предел разъезжается. Защита разваливается в единственный момент, ради которого её писали.
Ещё хуже то, что в комментарии к такому коду обычно честно написано «достаточно для одного сервера». Написано — и забыто, потому что площадку выбирали позже.
Лечение простое: счётчик должен быть общим. Redis с обращением по HTTP подходит идеально — не нужна ни библиотека, ни постоянное соединение, работает и в edge-окружении.
Две тонкости, на которых легко ошибиться.
Первая — срок жизни ключа надо ставить только при создании. Если обновлять его на каждом запросе, окно у того, кто стучится непрерывно, не закроется никогда.
Вторая — что делать, если хранилище не ответило. Пропускать всех нельзя: отказ Redis превратится в открытую дверь. Ронять запрос тоже нельзя. Правильный ответ — вернуться к счётчику в памяти: он слабее, но он есть.
export async function rateLimit( key: string, limit = 8, windowMs = 60_000,): Promise<RateLimitResult> { const url = envString("UPSTASH_REDIS_REST_URL"); const token = envString("UPSTASH_REDIS_REST_TOKEN"); if (url && token) { const result = await inRedis(url, token, `rl:${key}`, limit, windowMs); /* Redis не ответил — считаем в памяти, а не пропускаем всех. Отказ хранилища не повод снимать защиту, но и повесить сайт из-за него нельзя: в памяти предел слабее, но он есть. */ if (result) return result; } return inMemory(key, limit, windowMs);}