К содержимому
Hub
Все заметки

24 августа 2026 г. · 3 мин чтения

Ограничитель частоты, который не ограничивает

БезопасностьVercelRedisАрхитектура

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

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

Там нет «одного сервера». Каждый запрос попадает в экземпляр функции, которых площадка поднимает столько, сколько считает нужным, и гасит, когда сочтёт нужным. У каждого своя память. Предел «тридцать сообщений в минуту» на деле означает «тридцать на каждый живой экземпляр», а сколько их будет, решает не сайт.

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

Ещё хуже то, что в комментарии к такому коду обычно честно написано «достаточно для одного сервера». Написано — и забыто, потому что площадку выбирали позже.

Лечение простое: счётчик должен быть общим. Redis с обращением по HTTP подходит идеально — не нужна ни библиотека, ни постоянное соединение, работает и в edge-окружении.

Две тонкости, на которых легко ошибиться.

Первая — срок жизни ключа надо ставить только при создании. Если обновлять его на каждом запросе, окно у того, кто стучится непрерывно, не закроется никогда.

Вторая — что делать, если хранилище не ответило. Пропускать всех нельзя: отказ Redis превратится в открытую дверь. Ронять запрос тоже нельзя. Правильный ответ — вернуться к счётчику в памяти: он слабее, но он есть.

TypeScript
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);}