Лимит частоты запросов
This content is not available in your language yet.
Рецепт о том, как ограничить число запросов в минуту — по адресу, по логину, по любому другому ключу — так, чтобы сверх лимита клиент получал 429 и подсказку, через сколько секунд повторить.
Справочник по RateLimit и TooManyRequestsResponse — @aom-js/api §«RateLimit». Здесь — только то, что нужно, чтобы написать код и не наступить на грабли.
Как это устроено
Заголовок раздела «Как это устроено»Лимит состоит из двух частей:
- Лимитер — объект из библиотеки
rate-limiter-flexible, который считает запросы. Он один на весь процесс и лежит вstatic Limiterкласса. - Шаг ключа — middleware, который говорит, по чему считать: адрес, логин, id пользователя. Он кладёт значение в
self.key, и сразу за ним (@After(…Apply)) срабатываетApply— списывает очко и при исчерпании лимита отвечает429.
Библиотека целиком отдаётся из @aom-js/api: RateLimiterMemory, RateLimiterRedis и остальное импортируются оттуда же, отдельно ставить её не нужно.
Один лимит по IP-адресу
Заголовок раздела «Один лимит по IP-адресу»import { RateLimit, RateLimiterMemory, type HttpCtx } from "@aom-js/api";import { After, Before, Body, Controller, Ctx, Middleware, Post, This } from "@aom-js/router";
@Controller()class ByIp extends RateLimit { // 100 запросов в минуту с одного адреса; пороги — здесь, в коде static readonly Limiter = new RateLimiterMemory({ points: 100, duration: 60 });
@Middleware() @After(ByIp.Apply) static async Key(@Ctx() ctx: HttpCtx, @This() self: ByIp) { self.key = ctx.req.socket.remoteAddress ?? null; }}
@Controller()class Root { @Post("/x") @Before(ByIp.Key) static async X() { return "ok"; }}Пока очки остаются, запрос идёт дальше. Когда они закончились, клиент получает 429 со стандартным телом (без data) и заголовком Retry-After — целых секунд до следующей попытки, не меньше одной.
IPv6. Один клиент с IPv6, как правило, владеет целой подсетью /64 — это миллиарды адресов, — и ключ по одному адресу ему легко менять от запроса к запросу, обходя лимит. Если лимит по адресу для вас важен, считайте по префиксу /64 (первые четыре группы адреса), а не по адресу целиком; IPv4-адреса считаются как есть.
Браузер и Retry-After. Страница с другого источника (origin) не видит этот заголовок в ответе, пока сервер не пришлёт Access-Control-Expose-Headers: Retry-After. Сервер ядра отдаёт Access-Control-Allow-Origin: *, но Expose-Headers не ставит, так что такой клиент знает только, что получил 429, а не через сколько секунд повторять. Запросы с того же источника заголовок видят всегда; для чужого источника добавьте Expose-Headers в приложении (например, на прокси перед сервером).
Если ключа нет (в примере — у сокета нет адреса), это не 429 и не пропуск запроса, а обычная ошибка разработчика: лимит настроен неполно. То же — если у класса нет Limiter.
Каскад из двух лимитов
Заголовок раздела «Каскад из двух лимитов»Типичный случай — вход по логину. Нужны два лимита: общий по адресу (чтобы один адрес не перебирал много логинов) и узкий по логину (чтобы один логин не перебирали с разных адресов). Это два наследника RateLimit, у каждого свой лимитер; на маршруте они идут по порядку, от широкого к узкому.
@Controller()class IpLimit extends RateLimit { static readonly Limiter = new RateLimiterMemory({ points: 20, duration: 60 });
@Middleware() @After(IpLimit.Apply) static async ByIp(@Ctx() ctx: HttpCtx, @This() self: IpLimit) { self.key = ctx.req.socket.remoteAddress ?? null; }}
@Controller()class LoginLimit extends RateLimit { static readonly Limiter = new RateLimiterMemory({ points: 5, duration: 60 });
@Middleware() @After(LoginLimit.Apply) static async ByLogin(@This() self: LoginLimit, @Body() body: { login?: string }) { self.key = body.login ?? null; }}
@Controller()class Auth { @Post("/login") @Before(IpLimit.ByIp, LoginLimit.ByLogin) static async login() { return "ok"; }}Что тут важно:
- Ключ берётся из данных запроса. Шаг может читать всё, что видно middleware: тело (
@Body), заголовки, cookie, результат шагов выше. Выше по цепочке должно стоять то, что ключ готовит. - Первый отказ останавливает цепочку. Если исчерпан лимит адреса, до шага с логином дело не доходит и счёт по логину не растёт: чужой адрес не «сжигает» очки законного пользователя.
- Счёт у каждого наследника свой, даже если ключи совпадают: отказ первого не списывает очки у второго.
- Лимит по логину общий для всех адресов: тот же логин с другого адреса тоже получит
429. Это сознательная плата за защиту от перебора с разных адресов.
Тело читается лениво: @Body в шаге логина вызывает чтение тела только когда до него дошла очередь. См. рецепт про ленивое тело.
Ключ из логина: нормализация и проверка типа
Заголовок раздела «Ключ из логина: нормализация и проверка типа»Ключ по логину надёжен, только если он строится так же, как приложение ищет этого пользователя: тот же регистр, те же пробелы, тот же формат телефона. Иначе Ann, ann и ann для входа — один человек, а для лимита — три разных ключа с тремя отдельными счётчиками, и лимит обходится одной буквой. Значение, которое не строка, нужно отклонить 400 до Apply: объект в ключе превратился бы в [object Object], и все такие запросы делили бы один счётчик.
import { WrongDataResponse } from "@aom-js/api";
@Controller()class LoginLimit extends RateLimit { static readonly Limiter = new RateLimiterMemory({ points: 5, duration: 60 });
@Middleware() @After(LoginLimit.Apply) static async ByLogin(@This() self: LoginLimit, @Body() body: { login?: unknown }) { if (typeof body.login !== "string" || !body.login.trim()) { throw new WrongDataResponse("APP:LOGIN_REQUIRED"); } // та же нормализация, что и при поиске учётной записи self.key = body.login.trim().toLowerCase(); }}Тело шага выполняется до Apply (он стоит за шагом через @After), поэтому бросок в нём останавливает цепочку раньше, чем что-либо списано.
Память и база
Заголовок раздела «Память и база»RateLimiterMemory считает в памяти одного процесса. Подходит для одного экземпляра сервера, для разработки и тестов. Счёт пропадает при перезапуске, а у нескольких процессов каждый считает отдельно — реальный лимит тогда во столько раз больше.
Для нескольких процессов нужно общее хранилище. В ядре нет клиентов баз данных: клиент создаёт приложение и передаёт его лимитеру в storeClient.
import Redis from "ioredis"; // зависимость приложения, ядро её не содержитimport { RateLimiterRedis } from "@aom-js/api";
const redis = new Redis(process.env.REDIS_URL);
@Controller()class ByIp extends RateLimit { static readonly Limiter = new RateLimiterRedis({ storeClient: redis, keyPrefix: "ip", // ключи разных лимитов в одной базе не смешиваются points: 100, duration: 60, }); // шаг ключа — тот же, что и с памятью}Тот же код в тесте исполняется с заглушкой вместо настоящего клиента: проверяется, что лимитер получает клиента приложения, считает по ключу с префиксом и отдаёт Retry-After по времени, которое вернуло хранилище. Другие лимитеры библиотеки (для баз данных, для кластера) подключаются так же — любой из них годится в Limiter.
Сбой хранилища — не 429. Если база недоступна, ошибка пробрасывается как есть, и клиент получает 500. Лимит при этом не отключается: запрос не проходит «потому что счётчик сломался». Нужна другая политика — настраивайте резервный лимитер в самой библиотеке; ядро ничего не подменяет.
За прокси
Заголовок раздела «За прокси»За обратным прокси (nginx, балансировщик) ctx.req.socket.remoteAddress — это адрес самого прокси, и все клиенты окажутся «одним адресом». Тогда адрес берут из заголовка X-Forwarded-For, но осторожно: заголовок — список адресов через запятую, и левые записи пишет клиент. Доверять можно только записи, которую добавил ваш прокси, — при одном прокси это последняя.
@Controller()class ByClientIp extends RateLimit { static readonly Limiter = new RateLimiterMemory({ points: 100, duration: 60 });
@Middleware() @After(ByClientIp.Apply) static async Key(@Ctx() ctx: HttpCtx, @This() self: ByClientIp) { const forwarded = ctx.req.headers["x-forwarded-for"]; const chain = (Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? "")).split(","); // последняя запись — от нашего прокси; левые подставляет клиент self.key = chain.at(-1)?.trim() || ctx.req.socket.remoteAddress || null; }}Клиент, который шлёт X-Forwarded-For: 6.6.6.1, <реальный адрес>, а затем 6.6.6.2, <реальный адрес>, считается одним и тем же — прокси дописывает настоящий адрес справа. Если брать первую запись, лимит обходится одной строкой в заголовке.
Два условия, без которых так делать нельзя: сервер доступен только через прокси (иначе заголовок пришлёт кто угодно напрямую) и прокси дописывает адрес, а не пропускает присланный клиентом заголовок как есть. Если прокси больше одного — берите запись с нужного конца по числу прокси.
Антипример: лимитер в поле экземпляра
Заголовок раздела «Антипример: лимитер в поле экземпляра»@Controller()class Leaky extends RateLimit { // ❌ новый лимитер на каждый запрос: счётчик каждый раз пуст limiter = new RateLimiterMemory({ points: 1, duration: 60 }); // …}Объект класса контроллера создаётся заново на каждый запрос, а значит, и лимитер в его поле. Лимит в одну попытку не сработает никогда: каждый запрос считает с нуля. Поэтому лимитер лежит в static Limiter — он создаётся один раз при загрузке класса, а limiter у экземпляра — всего лишь геттер, который его возвращает. TypeScript такой класс не соберёт («свойство вместо геттера»); в тесте это обходится нарочно, чтобы показать эффект.
Антипример: флаг для тестов
Заголовок раздела «Антипример: флаг для тестов»@Controller()class ByIp extends RateLimit { // ❌ «в тестах лимит отключаем» static readonly Limiter = process.env.NODE_ENV === "test" ? undefined : new RateLimiterMemory({ points: 5, duration: 60 }); // …}Так лимит не отключить: нет Limiter — это Error разработчика, запрос получит 500, а не пройдёт. Это сделано намеренно: режима «лимит выключен» в ядре нет, чтобы он не мог оказаться включённым на проде по забытой переменной окружения.
Пороги живут в коде наследника, а не в настройках. Как не упираться в лимит в тестах — следующий раздел.
В тестах: сброс счётчика
Заголовок раздела «В тестах: сброс счётчика»static Limiter создаётся один раз и живёт весь прогон, поэтому очки, потраченные одним тестом, видит следующий. Две меры, которые обычно применяют вместе:
- Свой ключ на тест. Если тест не проверяет сам лимит, пусть его запросы считаются по ключу, которого нет в других тестах (свой адрес, свой логин).
- Сброс ключа после теста. Тест, который исчерпывает лимит, в конце удаляет свой ключ методом лимитера
delete:
import { expect, it } from "vitest";
it("сверх лимита — 429", async () => { const address = "203.0.113.5"; // тот же ключ, что шаг кладёт в self.key try { for (let i = 0; i < 100; i++) expect(await request(address)).toBe("ok"); await expect(request(address)).rejects.toMatchObject({ status: 429 }); } finally { // без сброса следующий тест с этим ключом сразу получит 429 await ByIp.Limiter.delete(address); }});request здесь — ваш способ выполнить маршрут в тесте с нужным адресом. finally сбрасывает ключ и тогда, когда проверка упала. Если ключ один на весь файл, то же можно сделать в afterEach.
Что важно:
- В
deleteпередают ключ безkeyPrefix— тот же, что шаг кладёт вself.key; префикс добавляет сама библиотека. - С общим хранилищем сброс обязателен.
RateLimiterRedisи другие лимитеры с базой хранят счёт вне процесса: безdeleteследующий прогон тестов начнёт с уже исчерпанным лимитом.deleteудаляет запись в самой базе. - Не подменяйте
Limiterв тестах новым объектом илиundefined: первое проверяет уже не тот лимитер, что работает в приложении, второе даёт500(см. антипример выше).
- Справочник по
RateLimit,TooManyRequestsResponse, повторному экспорту библиотеки:@aom-js/api§«RateLimit» - Ленивое тело, которое читает шаг с
@Body: рецепт про ленивое тело - Тесты, исполняющие этот рецепт:
packages/api/test/rate-limit.test.ts(пороги в тестах уменьшены, чтобы лимит исчерпывался за несколько вызовов; структура классов та же)