Перейти к содержанию

Атомарный лимит запросов

Оглавление серии

Даже маленькая задача может заполнить очередь, если клиент повторяет запрос слишком часто. Добавим в «Импортёр» ограничение постановки: не больше пяти запросов за минутное окно локального примера. Redis хранит общий счётчик, поэтому несколько HTTP-процессов, использующих одну учебную базу, увидят одну величину. Чтобы счётчик не остался бессрочным после сбоя между командами, выполняем изменение и назначение TTL одним Lua-скриптом.

Четвёртое состояние ещё не создаёт настоящую задачу: допустимый запрос получает учебный ответ, а очередь появится следующим уроком. Такой переход отделяет правило ограничения от механизма BullMQ. Используйте stages/04; Redis, Lua и HTTP не запускались, статус not_run. Предел пять запросов выбран для объяснения и не является измеренной пропускной способностью приложения.

Счётчик и срок образуют одну операцию

На каждый запрос увеличиваем importer:rate:local через INCR. Если результат равен единице, задаём PEXPIRE на 60000 миллисекунд. Затем возвращаем количество и оставшийся срок. Значение после первой команды определяет начало окна; оно не выравнивается по календарной минуте. После истечения ключ исчезнет, а следующий запрос начнёт новое окно и снова получит счётчик 1.

Полный rate-limit.js содержит скрипт и преобразование его результата для HTTP-обработчика. Функция вызывается из маршрута POST /api/imports перед дальнейшей обработкой.

const script = `
local value = redis.call('INCR', KEYS[1])
if value == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return {value, redis.call('PTTL', KEYS[1])}
`;

export async function rateLimit(redis) {
  const [count, ttl] = await redis.eval(script, 1, 'importer:rate:local', 60000);
  return { allowed: count <= 5, retryAfter: Math.max(1, Math.ceil(ttl / 1000)) };
}

Почему условие и TTL объединены? При отдельных вызовах процесс мог бы увеличивать счётчик до 1, затем остановиться до PEXPIRE. Следующие запросы увеличивали бы бессрочный ключ и никогда не начинали новое окно. Lua выполняется Redis без вмешательства другой команды между строками скрипта, поэтому проверка первого значения и установка срока согласованы. Это не означает, что Redis автоматически откатывает любые уже выполненные записи при произвольной ошибке внутри Lua.

В нашем скрипте ключ принадлежит только лимиту и должен иметь строковый числовой тип. Если кто-то записал туда другой тип или нечисловой текст, INCR завершится ошибкой, и API вернёт отказ зависимости. Назначение понятного namespace уменьшает случайные столкновения, но не гарантирует их невозможность. Локальное окружение должно быть отдельным, а в production владелец ключа и его формат являются частью операционного договора.

Код 429 объясняет повтор

Первые пять запросов окна разрешены, шестой получает 429. Заголовок Retry-After задаёт число секунд до следующей возможной попытки. Остаток PTTL округляется вверх, а минимальное значение делаем равным единице. Пользовательский клиент должен показать ожидание или повторить позднее, а не немедленно отправлять десятки одинаковых запросов в ответ на ограничение. Иначе ограничитель сам становится источником дополнительной нагрузки.

Ожидаемый ответ ограничения показан ниже. Это договор HTTP, не вывод выполненного сервера.

HTTP/1.1 429 Too Many Requests
Retry-After: 58

{"error":"rate_limit"}

Число 58 условное: оно зависит от времени запросов. Не сравнивайте его с точным значением из статьи как с тестом секундомера. Важны код, единица измерения и положительная граница повтора. TTL задаётся Redis, поэтому задержка выполнения HTTP или сеть может уменьшить срок к моменту получения ответа. Клиенту полезнее учитывать безопасное округление и собственный небольшой запас, чем ожидать синхронность часов двух процессов.

Область ограничения выбирается явно

В этом примере ключ общий для всех локальных запросов. Он не защищает отдельного пользователя, поскольку настоящая аутентификация здесь отсутствует. Название local напоминает, что мы ограничиваем учебный экземпляр в целом. Для действующего SaaS обычно выбирают область организации, пользователя или API-ключа, а иногда сочетают несколько уровней. Значение области должно следовать из проверенного сервером контекста, а не из свободного поля JSON.

Если использовать только IP-адрес, несколько сотрудников за одним шлюзом будут делить лимит. Если доверять любому X-Forwarded-For, клиент сможет менять область ограничителя самостоятельно. Для корректного решения требуется известная цепочка прокси и её настройка, чего локальная серия не моделирует. Поэтому не превращаем демонстрационный ключ в случайную production-рецептуру и не добавляем непроверенную логику определения личности по сетевому заголовку.

Запрос с неверным JSON или неизвестным dataset тоже может потреблять счётчик в зависимости от положения проверки. В нашем маршруте лимит выполняется до предметной проверки тела, поэтому неверные попытки также ограничиваются. Это уменьшает количество дальнейших операций. Но продукт может потребовать другой договор для оплачиваемого лимита успешных импортов: попытки, постановки и завершённые задачи являются тремя разными единицами учёта.

У фиксированного окна есть граница

Клиент способен отправить пять запросов перед истечением окна и пять сразу после начала следующего. За короткий промежуток получится десять запросов, хотя каждое окно удовлетворяет правилу. Это известное свойство fixed window. Для более плавного ограничения используют скользящее окно или token bucket, однако они требуют другой модели данных. В учебном курсе выбран простой механизм, чья граница понятна из времени создания ключа.

Лимит API и лимит обработки очереди независимы. Один запрос может создать дорогую задачу, а воркер может выполнять её долго. Ограничение постановки не управляет количеством одновременно работающих задач и не гарантирует скорость завершения. Позднее concurrency воркера задаст отдельный предел, а наблюдение покажет ожидание и прогресс. Не нужно возвращать «импорт выполнен» лишь потому, что клиент прошёл rate limit.

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

Для самостоятельного разбора представьте два HTTP-процесса с одинаковым ключом. Их запросы увеличивают один счётчик, а не две локальные переменные. Если заменить Redis-код картой в памяти каждого процесса, общий предел перестанет работать. Этот пример показывает пользу внешней согласованной операции и одновременно её зависимость: отказ Redis становится отказом самого правила, который требует определённого HTTP-результата.

Атомарность исполнения и аргументы описаны в Redis EVAL, а счётчик — в INCR. После четвёртого состояния поступление имеет общий предел и объяснимый повтор. Следующим шагом допустимый запрос создаст идентифицированную задачу BullMQ, а ответ 202 будет означать постановку, а не завершение импорта.