@application-kit/rate-limit
v0.3.0
Published
Частотная защита для NestJS: счётчик попыток с окном (память и Valkey с атомарным Lua), бюджет попыток и gate с Retry-After
Readme
@application-kit/rate-limit
Частотная защита для NestJS: счётчик попыток с окном, бюджет попыток и gate маршрута с заголовком Retry-After.
Доменных понятий в пакете нет: ключи, пороги, оси и ошибки отказа объявляет приложение.
Установка
yarn add @application-kit/rate-limitPeer-зависимости: @libs-for-dev/nestjs-config-module, @nestjs/common, @nestjs/core, class-transformer,
class-validator, nestjs-pino, oxide.ts, rxjs. Клиент Valkey (iovalkey) — зависимость пакета.
Подключение
import { RateLimitModule } from '@application-kit/rate-limit'
RateLimitModule.forRootAsync({
// Исполнитель команд оси — решения и исхода: обычно — шина команд приложения.
execute: {
inject: [CommandBus],
useFactory: (commandBus: CommandBus) => async (command: object) => commandBus.execute(command),
},
imports: [CqrsModule],
})Счётчик без опции counter выбирает окружение:
| Переменная | Умолчание | Смысл |
|---|---|---|
| VALKEY_HOST | '' | адрес хранилища; пусто — счётчик в памяти процесса и одно предупреждение в лог |
| VALKEY_PORT | 6379 | порт |
| VALKEY_CLUSTER | true | кластерный протокол (адрес — стартовый узел); false — одиночный сервер |
| VALKEY_USERNAME | default | пользователь ACL |
| VALKEY_PASSWORD | '' | пароль |
Клиент ленивый (lazyConnect): соединение открывает первая попытка, а не сборка графа. Окружение читается и
валидируется в forRootAsync, а не на импорте пакета; неверное значение роняет старт с именем переменной.
Свой счётчик — фабрикой в опции counter ({ useFactory: () => new FakeAttemptCounter() } из
@application-kit/rate-limit/testing в спеках): тогда VALKEY_* не читается.
Модуль экспортирует токены счётчика и исполнителя (RATE_LIMIT_COUNTER, RATE_LIMIT_EXECUTE), ThrottleBudget и
ThrottleGate: модуль ручки обязан их видеть — guard и интерцептор оси создаются там. Логгер отказов
(RATE_LIMIT_LOGGER, RateLimitLogger) модуль связывает сам — нужен глобальный LoggerModule из nestjs-pino.
Счётчик
increment(key, windowSeconds)— фиксирует попытку. Окно открывает первая попытка, попытки внутри окна его не продлевают: иначе блокировку нельзя переждать.read(key)— состояние окна илиNone.reset(key)— сброс окна, например после успешного входа.
ValkeyAttemptCounter меняет счёт и срок одним Lua-скриптом: попытки разных реплик складываются, а рестарт процесса
блокировку не снимает. InMemoryAttemptCounter — для спек и запуска без хранилища: порог в нём действует на процесс.
Бюджет
spend({ key, limit, refusal })— попытка стоит окна независимо от исхода (ручки с одинаковым ответом всегда).readFailures({ key, maxFailures, refusal })— бюджет только читается; тратит его зарегистрированный исход попытки, то естьincrementу потребителя.
Отказ пишется в лог одной записью { retryAfterSeconds, securityEvent } с сообщением refusal.message — без ключа
и идентификаторов.
Ось
Ось — данные приложения, объявленные defineThrottleAxis и поставленные на ручку декоратором @Throttle(axis):
// Бюджет тратит каждая попытка: ответ ручки одинаков при любом исходе.
const PASSWORD_RESET = defineThrottleAxis<ClientContext>({
createError: () => new TooManyPasswordResetRequestsError(),
key: ({ clientId }) => `passwordReset:rate:${clientId}`,
limit: PasswordResetProtectionConfig,
readContext: readClientContext,
refusal: { message: 'password reset request refused', securityEvent: 'passwordReset.refused' },
})
// Решение — команда приложения, исход попытки — вторая команда после ответа ручки.
const SIGN_IN = defineThrottleAxis<SignInContext, CheckSignInThrottleResult>({
createCommand: ({ clientId, subject }) => new CheckSignInThrottleCommand({ clientId, subject }),
createError: () => new TooManySignInAttemptsError(),
isAllowed: (decision) => decision.reason === null,
outcome: {
createCommand: ({ subject }, succeeded) => new RegisterSignInOutcomeCommand({ subject, succeeded }),
isAttemptSpent: isSignInAttemptSpent,
},
readContext: readSignInContext,
})
@Throttle(SIGN_IN)
public async signIn(...) {}ThrottleGuardдо тела ручки спрашиваетThrottleGate: у оси сlimitрешение —ThrottleBudget.spendпо порогам из провайдера приложения, у оси сcreateCommand— команда черезexecute. Отказ пишетretry-afterв ответ и бросает ошибку оси.ThrottleOutcomeInterceptorстоит только у оси сoutcome: успех регистрируется всегда, ошибка — когдаisAttemptSpentсчитает её потраченной попыткой; ошибка ручки уходит дальше без изменений.
Спеки потребителя
import { FakeAttemptCounter } from '@application-kit/rate-limit/testing'Фейк копит счёт и вызовы (increments, resets, seed, countOf) и отдаёт фиксированный остаток окна.
