@stone-js/rate-limit
v0.8.19
Published
Rate limiting for Stone.js: throttle the subject rather than the address, in any context, with a driver you choose.
Maintainers
Readme
Stone.js - Rate limit
Rate limiting for Stone.js. A budget declared where the route is declared, enforced before authentication, authorization and validation, and counted by a limiter you choose: per process by default, shared through Redis, or a driver of your own for the store your deployment already runs on.
The rule it exists to serve: throttle the subject, not the address alone.
Installation
npm install @stone-js/rate-limit
# for the shared Redis limiter (optional):
npm install ioredisPeer dependency:
@stone-js/core.ioredisis an optional peer, imported lazily only when a Redis limiter is used.
Enable it
Declarative:
import { StoneApp } from '@stone-js/core'
import { RateLimit } from '@stone-js/rate-limit'
@RateLimit()
@StoneApp({ name: 'app' })
export class Application {}Imperative, through stone.rateLimit:
import { defineConfig } from '@stone-js/core'
export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.rateLimit', {
default: 'shared',
limiters: [{ name: 'shared', driver: 'redis', url: 'redis://localhost:6379' }],
trustedAddressHeaders: ['cloudfront-viewer-address']
}))Enabling the module limits nothing on its own. Nothing is throttled until a route says so.
Declare a budget
On a route, which is the usual place:
@Post('/auth/code', { rateLimit: { max: 3, window: 900, by: 'email' } })
sendCode (event: IncomingHttpEvent) { … }by is required. The only default it could have is 'address', and that is the one thing this module argues against, so a rule that omitted the word would quietly mean the opposite of what is recommended here. Whoever wants the address writes 'address'.
On a group, which every child inherits:
@EventHandler('/api', { rateLimit: { max: 100, window: 60, by: 'address', scope: 'api' } })
export class ApiHandler {
@Get('/notes', { rateLimit: { max: 20, window: 60, by: 'address' } })
notes (event: IncomingHttpEvent) { … }
}Both budgets are kept: the group's and the route's, counted separately, enforced in that order.
On a handler method, the form that needs no router at all (a command, a queue consumer, a single-handler application):
class AuthController {
@Throttle({ max: 3, window: 900, by: 'email' })
sendCode (event: IncomingEvent) { … }
}Or once, for everything that declares nothing, through stone.rateLimit.global.
What a rule says
| Option | Meaning |
|---|---|
| max | How many requests the window allows. |
| window | How long the window lasts, in seconds. |
| by | Required. What the budget belongs to: a request field ('email'), alternatives ('phone\|email', first present wins), 'user' for the authenticated principal, 'address', or a function (event) => string \| undefined. |
| backstop | The per-address bucket that runs alongside a subject budget, as a multiple of max. Defaults to ten times. false runs the subject budget alone. |
| scope | A bucket shared with every rule naming it, instead of one per route. This is how a ceiling spanning several routes is expressed. |
| limiter | Which configured limiter counts this rule. |
Why the subject, and not the address
Throttling by address assumes one address is one person. On mobile networks using carrier-grade NAT, the norm across much of the world, hundreds of unrelated subscribers share one public address. A per-address quota then refuses legitimate users at random, and hardest exactly where the audience is largest.
So the budget belongs to the thing being protected: the account, the mailbox, the phone number. The address keeps a much looser bucket whose only job is to stop one machine enumerating subjects in bulk.
Subjects are hashed before they are used as keys. A key is read by whoever debugs the store, and a mailbox has no business being there.
Where the subject lives
A field name covers the common cases: this module reads it from the route parameters, then the body, then the query. Anything else is a function, and that is the honest answer, because an application knows where its subject lives and this module would only be guessing:
@Post('/auth/code', { rateLimit: { max: 3, window: 900, by: (event) => event.getHeader('x-account') } })A subject read never breaks the route. Whatever fails, a resolver that throws included, the request falls back to the address bucket at the backstop, and a warning says which rule was downgraded.
by: 'user' runs before authentication
Enforcement sits ahead of authentication on purpose, so at that point nothing has resolved a principal yet unless the application resolved one earlier. Say where yours lives:
blueprint.set('stone.rateLimit.principal', (event) => event.getUser()?.userId)The default reads event.getUser?.() and takes its id, sub or userId. With nothing to bill,
the rule falls back to the address at the backstop, ten times the limit, and warns: correct as a
behaviour, unacceptable as a silence, since a budget of three would otherwise allow thirty with every
test still green.
Why scope exists
A rule declared on a group is copied onto each child, and at enforcement time nothing records which ancestor a rule came from. Unscoped, a group's max: 100 is therefore a hundred per child route. Naming a scope makes it the shared ceiling it looks like, and lets unrelated routes share one budget too.
Limiters
memory is always available, needs no configuration, and counts per process: fine for one instance, and not a limit at all across several, since each grants the whole budget again.
redis is the one to use wherever the application runs as more than one process. One round trip per request, no read: the window index is part of the key, so a new window is a new key starting at zero, and the counter expires on its own.
A deployment that already has a store can plug it in, which is how a serverless application counts where its state lives. Declare it with the other limiters, not from a provider: the container is rebuilt for every event, so anything registered imperatively during one event is gone for the next.
blueprint.set('stone.rateLimit', {
default: 'table',
limiters: [{
name: 'table',
factory: () => ({ hit: async (key, limit, windowMs) => await countInMyTable(key, limit, windowMs) })
}]
})hit receives the limit rather than holding it, so an implementation that can refuse atomically through a conditional write has what it needs to express the condition, and pays nothing for a refusal.
What a caller is told
A refusal answers 429 with Retry-After. The error carries its own status, so an HTTP platform
answers 429 and a CLI or queue consumer reads RateLimitError directly. It also carries the stable
code RATE_LIMIT_EXCEEDED, so an application maps it to its own error envelope without importing
this package into its error handler.
While a caller is within its budget, the response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, reporting the budget closest to being exceeded. Set stone.rateLimit.headers to false to publish nothing.
Addresses behind a proxy
No forwarded header is read unless you name it, because a forwarded header is client-spoofable unless an edge you trust overwrites it, and reading one by default would hand every caller an unlimited supply of identities. Name the one your own edge guarantees, through trustedAddressHeaders.
Documentation
See the official documentation for the full guide.
