@wazobiatech/rate-limit
v1.0.0
Published
Production-grade rate limiting store interface for NestJS, Express, and Node.js
Downloads
87
Readme
@wazobiatech/rate-limit
Production-grade rate limiting for NestJS, Express, and plain Node.js. Five pluggable algorithms, two store backends (Redis and in-memory), a static facade with Wazobia-specific presets, and full TypeScript types.
Table of Contents
- Installation
- Algorithm Overview
- NestJS: Module Registration
- NestJS: @RateLimit() Decorator
- NestJS: Guard behaviour
- NestJS: Guard ordering
- Express: rateLimitMiddleware
- WazobiaRateLimit Facade
- Algorithm Classes — Direct Usage
- Store Backends
- Identifier Extraction and Key Building
- RateLimitConfig Reference
- Testing Patterns
- Public API Reference
Installation
npm install @wazobiatech/rate-limitPeer dependencies — install what you use:
# NestJS integration
npm install @nestjs/common @nestjs/core reflect-metadata
# GraphQL support (optional — only if you rate-limit GraphQL mutations)
npm install @nestjs/graphql graphql
# Redis store (production)
npm install ioredis
# Express middleware (optional)
npm install expressAlgorithm Overview
| Algorithm | Key property | Best for |
|---|---|---|
| sliding-window | Counter with TTL anchored to first request | Login IP throttle, general API caps |
| fixed-window | Hard cap per discrete period; increments even on block | Daily SMS quota, toll fraud prevention |
| leaky-bucket | Strict minimum interval, zero burst, fully atomic | SMS dispatch, OTP send, password reset emails |
| failure-counter | Two-key lock + counter, exponential or flat backoff | Login lockout, OTP verify lockout |
| token-bucket | Burst-tolerant lazy refill; allows burst then smooths | Authenticated API endpoints |
NestJS: Module Registration
Register the module once — typically in AppModule. Pass any IRateLimitStore implementation.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import {
RateLimitModule,
RateLimitGuard,
RedisStore,
} from '@wazobiatech/rate-limit';
import Redis from 'ioredis';
@Module({
imports: [
RateLimitModule.forRoot({
store: new RedisStore(new Redis({ host: 'localhost', port: 6379 })),
}),
],
providers: [
// Register as a global guard so every decorated endpoint is protected
{ provide: APP_GUARD, useClass: RateLimitGuard },
],
})
export class AppModule {}For tests or single-process services with no Redis dependency:
import { InMemoryStore } from '@wazobiatech/rate-limit';
RateLimitModule.forRoot({ store: new InMemoryStore() })NestJS: @RateLimit() Decorator
@RateLimit(config) attaches rate limit configuration to a controller class or a specific handler method. The guard reads this configuration at runtime using NestJS's Reflector.
If @RateLimit() is not present on either the handler or its class, the guard is a complete no-op for that route.
REST endpoints
import { Controller, Post, Get, Body } from '@nestjs/common';
import { RateLimit } from '@wazobiatech/rate-limit';
@Controller('auth')
export class AuthController {
// Sliding window: 10 requests per 60 s per IP
@Post('login')
@RateLimit({
algorithm: 'sliding-window',
limit: 10,
windowSeconds: 60,
identifierCombo: 'ip',
keyPrefix: 'auth:login:ip',
})
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
// Leaky bucket: strictly one request per 30 s per IP (no burst allowed)
@Post('request-otp')
@RateLimit({
algorithm: 'leaky-bucket',
intervalSeconds: 30,
identifierCombo: 'ip',
keyPrefix: 'auth:otp:ip',
})
async requestOtp(@Body() dto: OtpRequestDto) {
return this.authService.sendOtp(dto.phone);
}
// Failure counter: guard checks the lock; auth service records failures
@Post('verify-otp')
@RateLimit({
algorithm: 'failure-counter',
maxFailures: 5,
windowSeconds: 900,
identifierCombo: 'email',
keyPrefix: 'lock:otp', // <-- must be the LOCK key prefix
})
async verifyOtp(@Body() dto: OtpVerifyDto) {
return this.authService.verifyOtp(dto);
}
// Token bucket: 20 burst, then 5 req/s per authenticated user
@Get('profile')
@RateLimit({
algorithm: 'token-bucket',
limit: 20,
refillRate: 5,
identifierCombo: 'user',
keyPrefix: 'api:profile',
})
async getProfile() {
return this.userService.getProfile();
}
// Fixed window: 3 requests per 10 minutes per phone number
@Post('resend-sms')
@RateLimit({
algorithm: 'fixed-window',
limit: 3,
windowSeconds: 600,
identifierCombo: 'phone',
keyPrefix: 'sms:resend',
})
async resendSms(@Body() dto: ResendDto) {
return this.smsService.resend(dto.phone);
}
}GraphQL resolvers
The guard automatically detects GraphQL execution contexts and unwraps the underlying Express request from GqlExecutionContext. No extra configuration is needed.
Email and phone identifiers are extracted from req.body.variables (where Apollo Server places GraphQL mutation inputs).
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { RateLimit } from '@wazobiatech/rate-limit';
@Resolver()
export class AuthResolver {
// GraphQL mutation: 5 login attempts per 60 s per IP
@Mutation(() => AuthPayload)
@RateLimit({
algorithm: 'fixed-window',
limit: 5,
windowSeconds: 60,
identifierCombo: 'ip',
keyPrefix: 'gql:login:ip',
})
async login(@Args('input') input: LoginInput) {
return this.authService.login(input);
}
// Identifier extracted from mutation variables: { email: '...' }
@Mutation(() => Boolean)
@RateLimit({
algorithm: 'leaky-bucket',
intervalSeconds: 60,
identifierCombo: 'email', // reads from req.body.variables.email
keyPrefix: 'gql:otp:email',
})
async requestOtp(@Args('email') email: string) {
return this.authService.sendOtp(email);
}
}Note: The guard does not set the
Retry-AfterHTTP response header for GraphQL contexts — GraphQL responses use theerrorsarray, not HTTP headers. TheretryAftervalue is still present in the thrownHttpExceptionbody and available to a GraphQL exception filter.
Controller-level decoration
Apply @RateLimit() to the controller class to rate limit all its handlers with the same policy.
// Every handler in this controller is covered by the same 100 req/min IP limit
@RateLimit({
algorithm: 'sliding-window',
limit: 100,
windowSeconds: 60,
identifierCombo: 'ip',
keyPrefix: 'api:global:ip',
})
@Controller('api')
export class ApiController {
@Get('feed')
getFeed() { ... }
@Get('trending')
getTrending() { ... }
@Get('search')
search() { ... }
}Method-level override
A handler-level @RateLimit() always overrides the class-level one. The two policies use independent store keys so their counters never interfere.
@RateLimit({
algorithm: 'sliding-window',
limit: 100,
windowSeconds: 60,
identifierCombo: 'ip',
keyPrefix: 'api:ip',
})
@Controller('api')
export class ApiController {
@Get('feed')
getFeed() { ... } // ← uses class-level: 100 req/min per IP
// Tighter per-user limit on a write operation
@Post('publish')
@RateLimit({
algorithm: 'fixed-window',
limit: 5,
windowSeconds: 3600,
identifierCombo: 'ip+user',
keyPrefix: 'api:publish', // ← different key namespace, different counter
})
publish() { ... } // ← uses method-level: 5 req/hour per IP+user
}NestJS: Guard Behaviour
When a rate-limited request is allowed, the guard attaches the following properties to the request object for downstream use (interceptors, handlers, logging):
| Property | Type | Description |
|---|---|---|
| req.rateLimitIds | { ip?, userId?, email?, phone? } | Identifiers extracted from the request |
| req.rateLimitKey | string | Fully-qualified store key used for this check |
| req.rateLimitResult | { allowed: boolean; retryAfter?: number; ... } | Raw algorithm decision |
When a rate-limited request is blocked:
- The guard throws
HttpExceptionwith status429 Too Many Requests - Response body:
{ statusCode: 429, message: 'Too many requests', retryAfter: <number> } - For HTTP contexts:
Retry-After: <seconds>header is set on the response - For GraphQL contexts: no HTTP header (handled by the GQL error transport)
NestJS: Guard Ordering
When using APP_GUARD, the order in which providers are registered determines execution order.
Login endpoint (user is not yet authenticated):
providers: [
{ provide: APP_GUARD, useClass: RateLimitGuard }, // 1. Rate limit first
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 2. Auth second
]Rate limiting runs before authentication so that attackers can be blocked without going through credential verification.
Protected endpoints using ip+user combo:
The ip+user identifier combo requires req.user to be populated before the rate limit guard runs. Use handler-level @UseGuards() to control order precisely:
@UseGuards(JwtAuthGuard, RateLimitGuard) // Auth first → populates req.user → RateLimit reads it
@Get('protected')
getProtected() { ... }If req.user is absent when the rate limit guard runs with identifierCombo: 'ip+user', the guard throws a descriptive error: buildKey: identifier 'user' is required for combo 'ip+user' but was not extracted.
Express: rateLimitMiddleware
Use rateLimitMiddleware to apply rate limiting in a plain Express app or alongside NestJS in a mixed setup. The factory validates configuration at registration time — misconfigured routes fail at startup, not at first request.
import express from 'express';
import {
rateLimitMiddleware,
InMemoryStore,
RedisStore,
} from '@wazobiatech/rate-limit';
import Redis from 'ioredis';
const app = express();
const store = new RedisStore(new Redis());
// Apply to a specific route
app.post(
'/auth/login',
rateLimitMiddleware(store, {
algorithm: 'sliding-window',
limit: 10,
windowSeconds: 60,
identifierCombo: 'ip',
keyPrefix: 'login:ip',
}),
loginHandler,
);Stacking multiple middlewares
Multiple rateLimitMiddleware instances stack naturally on a single route. Each enforces independently using its own store key namespace.
app.post(
'/sms/send',
// Per-phone pacing: max 3 per 10 minutes
rateLimitMiddleware(store, {
algorithm: 'fixed-window',
limit: 3,
windowSeconds: 600,
identifierCombo: 'phone',
keyPrefix: 'sms:phone:min',
}),
// Per-IP leaky bucket: 1 per 30 s (blocks IP cycling)
rateLimitMiddleware(store, {
algorithm: 'leaky-bucket',
intervalSeconds: 30,
identifierCombo: 'ip',
keyPrefix: 'sms:ip',
}),
smsController.send,
);onError option
Control behaviour when the backing store throws (e.g., Redis is down):
// 'allow' (default): fail open — request passes through, error forwarded to next(err)
rateLimitMiddleware(store, config, { onError: 'allow' })
// 'block': fail closed — return 429 even on store errors
// Use on security-critical paths where a degraded store is worse than downtime
rateLimitMiddleware(store, config, { onError: 'block' })With onError: 'allow' (default), the error is passed to next(err), allowing your Express error handler to log it and return 500, while the request continues.
What middleware attaches to req
On an allowed request, the middleware sets:
req.rateLimitIds // ExtractedIdentifiers
req.rateLimitKey // string (store key)
req.rateLimitResult // { allowed: boolean; retryAfter?: number }On a blocked request (429):
res.set('Retry-After', String(retryAfter));
res.status(429).json({ message: 'Too many requests', retryAfter });WazobiaRateLimit Facade
WazobiaRateLimit is a static class that provides named factory methods for every Wazobia-specific rate limiting preset. Import it instead of instantiating algorithm classes manually — the correct algorithm, key prefix, and limits are already encoded.
import { WazobiaRateLimit, RedisStore } from '@wazobiatech/rate-limit';
import Redis from 'ioredis';
const store = new RedisStore(new Redis());loginIpWindow
SlidingWindow — 20 requests per 10 minutes per IP
Attached to the login endpoint to slow password-spraying attacks from a single IP while allowing legitimate shared-IP users (offices, universities) through.
const ipLimit = WazobiaRateLimit.loginIpWindow(store);
// In your guard or service:
const result = await ipLimit.check(clientIp);
if (!result.allowed) {
throw new TooManyRequestsException(`Retry after ${result.retryAfter}s`);
}Store key: login:ip:{ip}
loginAccountLock
FailureCounter — 5 bad passwords trigger exponential lockout within a 1-hour window
The guard checks isLocked(email). Your auth service calls recordFailure(email) on a wrong password and reset(email) on success.
const accountLock = WazobiaRateLimit.loginAccountLock(store);
async function validateLogin(email: string, password: string) {
// Check before any credential work
if (await accountLock.isLocked(email)) {
throw new TooManyRequestsException();
}
const valid = await checkPassword(email, password);
if (!valid) {
const { locked, failures } = await accountLock.recordFailure(email);
if (locked) {
throw new TooManyRequestsException(`Account locked after ${failures} failures`);
}
throw new UnauthorizedException('Invalid credentials');
}
// Success — reset the counter
await accountLock.reset(email);
return issueTokens(email);
}Backoff schedule:
| Cumulative failures | Lock duration | |---|---| | 5–9 | 30 seconds | | 10–14 | 60 seconds | | 15–19 | 2 minutes | | 20–24 | 4 minutes | | 25–29 | 8 minutes | | 40+ | 1 hour (cap) |
Store keys: fail:login:{email}, lock:login:{email}
Critical: Never call
recordFailureinside a guard. At guard time you have not yet verified the credential — you cannot know if the attempt would succeed. Recording a failure on every guard invocation would lock out legitimate users. Only callrecordFailureafter a confirmed bad credential in the auth layer.
smsPhoneMinuteCap
FixedWindow — 3 SMS per 10 minutes per phone number
Prevents a burst of messages when a user taps "Resend" multiple times in quick succession.
const minuteCap = WazobiaRateLimit.smsPhoneMinuteCap(store);
const result = await minuteCap.check(phoneNumber);
if (!result.allowed) {
throw new TooManyRequestsException('SMS limit reached. Try again in 10 minutes.');
}
// Proceed to send SMSStore key: sms:phone:min:{phone}
smsPhoneDayCap
FixedWindow — 10 SMS per 24 hours per phone number
Daily hard ceiling to prevent toll fraud regardless of how many flows (login, OTP, notification) are triggered.
const dayCap = WazobiaRateLimit.smsPhoneDayCap(store);
const result = await dayCap.check(phoneNumber);
if (!result.allowed) {
throw new TooManyRequestsException('Daily SMS limit reached.');
}Store key: sms:phone:day:{phone}
smsIpLeaky
LeakyBucket — 1 SMS per 30 seconds per IP address
Blocks an attacker from cycling through many phone numbers from the same IP to bypass the per-phone caps.
const ipLeaky = WazobiaRateLimit.smsIpLeaky(store);
const result = await ipLeaky.check(clientIp);
if (!result.allowed) {
throw new TooManyRequestsException(`Please wait ${result.retryAfter}s before sending another SMS.`);
}Store key: sms:ip:{ip}
otpVerifyCounter
FailureCounter — 5 wrong OTPs trigger a flat 15-minute lockout
Unlike the login lockout (exponential), OTP lockout is flat because the OTP itself expires in 15 minutes — there is no benefit to a longer lockout.
const otpGuard = WazobiaRateLimit.otpVerifyCounter(store);
async function verifyOtp(userId: string, submittedCode: string) {
if (await otpGuard.isLocked(userId)) {
throw new TooManyRequestsException('Too many incorrect attempts. Try again in 15 minutes.');
}
const valid = await validateOtpCode(userId, submittedCode);
if (!valid) {
await otpGuard.recordFailure(userId);
throw new UnauthorizedException('Invalid OTP');
}
await otpGuard.reset(userId);
return { success: true };
}Lockout: flat 15 minutes regardless of how many failures.
Store keys: otp:attempts:{identifier}, otp:lock:{identifier}
apiTokenBucket
TokenBucket — 20 burst, 5 tokens/s sustained per user
Default configuration allows a burst of 20 requests immediately (covering parallel dashboard API calls), then a sustained rate of 5 req/s. Both capacity and refillRate are configurable.
// Default: capacity 20, refillRate 5
const apiLimit = WazobiaRateLimit.apiTokenBucket(store);
// Heavier endpoint: smaller burst, slower refill
const heavyEndpointLimit = WazobiaRateLimit.apiTokenBucket(store, {
capacity: 5,
refillRate: 1,
});
// Lightweight endpoint: larger burst allowed
const lightEndpointLimit = WazobiaRateLimit.apiTokenBucket(store, {
capacity: 50,
refillRate: 10,
});
// Usage
const result = await apiLimit.check(userId);
if (!result.allowed) {
throw new TooManyRequestsException();
}
// Optionally set standard rate limit headers
res.set('X-RateLimit-Remaining', String(result.remaining));Store key: api:user:{userId}
Layered SMS defence pattern
Apply all three SMS limiters together on the SMS send endpoint. Each catches a different attack vector and the three together form overlapping coverage:
// Express
app.post('/sms/send',
async (req, res, next) => {
const { phone } = req.body;
const ip = extractIp(req);
const store = app.get('rateLimitStore');
// Layer 1: Per-phone burst prevention
const minuteResult = await WazobiaRateLimit.smsPhoneMinuteCap(store).check(phone);
if (!minuteResult.allowed) {
return res.status(429).json({ message: 'Too many SMS attempts. Try again in 10 minutes.' });
}
// Layer 2: Per-phone daily cap
const dayResult = await WazobiaRateLimit.smsPhoneDayCap(store).check(phone);
if (!dayResult.allowed) {
return res.status(429).json({ message: 'Daily SMS limit reached.' });
}
// Layer 3: Per-IP pacing (blocks multi-phone attacks from one IP)
const ipResult = await WazobiaRateLimit.smsIpLeaky(store).check(ip);
if (!ipResult.allowed) {
return res.status(429).json({ message: `Wait ${ipResult.retryAfter}s before sending.` });
}
next();
},
smsController.send,
);Algorithm Classes — Direct Usage
When the preset configurations in WazobiaRateLimit don't fit your use case, instantiate the algorithm classes directly.
SlidingWindow
Counter-approximation rolling window. Window anchors to the first request; TTL is never extended.
import { SlidingWindow } from '@wazobiatech/rate-limit';
const limiter = new SlidingWindow(store, {
limit: 50,
windowSeconds: 300, // 5 minutes
keyPrefix: 'custom:sw',
});
const result = await limiter.check(identifier);
// result: { allowed, count, remaining, retryAfter? }// Or with a pre-built key (e.g., combining multiple dimensions)
const key = `${keyPrefix}:${ip}:${countryCode}`;
const result = await limiter.checkKey(key);Important: Despite the name, this is a fixed-window counter anchored to the first request. See ARCHITECTURE.md for the distinction and its security implications.
FixedWindow
Hard cap per discrete period. Counter increments on blocked calls (intentional — burns abuse quota faster).
import { FixedWindow } from '@wazobiatech/rate-limit';
const limiter = new FixedWindow(store, {
limit: 10,
windowSeconds: 86400, // 24 hours
keyPrefix: 'daily:cap',
});
const result = await limiter.check(phoneNumber);
// result: { allowed, count, remaining, retryAfter? }
// retryAfter is present (in seconds) when allowed=falseLeakyBucket
Atomic strict-interval enforcer. One allowed action per intervalSeconds; zero burst.
import { LeakyBucket } from '@wazobiatech/rate-limit';
const limiter = new LeakyBucket(store, {
intervalSeconds: 60,
keyPrefix: 'pw:reset:ip',
});
const result = await limiter.check(clientIp);
// result: { allowed, retryAfter? }
// retryAfter is accurate (equals intervalSeconds — no approximation)FailureCounter
Two-key account lockout with configurable backoff.
import { FailureCounter } from '@wazobiatech/rate-limit';
// Exponential backoff (default — doubles every 5 failures)
const loginLock = new FailureCounter(store, {
maxFailures: 5,
windowSeconds: 3600,
keyPrefix: 'fail:login',
lockKeyPrefix: 'lock:login',
});
// Flat backoff
const otpLock = new FailureCounter(store, {
maxFailures: 5,
windowSeconds: 900,
keyPrefix: 'fail:otp',
lockKeyPrefix: 'lock:otp',
backoff: () => 900, // always 15 minutes
});
// Custom backoff function
const customLock = new FailureCounter(store, {
maxFailures: 3,
windowSeconds: 3600,
keyPrefix: 'fail:custom',
lockKeyPrefix: 'lock:custom',
backoff: (failures) => failures * 30, // 30s per failure: 3→90s, 4→120s, …
});
// API
await lock.isLocked(identifier); // boolean
await lock.recordFailure(identifier); // { locked, failures }
await lock.reset(identifier); // void
await lock.check(identifier); // { allowed }
await lock.checkKey(fullyQualifiedLockKey); // { allowed }TokenBucket
Lazy-refill burst-tolerant throttle.
import { TokenBucket } from '@wazobiatech/rate-limit';
const limiter = new TokenBucket(store, {
capacity: 10, // maximum burst
refillRate: 2, // 2 tokens/second = 2 req/s sustained
keyPrefix: 'api:heavy',
});
const result = await limiter.check(userId);
// result: { allowed, tokens (pre-deduction), remaining (post-deduction) }
if (result.allowed) {
res.set('X-RateLimit-Remaining', String(result.remaining));
}Concurrency note: The GET → compute → SET sequence is not atomic in Redis. Under concurrent load, an occasional extra request may slip through at the capacity boundary. This is an accepted trade-off for API throttling where one extra request is not a security risk.
Store Backends
RedisStore
Production store. Uses ioredis. Required for multi-instance deployments (multiple processes or containers must share rate limit state).
import { RedisStore } from '@wazobiatech/rate-limit';
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
});
const store = new RedisStore(redis);RedisStore accepts any ioredis Redis instance. It uses five Redis commands: INCR, EXPIRE, GET, SET, DEL. It works with Redis Cluster but all keys for a single rate limit check must be on the same slot (true by default since keys are derived from the same identifier).
InMemoryStore
In-process store. No external dependencies. Not suitable for multi-process or containerised deployments — each process has its own counters.
import { InMemoryStore } from '@wazobiatech/rate-limit';
const store = new InMemoryStore();Use in tests: Pass to any algorithm class or RateLimitModule.forRoot() for fast, deterministic tests without a Redis connection.
Use in single-process services: Suitable for development environments and single-instance deployments where cross-process state sharing is not needed.
Memory management: Expired entries are evicted lazily (on read). For long-running services with high identifier churn, call cleanup() periodically to evict entries that are never re-read:
// Proactive cleanup every 60 seconds
setInterval(() => store.cleanup(), 60_000);_setWithExpiry (tests only): Write a key with an absolute expiry timestamp rather than a relative TTL. Useful when using jest.useFakeTimers() and you need to pre-seed the store at a precise fake-clock position.
jest.useFakeTimers();
store._setWithExpiry('key', 'value', Date.now() + 30_000);
jest.advanceTimersByTime(29_999);
expect(await store.get('key')).toBe('value'); // still alive
jest.advanceTimersByTime(2);
expect(await store.get('key')).toBeNull(); // expiredIdentifier Extraction and Key Building
The package provides two utilities for extracting rate limit identifiers from HTTP requests and constructing namespaced store keys.
extractIdentifiers(req)
Works with Express Request, NestJS HTTP context, and Apollo GraphQL context (structural duck-typing — no framework import needed).
import { extractIdentifiers } from '@wazobiatech/rate-limit';
const ids = extractIdentifiers(req);
// ids: { ip?: string, userId?: string, email?: string, phone?: string }IP resolution order (first non-empty wins):
x-forwarded-forheader — leftmost entry in the proxy chain
⚠ Only trustworthy whentrust proxyis configured:app.set('trust proxy', 1). Without it, clients can spoof this header to bypass IP-based limits.x-real-ipheader — set by Nginxreq.socket.remoteAddress— direct TCP peer addressreq.ip— Express/Fastify trust-proxy value
userId: req.user?.id → fallback to req.user?.sub (JWT sub claim)
email / phone: req.body.email → req.body.variables.email (Apollo GraphQL) → req.query.email
buildKey(prefix, combo, ids)
Constructs a colon-delimited namespaced store key from a prefix, identifier combination, and extracted identifiers.
import { buildKey } from '@wazobiatech/rate-limit';
buildKey('login', 'ip', ids) // → 'login:1.2.3.4'
buildKey('login', 'ip+user', ids) // → 'login:1.2.3.4:user-123'
buildKey('sms', 'ip+phone', ids) // → 'sms:1.2.3.4:+2348012345678'
buildKey('lock', 'email', ids) // → 'lock:[email protected]'Throws if any identifier required by the combo is absent:
Error: buildKey: identifier 'user' is required for combo 'ip+user'
but was not extracted from the requestThis is intentional — silently falling back to a different key would change rate limit semantics without any warning.
Available identifier combos:
| Combo | Key format | Requires |
|---|---|---|
| ip | prefix:ip | IP header or socket address |
| user | prefix:userId | req.user.id or req.user.sub |
| email | prefix:email | req.body.email or req.body.variables.email |
| phone | prefix:phone | req.body.phone or req.body.variables.phone |
| ip+user | prefix:ip:userId | IP + authenticated user |
| ip+email | prefix:ip:email | IP + email |
| ip+phone | prefix:ip:phone | IP + phone number |
RateLimitConfig Reference
Used by @RateLimit(), rateLimitMiddleware(), and RateLimitGuard internally.
interface RateLimitConfig {
/**
* Which algorithm to apply.
*/
algorithm: 'sliding-window' | 'fixed-window' | 'leaky-bucket' | 'failure-counter' | 'token-bucket';
/**
* Maximum requests allowed within the window.
* Required for: sliding-window, fixed-window.
* Also the burst capacity for: token-bucket.
*/
limit?: number;
/**
* Window duration in seconds.
* Required for: sliding-window, fixed-window.
* Optional (default 3600) for: failure-counter.
*/
windowSeconds?: number;
/**
* Minimum interval in seconds between allowed requests (leaky-bucket only).
* Required for: leaky-bucket.
*/
intervalSeconds?: number;
/**
* Maximum failures before lockout (failure-counter only).
* Required for: failure-counter.
*/
maxFailures?: number;
/**
* Backoff strategy for failure-counter.
* 'exponential' (default): 30 s → 60 s → 120 s → ... → 3600 s cap
* 'flat': constant lockout duration equal to windowSeconds (default: 300 s)
*/
backoff?: 'exponential' | 'flat';
/**
* How identifiers are extracted and combined to form the rate limit key.
*/
identifierCombo: 'ip' | 'user' | 'email' | 'phone' | 'ip+user' | 'ip+email' | 'ip+phone';
/**
* Namespace prefix for all store keys on this endpoint.
* Must be unique per endpoint/algorithm combination to avoid key collisions.
*
* For failure-counter: this must be the LOCK key prefix (e.g. 'lock:login'),
* because the guard only checks the lock flag — not the counter.
*/
keyPrefix: string;
/**
* Tokens added to the bucket per second (token-bucket only).
* Default: 5. Determines the sustained request rate after the initial burst.
*/
refillRate?: number;
}Common configuration mistakes:
// ❌ Wrong: using the counter prefix for failure-counter
@RateLimit({ algorithm: 'failure-counter', keyPrefix: 'fail:login', ... })
// ✅ Correct: use the lock prefix
@RateLimit({ algorithm: 'failure-counter', keyPrefix: 'lock:login', ... })
// The guard checks store.get('lock:login:[email protected]') — must match lockKeyPrefix in FailureCounter
// ❌ Wrong: sliding-window without required fields
@RateLimit({ algorithm: 'sliding-window', identifierCombo: 'ip', keyPrefix: 'x' })
// throws: 'sliding-window requires limit and windowSeconds'
// ✅ Correct
@RateLimit({ algorithm: 'sliding-window', limit: 10, windowSeconds: 60, identifierCombo: 'ip', keyPrefix: 'x' })Testing Patterns
Basic guard test (mock reflector)
import 'reflect-metadata';
import { RateLimitGuard } from '@wazobiatech/rate-limit';
import { InMemoryStore } from '@wazobiatech/rate-limit';
import type { ExecutionContext } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
function makeGuard(config) {
const store = new InMemoryStore();
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(config) } as unknown as Reflector;
const guard = new RateLimitGuard(reflector, store);
return { guard, store };
}
function httpCtx(req = { headers: { 'x-forwarded-for': '1.2.3.4' } }) {
return {
getType: () => 'http',
getHandler: jest.fn().mockReturnValue({}),
getClass: jest.fn().mockReturnValue({}),
switchToHttp: () => ({ getRequest: () => req, getResponse: () => ({ header: jest.fn() }) }),
} as unknown as ExecutionContext;
}
it('blocks after limit', async () => {
jest.useFakeTimers();
const { guard } = makeGuard({ algorithm: 'sliding-window', limit: 2, windowSeconds: 60, keyPrefix: 'test', identifierCombo: 'ip' });
const req = { headers: { 'x-forwarded-for': '1.2.3.4' } };
await guard.canActivate(httpCtx(req));
await guard.canActivate(httpCtx(req));
await expect(guard.canActivate(httpCtx(req))).rejects.toThrow();
jest.useRealTimers();
});Integration test with real DI container
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { Controller } from '@nestjs/common';
import { RateLimitModule, RateLimitGuard, RateLimit, InMemoryStore } from '@wazobiatech/rate-limit';
@Controller()
class TestController {
@RateLimit({ algorithm: 'fixed-window', limit: 2, windowSeconds: 60, keyPrefix: 'test', identifierCombo: 'ip' })
limitedAction(): void {}
}
describe('guard integration', () => {
let guard: RateLimitGuard;
beforeEach(async () => {
jest.useFakeTimers();
const store = new InMemoryStore();
const module = await Test.createTestingModule({
imports: [RateLimitModule.forRoot({ store })],
controllers: [TestController],
}).compile();
guard = module.get(RateLimitGuard);
});
afterEach(() => jest.useRealTimers());
it('reads real @RateLimit() metadata via the DI Reflector', async () => {
const req = { headers: { 'x-forwarded-for': '1.1.1.1' } };
const ctx = {
getType: () => 'http',
getHandler: () => TestController.prototype.limitedAction,
getClass: () => TestController,
switchToHttp: () => ({ getRequest: () => req, getResponse: () => ({ header: jest.fn() }) }),
};
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
await expect(guard.canActivate(ctx as any)).rejects.toThrow();
});
});Testing WazobiaRateLimit presets
import { WazobiaRateLimit, InMemoryStore } from '@wazobiatech/rate-limit';
describe('loginAccountLock', () => {
it('locks after 5 failures with 30 s backoff', async () => {
jest.useFakeTimers();
const store = new InMemoryStore();
const lock = WazobiaRateLimit.loginAccountLock(store);
for (let i = 0; i < 5; i++) await lock.recordFailure('[email protected]');
expect(await lock.isLocked('[email protected]')).toBe(true);
jest.advanceTimersByTime(29_999);
expect(await lock.isLocked('[email protected]')).toBe(true);
jest.advanceTimersByTime(2);
expect(await lock.isLocked('[email protected]')).toBe(false);
jest.useRealTimers();
});
});Testing Express middleware
import { rateLimitMiddleware, InMemoryStore } from '@wazobiatech/rate-limit';
function makeRes() {
const res = { _status: 0, _headers: {}, _body: null };
res.set = (name, value) => { res._headers[name] = value; return res; };
res.status = (code) => { res._status = code; return res; };
res.json = (body) => { res._body = body; return res; };
return res;
}
it('returns 429 after limit', async () => {
jest.useFakeTimers();
const store = new InMemoryStore();
const mw = rateLimitMiddleware(store, {
algorithm: 'sliding-window', limit: 2, windowSeconds: 60,
identifierCombo: 'ip', keyPrefix: 'test',
});
const req = { headers: { 'x-forwarded-for': '1.2.3.4' } };
const res = makeRes();
await mw(req, res, jest.fn());
await mw(req, res, jest.fn());
await mw(req, res, jest.fn());
expect(res._status).toBe(429);
jest.useRealTimers();
});Public API Reference
Everything exported from the package root:
// Wazobia presets facade
export { WazobiaRateLimit } from '@wazobiatech/rate-limit';
// Store interface and implementations
export type { IRateLimitStore } from '@wazobiatech/rate-limit';
export { RedisStore, InMemoryStore } from '@wazobiatech/rate-limit';
// Algorithm classes
export { SlidingWindow, FixedWindow, LeakyBucket, FailureCounter, TokenBucket }
export type { SlidingWindowOptions, RateLimitResult }
export type { FixedWindowOptions, FixedWindowResult }
export type { LeakyBucketOptions, LeakyBucketResult }
export type { FailureCounterOptions, RecordFailureResult }
export type { TokenBucketOptions, TokenBucketResult }
// HTTP / GraphQL utilities
export { extractIdentifiers, buildKey } from '@wazobiatech/rate-limit';
export type { RequestLike, ExtractedIdentifiers, IdentifierCombo }
// Express middleware
export { rateLimitMiddleware } from '@wazobiatech/rate-limit';
export type { MiddlewareOptions } from '@wazobiatech/rate-limit';
// NestJS integration
export { RateLimitGuard, RateLimitModule, RateLimit } from '@wazobiatech/rate-limit';
export type { RateLimitModuleOptions, RateLimitConfig, AlgorithmType }