Extensions
Rate limit
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.
#Install
npm i @stone-js/rate-limit
npm i ioredis # only for the shared Redis limiter#Enable it
The principle
A limit is part of what an endpoint promises, so it belongs next to the endpoint, in the same declaration as its path and its guard.
In Stone.js
Enabling the module puts the enforcement in place and limits nothing. A route says rateLimit, and that route is throttled: no separate registry to keep in step with the routes it protects.
import { StoneApp } from '@stone-js/core'
import { RateLimit } from '@stone-js/rate-limit'
@RateLimit()
@StoneApp({ name: 'app' })
export class Application {}import { defineConfig, defineStoneApp } from '@stone-js/core'
import { rateLimitBlueprint } from '@stone-js/rate-limit'
// Enable the module on the manifest, exactly where the decorator sits
export const App = defineStoneApp({ name: 'app' }, [rateLimitBlueprint])
// Then configure it
export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.rateLimit', {
default: 'shared',
limiters: [{ name: 'shared', driver: 'redis', url: 'redis://localhost:6379' }],
trustedAddressHeaders: ['cloudfront-viewer-address']
}))#Declare a budget
On a route, which is the usual place. Three requests per subject per fifteen minutes, keyed on the mailbox the code would be sent to:
import { Post } from '@stone-js/router'
export class AuthController {
@Post('/auth/code', { rateLimit: { max: 3, window: 900, by: 'email' } })
sendCode (event: IncomingHttpEvent) { /* ... */ }
}import { defineEventHandler, definePost } from '@stone-js/router'
export const AuthController = defineEventHandler({}, {
sendCode: definePost('/auth/code', { rateLimit: { max: 3, window: 900, by: 'email' } })
})#On a group
A budget on a group holds for every route under it, alongside each route's own. Both promises are kept, counted separately, and enforced group-first.
@EventHandler('/api', { rateLimit: { max: 100, window: 60, scope: 'api' } })
export class ApiHandler {
@Get('/notes', { rateLimit: { max: 20, window: 60 } })
notes (event: IncomingHttpEvent) { /* ... */ }
}#Without a router
A command, a queue consumer or a single-handler application has no route to declare on. @Throttle declares the budget on the handler method itself, and the global rule covers everything that declares nothing.
import { Throttle } from '@stone-js/rate-limit'
class AuthController {
@Throttle({ max: 3, window: 900, by: 'email' })
sendCode (event: IncomingEvent) { /* ... */ }
}#What a rule says
| Option | Type | Default | Description |
|---|---|---|---|
| max* | number | · | How many requests the window allows. |
| window* | number | · | How long the window lasts, in seconds. |
| by | string | 'address' | What the budget belongs to: a request field ('email'), alternatives ('phone|email', first present wins), 'user' for the authenticated principal, or 'address'. |
| backstop | number | false | 10 | The per-address bucket that runs alongside a subject budget, as a multiple of max. false runs the subject budget alone. |
| scope | string | · | A bucket shared with every rule naming it, instead of one per route. How a ceiling spanning several routes is expressed. |
| limiter | string | · | Which configured limiter counts this rule. Defaults to the application default. |
#Throttle the subject, not the address
A per-address quota assumes one address is one person, and refuses hardest exactly where the audience is largest.
On mobile networks using carrier-grade NAT, the norm across much of the world, hundreds of unrelated subscribers share one public address. So the budget belongs to the thing actually being protected: the account, the mailbox, the phone number. The address keeps a much looser bucket, the backstop, whose only job is to stop one machine enumerating subjects in bulk.
A request that carries no subject is billed to that looser bucket rather than the strict one, so a malformed request cannot spend an account's budget, and omitting a field is not a way to buy an unlimited one.
#Limiters
| limiter | Type | Description |
|---|---|---|
| memory | built-in | Per-process fixed window. Zero-config and always available. Across several instances it is not a limit, since each one grants the whole budget again. |
| redis | ioredis | Shared across instances. One round trip per request and 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. |
| your own | contract | Register a limiter for the store your deployment already runs on. |
A limiter 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.
import { RateLimitManager } from '@stone-js/rate-limit'
export class TableLimiterProvider implements IServiceProvider {
constructor (private readonly container: IContainer) {}
register (): void {
this.container.make<RateLimitManager>(RateLimitManager).register('table', {
hit: async (key, limit, windowMs) => await countInMyTable(key, limit, windowMs)
})
}
}#What a caller is told
A refusal answers 429 with Retry-After. The error carries its own status, so an HTTP platform answers 429 while a CLI or a queue consumer reads RateLimitError directly: nothing in this module knows which platform is answering.
Within 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.
#Behind a proxy
blueprint.set('stone.rateLimit.trustedAddressHeaders', ['cloudfront-viewer-address'])Whatever the address comes from, the port is stripped before it becomes a key. A port is per connection, so leaving it in gives each connection its own budget: a limiter that fires only on the callers well-behaved enough to reuse a keep-alive connection.
#Where it runs
On the router layer, outside every other route middleware: authentication is next, then authorization and resources, then validation. Rejecting a caller past its budget is worth nothing once the database has been read and the mail provider called.