Rate Limit
@miiajs/rate-limit adds fixed-window rate limiting to MiiaJS. The primary path is the guard flow: configure a default policy once, enforce it app-wide with RateLimitGuard, and tune individual routes with @RateLimit / @SkipRateLimit. A separate rateLimit() middleware protects the perimeter - including requests that never match a route. Both are thin layers over the same RateLimiter core, share one RateLimitStore contract, and emit standard RateLimit-* headers. The package has no runtime dependencies.
Installation
bun add @miiajs/rate-limit
npm install @miiajs/rate-limit
pnpm add @miiajs/rate-limit
yarn add @miiajs/rate-limit
@miiajs/core is a required peer dependency.
Quick start
Configure a default policy in your root module and enforce it on every matched route with the global guard:
import { Miia, Module } from '@miiajs/core'
import { RateLimitGuard, RateLimitModule } from '@miiajs/rate-limit'
@Module({
imports: [
RateLimitModule.configure({ limit: 100, window: '1m' }),
],
controllers: [UserController],
})
class AppModule {}
const app = new Miia().register(AppModule)
app.useGuard(RateLimitGuard)
await app.listen(3000)
Each client - keyed by IP by default - gets 100 requests per minute, shared across all matched routes. When the quota runs out, the request is rejected with a structured 429:
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Too Many Requests",
"details": { "retryAfter": 42 }
}
window accepts milliseconds or a duration string: '500ms', '10s', '5m', '1h', '1d'.
Per-route policies
@RateLimit(policy) sets a policy for a single route (on a method) or for every route of a controller (on the class). The most specific policy wins - the same precedence as @BodyLimit: a method policy replaces the class policy and the global guard; a class policy replaces the global guard. A decorated route keeps its own counter and does not consume the global quota.
import { Controller, Get, type RequestContext } from '@miiajs/core'
import { RateLimit, SkipRateLimit } from '@miiajs/rate-limit'
@Controller('search')
class SearchController {
// Replaces the global 100/min for this route: callers get a true 1000/min,
// and these requests do not count against the global quota.
@RateLimit({ limit: 1000, window: '1m' })
@Get()
search(ctx: RequestContext) {
/* ... */
}
// No rate limit on this route at all.
@SkipRateLimit()
@Get('health')
health() {
return { ok: true }
}
}
Route policies inherit store, keyGenerator, headers, and message from RateLimitModule.configure() - the policy fields you pass to the decorator override the configured defaults.
One exception to the @BodyLimit analogy: skip beats specificity. @SkipRateLimit() disables rate limiting entirely on its scope - including a @RateLimit on the same method, and a class-level @SkipRateLimit() also disables method-level @RateLimit policies inside that controller. The precedence chain only applies between @RateLimit policies.
@SkipRateLimit() - not @SkipGuard(RateLimitGuard). The generic skip only catches the global guard and explicit factory guards; policies applied through @RateLimit are out of its reach.Also note that app.useGuard(RateLimitGuard) constructs the guard at startup. Without RateLimitModule.configure() the app fails fast with a configuration error, even if every route declares its own @RateLimit.RateLimitModule.configure() in the same module tree as the controllers that use @RateLimit. The DI container is flat, so the placement inside the imports graph does not matter - but a feature module registered through a separate, earlier app.register() call constructs its guards before the configuration provider exists. A @RateLimit with a full policy (limit + window) works without the module; the error only fires when neither the decorator nor the module provides them.Perimeter limiting
Global guards run only on matched routes - a scanner probing non-existent paths never reaches them. The rateLimit() middleware runs before routing, so it covers 404s too, and it works standalone with no module or DI setup:
import { rateLimit } from '@miiajs/rate-limit'
app.use(rateLimit({ limit: 300, window: '1m' }))
rateLimit() middleware with the guard flow. The two layers know nothing about each other: @SkipRateLimit() and per-route replacement have no effect on middleware. Pick the guard flow for business policies; reach for the middleware when you need perimeter coverage or a zero-setup limiter.The middleware is also the deliberate way to stack limits: a route-bound @Use(rateLimit({ ... })) counts independently of whatever guard policy is active on the same route.
Configuration options
Policy
Accepted by @RateLimit, RateLimitModule.configure(), rateLimit(), and new RateLimiter():
| Option | Type | Default | Description |
|---|---|---|---|
limit | number | - | Max requests per window |
window | number | string | - | Window length: ms or '500ms' / '10s' / '5m' / '1h' / '1d' |
blockDuration | number | string | - | Optional ban once the limit is exceeded |
blockBackoff | number | 1 | Multiplier that grows the ban per repeat offence; any value > 1 requires maxBlockDuration |
maxBlockDuration | number | string | blockDuration | Ceiling for the escalating ban |
strikeReset | number | string | maxBlockDuration | Clean-behaviour grace (measured from the end of the block) before strikes reset |
Middleware options
rateLimit() extends the policy with:
| Option | Type | Default | Description |
|---|---|---|---|
store | RateLimitStore | new MemoryStore() | Counter storage backend |
keyGenerator | (ctx) => string | ctx.ip ?? 'unknown' | Builds the client key |
headers | 'draft-6' | 'legacy' | false | 'draft-6' | Response header format |
skip | (ctx) => boolean | - | Bypass limiting for a request |
message | string | 'Too Many Requests' | 429 message |
prefix | string | unique per instance | Key namespace; set explicitly to share a bucket |
Module options
RateLimitModule.configure() takes the same options minus skip. They become the defaults for the global guard and for every @RateLimit policy; the configured store (or a MemoryStore created once) is shared by all guards.
Key generation
The default key is ctx.ip ?? 'unknown'. ctx.ip is the socket address unless you tell the app which proxy headers to trust:
new Miia({ trustProxy: true }) // leftmost X-Forwarded-For entry
new Miia({ trustProxy: 'cf-connecting-ip' }) // a single trusted header (Cloudflare)
new Miia({ trustProxy: ['cf-connecting-ip', 'x-real-ip'] }) // first present, in priority order
trustProxy: true takes the leftmost X-Forwarded-For entry, which the client controls when your proxy appends to the header instead of overwriting it - an attacker can rotate fake IPs to bypass limits. Behind Cloudflare, Fly.io, or nginx with X-Real-IP, prefer the vendor header form.Any other keying strategy is a custom keyGenerator - for example, per-user limits after authentication:
RateLimitModule.configure({
limit: 100,
window: '1m',
keyGenerator: (ctx) => ctx.user?.id ?? ctx.ip ?? 'unknown',
})
Response headers
With the default 'draft-6' mode every response carries:
| Header | Value |
|---|---|
RateLimit-Limit | Policy limit |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds until the window resets (delta) |
RateLimit-Policy | 100;w=60 (limit and window seconds) |
'legacy' emits the same values as X-RateLimit-*. A blocked request always gets Retry-After in seconds - even with headers: false.
headers: false restores the fast path if you measure the difference and the headers are not needed.Blocking repeat offenders
blockDuration turns the limit into a ban: once a client exceeds the limit, the key is blocked for the given duration and requests are rejected without counting. The request that triggers the block already gets Retry-After equal to the full block duration, and once the block expires the client starts with a fresh window.
// 5 attempts per minute; exceeding locks the key for 15 minutes
@RateLimit({ limit: 5, window: '1m', blockDuration: '15m' })
@Post('login')
login(ctx: RequestContext) {
/* ... */
}
Geometric backoff
A fixed ban treats the first offence the same as the hundredth. blockBackoff makes the ban grow geometrically per repeat offence: the first block is blockDuration, the next blockDuration × blockBackoff, then blockDuration × blockBackoff², and so on, clamped at maxBlockDuration. This is opt-in - the default blockBackoff of 1 keeps the fixed-ban behaviour, and any value above 1 requires maxBlockDuration so escalation is always bounded.
// 1s ban, doubling each repeat offence, capped at 1 hour:
// 1s -> 2s -> 4s -> 8s -> ... -> 1h -> 1h
@RateLimit({
limit: 5,
window: '1m',
blockDuration: '1s',
blockBackoff: 2,
maxBlockDuration: '1h',
})
@Post('login')
login(ctx: RequestContext) {
/* ... */
}
Escalation is tracked by an accumulated strike count. Strikes reset to zero in full after a grace period of clean behaviour, measured from the end of the block (strikesExpireAt = blockExpiresAt + strikeReset). strikeReset defaults to maxBlockDuration - so once a client has served the longest possible ban and then stays quiet for that same span, the next offence starts again at the base blockDuration. There is no gradual decay: it is full memory until the grace elapses, then a clean slate.
MemoryStore keeps strikes per process; a shared store (Redis) carries the decision atomically in its increment script. With blockBackoff unset or 1, MemoryStore behaves exactly as before - it drops the key the moment the block expires, with no strike memory.Bucket scope
@RateLimiton a method - one counter per route.@RateLimiton a class - one counter shared by all routes of that controller.- The global guard - one counter shared by all matched routes.
Replacement means exactly one guard policy is active per route, so buckets of different levels never interact. To share one bucket between routes or controllers deliberately, give their policies the same explicit prefix:
// login and password reset drain the same 5/min budget
@RateLimit({ limit: 5, window: '1m', prefix: 'auth-attempts' })
Custom stores
Storage implements two methods. increment both counts the hit and decides blocking atomically - the contract is shaped for a Redis Lua script where the whole decision is a single round trip:
import type { IncrementOptions, RateLimitStore, StoreRecord } from '@miiajs/rate-limit'
class MyStore implements RateLimitStore {
increment(key: string, opts: IncrementOptions): Promise<StoreRecord> {
// opts: { windowMs, limit, blockDurationMs, blockBackoff, maxBlockDurationMs, strikeResetMs }
// return { totalHits, timeToExpireMs, isBlocked, timeToBlockExpireMs, strikes }
}
reset(key: string): Promise<void> {
// drop the key
}
}
RateLimitModule.configure({ limit: 100, window: '1m', store: new MyStore() })
The bundled MemoryStore keeps counters in a Map with lazy expiry and no timers - suitable for a single process. Multi-instance deployments need a shared store; a first-party Redis store is on the roadmap.
Standalone RateLimiter
The core class works outside HTTP - useful for queues, jobs, or anything with a string key. limit() returns a result object and never throws:
import { RateLimiter } from '@miiajs/rate-limit'
const limiter = new RateLimiter({ limit: 10, window: '1s' })
const result = await limiter.limit(`webhook:${tenantId}`)
if (!result.success) {
// result.retryAfterMs tells how long to back off
}
Testing
TestApp.request() accepts an ip option, so per-client limits are easy to exercise:
import { describe, expect, it } from 'bun:test'
import { TestApp } from '@miiajs/core/testing'
it('limits each client independently', async () => {
const app = await TestApp.create(AppModule).useGuard(RateLimitGuard).compile()
for (let i = 0; i < 3; i++) {
await app.request('GET', '/users', { ip: '10.0.0.1' })
}
const blocked = await app.request('GET', '/users', { ip: '10.0.0.1' })
const other = await app.request('GET', '/users', { ip: '10.0.0.2' })
expect(blocked.status).toBe(429)
expect(other.status).toBe(200)
await app.close()
})
Exports
import {
MemoryStore,
RATE_LIMIT_OPTIONS,
RateLimit,
RateLimitGuard,
RateLimitModule,
RateLimiter,
SkipRateLimit,
parseWindow,
rateLimit,
setRateLimitHeaders,
} from '@miiajs/rate-limit'
import type {
HeadersMode,
KeyGenerator,
RateLimitModuleOptions,
RateLimitOptions,
RateLimitPolicy,
RateLimitResult,
RateLimitStore,
RateLimiterOptions,
StoreRecord,
} from '@miiajs/rate-limit'
See also
- Middleware - the onion model the
rateLimit()middleware plugs into - Guards - how
RateLimitGuardand@SkipGuardwork - Exceptions - the
TooManyRequestsException(429) shape - Testing -
TestAppand theiprequest option