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

Лимит частоты запросов

Рецепт о том, как ограничить число запросов в минуту — по адресу, по логину, по любому другому ключу — так, чтобы сверх лимита клиент получал 429 и подсказку, через сколько секунд повторить.

Справочник по RateLimit и TooManyRequestsResponse — @aom-js/api §«RateLimit». Здесь — только то, что нужно, чтобы написать код и не наступить на грабли.


Лимит состоит из двух частей:

  1. Лимитер — объект из библиотеки rate-limiter-flexible, который считает запросы. Он один на весь процесс и лежит в static Limiter класса.
  2. Шаг ключа — middleware, который говорит, по чему считать: адрес, логин, id пользователя. Он кладёт значение в self.key, и сразу за ним (@After(…Apply)) срабатывает Apply — списывает очко и при исчерпании лимита отвечает 429.

Библиотека целиком отдаётся из @aom-js/api: RateLimiterMemory, RateLimiterRedis и остальное импортируются оттуда же, отдельно ставить её не нужно.


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 (пороги в тестах уменьшены, чтобы лимит исчерпывался за несколько вызовов; структура классов та же)