Rate Limit
Contract
@modularityjs/rate-limit defines the abstract RateLimiterService:
interface RateLimitResult {
readonly allowed: boolean;
readonly remaining: number;
readonly total: number;
readonly resetAt: number; // Unix timestamp ms
}
abstract class RateLimiterService {
abstract consume(key: string, points?: number): Promise<RateLimitResult>;
abstract get(key: string): Promise<RateLimitResult>;
abstract reset(key: string): Promise<void>;
}consume(key, points)— attempt to consume points (default 1). Returnsallowed: falseif limit exceeded.get(key)— check current state without consuming.reset(key)— clear the counter for a key.
Drivers
Memory (@modularityjs/rate-limit-memory)
In-memory fixed-window rate limiter. For development and single-instance deployments.
limit and windowMs are configured once on the contract via RateLimitModule.forRoot(...), so swapping the driver never resets them; the driver's own forRoot carries only transport-specific options.
import { RateLimitModule } from '@modularityjs/rate-limit';
import { RateLimitMemoryModule } from '@modularityjs/rate-limit-memory';
const modules = [
RateLimitModule.forRoot({ limit: 100, windowMs: 60_000 }),
RateLimitMemoryModule, // or RateLimitMemoryModule.forRoot({ maxEntries }) to tune the store
];Contract options (RateLimitModule.forRoot):
| Option | Default | Description |
|---|---|---|
limit | 100 | Maximum points per window |
windowMs | 60000 | Window duration in milliseconds |
Memory-driver options (RateLimitMemoryModule.forRoot):
| Option | Default | Description |
|---|---|---|
maxEntries | 10000 | Cap on tracked keys; once reached, consume returns allowed: false for new keys until older entries expire or evict, and a ModularityJsRateLimitMemoryEntryCapReached warning is emitted once per overflow episode |
evictionIntervalMs | — | Optional periodic sweep that removes expired entries; unset means entries are only purged lazily on access |
Redis (@modularityjs/rate-limit-redis)
Redis-backed rate limiter. consume runs a Lua script combining INCRBY + PEXPIRE + PTTL (PEXPIRE only on the first hit of a window; PTTL computes resetAt); get runs a separate Lua script that combines GET + PTTL. For distributed systems where multiple instances share limits.
import { RateLimitModule } from '@modularityjs/rate-limit';
import { RedisModule } from '@modularityjs/redis';
import { RateLimitRedisModule } from '@modularityjs/rate-limit-redis';
const modules = [
RedisModule.forRoot({ url: 'redis://localhost:6379' }),
RateLimitModule.forRoot({ limit: 100, windowMs: 60_000 }),
RateLimitRedisModule, // or RateLimitRedisModule.forRoot({ keyNamespace })
];Redis-driver options (RateLimitRedisModule.forRoot):
| Option | Default | Description |
|---|---|---|
keyNamespace | 'rl:' | Prefix for Redis keys |
HTTP Bridge (@modularityjs/http-rate-limit)
Automatically rate-limits all HTTP requests via HttpServer.onRequest() hook. Throws RateLimitExceededException (HTTP 429) when the limit is exceeded.
The extension is fail-closed: if the underlying RateLimiterService.consume() throws (e.g. Redis is unreachable), the error is reported via process.emitWarning and the request is refused with a 503. A limiter outage must not silently disable brute-force protection process-wide. Set HttpRateLimitConfig.failOpen: true (or failOpen on an individual @RateLimit) only where availability genuinely outranks the protection — never on login, OTP, token or password-reset routes.
import { HttpRateLimitModule } from '@modularityjs/http-rate-limit';
const modules = [
RateLimitModule.forRoot({ limit: 100, windowMs: 60_000 }),
RateLimitMemoryModule,
HttpRateLimitModule,
];By default, the rate limit key is extracted from the x-forwarded-for header (falling back to 'unknown'). Customize with forRoot():
import { getRequestAuth } from '@modularityjs/http-auth';
HttpRateLimitModule.forRoot({
keyExtractor: (request) => {
const identity = getRequestAuth(request);
return identity
? `user:${identity.id}`
: ((request.headers['x-forwarded-for'] as string) ?? 'anon');
},
});Behind a load balancer,
request.ipis the proxy's address unless you enableHttpModuleConfig.trustProxy— without it, an IP-keyed limiter puts every client in one bucket (and the limit is trivially bypassed once that shared window resets).
A denied consume does not consume points: a multi-point request that would exceed the limit leaves the window untouched, so a client retrying with fewer points can still succeed.
Per-route limits
@RateLimit gives one route its own bucket instead of the global one, so /auth/login can sit at 5/min while the rest of the app keeps its allowance:
import { RateLimit } from '@modularityjs/http-rate-limit';
@Controller('/auth')
class AuthController {
@RateLimit({ limit: 5, windowMs: 60_000 })
@Post('/login')
login() {}
// Several routes drawing from ONE budget: return the same key from each.
@RateLimit({ limit: 3, windowMs: 3_600_000, key: (r) => `pw:${r.ip}` })
@Post('/password/reset')
requestReset() {}
}A custom key is namespaced under route: and otherwise used as returned — the route's own id is deliberately not mixed in, which is what lets two routes share a budget. Omit it and the key is the route's own namespace plus keyExtractor.
Enforcement happens in the same onRequest gate as the global bucket, ahead of body parsing and guards. The gate re-derives the route from the adapter's route table; when it can identify the route unambiguously it charges that route's bucket there, and the route's own interceptor stands down. A route the gate cannot attribute — an ambiguous pattern, a non-canonical path — falls back to the global bucket, and the route's interceptor charges its bucket if the handler runs. The stand-down marker is keyed by route, so the gate can never silence a route it did not charge.
Non-canonical paths are not guessed
/auth/login/ and //auth//login are 404s under Fastify's defaults. The gate answers "unknown" for them rather than guessing login, because charging a bucket for a handler that never runs lets anyone drain a tight login limit without touching the login route.
Programmatic Usage
Inject RateLimiterService directly for non-HTTP rate limiting:
@Injectable()
class LoginService {
constructor(
@Inject(RateLimiterService) private readonly limiter: RateLimiterService,
) {}
async login(username: string, password: string) {
const result = await this.limiter.consume(`login:${username}`);
if (!result.allowed) {
throw new RateLimitExceededException(result.resetAt);
}
// proceed with authentication
}
}Use cases beyond HTTP:
- Login attempt throttling (per username)
- Queue message processing rate control
- External API call budgeting
- Scheduled job frequency limiting
@RateLimited (rate-limit-plugins)
@modularityjs/rate-limit-plugins lifts rate limiting off the HTTP request pipeline and onto any method, so a queue consumer, a scheduled job, or a CLI command can hold a budget too — the callers most likely to hammer a metered third-party API, and the ones http-rate-limit structurally cannot reach:
import { RateLimited } from '@modularityjs/rate-limit-plugins';
@Injectable()
class VendorSync {
@RateLimited({
limit: 100,
windowMs: 60_000,
key: (args) => `vendor:${String(args[0])}`,
})
async pull(tenantId: string): Promise<void> {
/* ... */
}
}Wire RateLimitPluginsModule alongside PluginsModule, RateLimitModule, and a driver. It shares @RateLimit's option names, its fail-closed failOpen default, and its exceptions (RateLimitExceededException over limit, ServiceUnavailableException on a limiter outage) without either package depending on the other. Keys are namespaced method:, so a method bucket never collides with the HTTP global bucket or a route: bucket sharing the same driver. It rides the method-interceptor seam at order 50 — outermost, so a cache hit cannot bypass the gate. Full option table in the plugins guide.