fastify-rate-limit
Advanced tools
Comparing version 2.4.0 to 3.0.0
@@ -5,2 +5,11 @@ import * as http from 'http'; | ||
declare namespace fastifyRateLimit { | ||
interface FastifyRateLimitStoreCtor { | ||
new (options: FastifyRateLimitOptions): FastifyRateLimitStore; | ||
} | ||
interface FastifyRateLimitStore { | ||
incr(key: string, callback: ( error: Error|null, result?: { current: number, ttl: number } ) => void): void; | ||
child(routeOptions: fastify.RouteOptions<http.Server, http.IncomingMessage, http.ServerResponse> & { path: string, prefix: string }): FastifyRateLimitStore; | ||
} | ||
interface FastifyRateLimitOptions { | ||
@@ -11,7 +20,10 @@ global?: boolean; | ||
cache?: number; | ||
store?: FastifyRateLimitStoreCtor; | ||
whitelist?: string[] | ((req: fastify.FastifyRequest<http.IncomingMessage>, key: string) => boolean); | ||
redis?: any; | ||
skipOnError?: boolean; | ||
ban?: number; | ||
keyGenerator?: (req: fastify.FastifyRequest<http.IncomingMessage>) => string | number; | ||
errorResponseBuilder?: (req: fastify.FastifyRequest<http.IncomingMessage>, context: errorResponseBuilderContext) => object; | ||
addHeaders?: AddHeaders; | ||
} | ||
@@ -23,2 +35,9 @@ | ||
} | ||
interface AddHeaders { | ||
'x-ratelimit-limit'?: boolean, | ||
'x-ratelimit-remaining'?: boolean, | ||
'x-ratelimit-reset'?: boolean, | ||
'retry-after'?: boolean | ||
} | ||
} | ||
@@ -25,0 +44,0 @@ |
56
index.js
@@ -26,2 +26,9 @@ 'use strict' | ||
globalParams.addHeaders = Object.assign({ | ||
'x-ratelimit-limit': true, | ||
'x-ratelimit-remaining': true, | ||
'x-ratelimit-reset': true, | ||
'retry-after': true | ||
}, settings.addHeaders) | ||
// define the global maximum of request allowed | ||
@@ -40,2 +47,3 @@ globalParams.max = (typeof settings.max === 'number' || typeof settings.max === 'function') | ||
globalParams.whitelist = settings.whitelist || null | ||
globalParams.ban = settings.ban || null | ||
@@ -47,6 +55,11 @@ // define the name of the app component. Related to redis, it will be use as a part of the keyname define in redis. | ||
if (settings.redis) { | ||
pluginComponent.store = new RedisStore(settings.redis, 'fastify-rate-limit-', globalParams.timeWindow) | ||
if (settings.store) { | ||
const Store = settings.store | ||
pluginComponent.store = new Store(globalParams) | ||
} else { | ||
pluginComponent.store = new LocalStore(globalParams.timeWindow, settings.cache, fastify) | ||
if (settings.redis) { | ||
pluginComponent.store = new RedisStore(settings.redis, 'fastify-rate-limit-', globalParams.timeWindow) | ||
} else { | ||
pluginComponent.store = new LocalStore(globalParams.timeWindow, settings.cache, fastify) | ||
} | ||
} | ||
@@ -58,3 +71,3 @@ | ||
globalParams.errorResponseBuilder = (req, context) => ({ statusCode: 429, error: 'Too Many Requests', message: `Rate limit exceeded, retry in ${context.after}` }) | ||
globalParams.errorResponseBuilder = (req, context) => ({ statusCode: context.statusCode, error: 'Too Many Requests', message: `Rate limit exceeded, retry in ${context.after}` }) | ||
globalParams.isCustomErrorMessage = false | ||
@@ -73,5 +86,10 @@ | ||
const current = Object.create(pluginComponent) | ||
const mergedRateLimitParams = makeParams(routeOptions.config.rateLimit) | ||
if (!routeOptions.config.rateLimit.timeWindow) { | ||
// load the global timewindow if it is missing from the route config | ||
routeOptions.config.rateLimit.timeWindow = mergedRateLimitParams.timeWindow | ||
} | ||
current.store = pluginComponent.store.child(routeOptions) | ||
// if the current endpoint have a custom rateLimit configuration ... | ||
buildRouteRate(current, makeParams(routeOptions.config.rateLimit), routeOptions) | ||
buildRouteRate(current, mergedRateLimitParams, routeOptions) | ||
} else if (routeOptions.config.rateLimit === false) { | ||
@@ -133,3 +151,3 @@ // don't apply any rate-limit | ||
function onIncr (err, current) { | ||
function onIncr (err, { current, ttl }) { | ||
if (err && params.skipOnError === false) { | ||
@@ -142,3 +160,4 @@ return next(err) | ||
res.header('x-ratelimit-limit', maximum) | ||
res.header('x-ratelimit-remaining', maximum - current) | ||
.header('x-ratelimit-remaining', maximum - current) | ||
.header('x-ratelimit-reset', Math.floor(ttl / 1000)) | ||
@@ -159,7 +178,20 @@ if (typeof params.onExceeding === 'function') { | ||
res.code(429) | ||
.header('x-ratelimit-limit', maximum) | ||
.header('x-ratelimit-remaining', 0) | ||
.header('retry-after', params.timeWindow) | ||
.send(params.errorResponseBuilder(req, { after, max: maximum })) | ||
if (params.addHeaders['x-ratelimit-limit']) { res.header('x-ratelimit-limit', maximum) } | ||
if (params.addHeaders['x-ratelimit-remaining']) { res.header('x-ratelimit-remaining', 0) } | ||
if (params.addHeaders['x-ratelimit-reset']) { res.header('x-ratelimit-reset', Math.floor(ttl / 1000)) } | ||
if (params.addHeaders['retry-after']) { res.header('retry-after', params.timeWindow) } | ||
const code = params.ban && current - maximum > params.ban ? 403 : 429 | ||
res.code(code) | ||
const respCtx = { | ||
statusCode: code, | ||
after, | ||
max: maximum | ||
} | ||
if (code === 403) { | ||
respCtx.ban = true | ||
} | ||
res.send(params.errorResponseBuilder(req, respCtx)) | ||
} | ||
@@ -166,0 +198,0 @@ |
{ | ||
"name": "fastify-rate-limit", | ||
"version": "2.4.0", | ||
"version": "3.0.0", | ||
"description": "A low overhead rate limiter for your routes", | ||
@@ -28,5 +28,7 @@ "main": "index.js", | ||
"@types/ioredis": "~4.0.11", | ||
"@types/node": "~12.7.3", | ||
"@types/node": "~12.11.0", | ||
"fastify": "^2.5.0", | ||
"ioredis": "^4.9.0", | ||
"knex": "^0.20.2", | ||
"sqlite3": "^4.1.0", | ||
"standard": "^14.0.2", | ||
@@ -33,0 +35,0 @@ "tap": "^12.6.6", |
@@ -53,2 +53,3 @@ # fastify-rate-limit | ||
|`x-ratelimit-remaining` | how many request remain to the client in the timewindow | ||
|`x-ratelimit-reset` | how many seconds must pass before the rate limit resets | ||
|`retry-after` | if the max has been reached, the millisecond the client must wait before perform new requests | ||
@@ -63,2 +64,3 @@ | ||
max: 3, // default 1000 | ||
ban: 2, // default null | ||
timeWindow: 5000, // default 1000 * 60 | ||
@@ -70,3 +72,9 @@ cache: 10000, // default 5000 | ||
keyGenerator: function(req) { /* ... */ }, // default (req) => req.raw.ip | ||
errorResponseBuilder: function(req, context) { /* ... */}, | ||
errorResponseBuilder: function(req, context) { /* ... */}, | ||
addHeaders: { // default show all the response headers when rate limit is reached | ||
'x-ratelimit-limit': true, | ||
'x-ratelimit-remaining': true, | ||
'x-ratelimit-reset': true, | ||
'retry-after': true | ||
} | ||
}) | ||
@@ -77,2 +85,3 @@ ``` | ||
- `max`: is the maximum number of requests a single client can perform inside a timeWindow. It can be a sync function with the signature `(req, key) => {}` where `req` is the Fastify request object and `key` is the value generated by the `keyGenerator`. The function **must** return a number. | ||
- `ban`: is the maximum number of 429 responses to return to a single client before returning 403. When the ban limit is exceeded the context field will have `ban=true` in the errorResponseBuilder. This parameter is an in-memory counter and could not work properly in a distributed environment. | ||
- `timeWindow:` the duration of the time window. It can be expressed in milliseconds or as a string (in the [`ms`](https://github.com/zeit/ms) format) | ||
@@ -83,5 +92,7 @@ - `cache`: this plugin internally uses a lru cache to handle the clients, you can change the size of the cache with this option | ||
You can pass a Redis client here and magically the issue is solved. To achieve the maximum speed, this plugins requires the use of [`ioredis`](https://github.com/luin/ioredis) | ||
- `store`: a custom store to track requests and rates which allows you to use your own storage mechanism (such as using an RDBMS, MongoDB, etc...) as well as further customizing the logic used in calculating the rate limits. A simple example is provided below as well as a more detailed example using Knex.js can be found in the [`example/`](https://github.com/fastify/fastify-rate-limit/tree/master/example) folder | ||
- `skipOnError`: if `true` it will skip errors generated by the storage (eg, redis not reachable). | ||
- `keyGenerator`: a function to generate a unique identifier for each incoming request. Defaults to `(req) => req.ip`, the IP is resolved by fastify using `req.connection.remoteAddress` or `req.headers['x-forwarded-for']` if [trustProxy](https://www.fastify.io/docs/master/Server/#trustproxy) option is enabled. Use it if you want to override this behavior | ||
- `errorResponseBuilder`: a function to generate a custom response object. Defaults to `(req, context) => ({statusCode: 429, error: 'Too Many Requests', message: ``Rate limit exceeded, retry in ${context.after}``})` | ||
- `addHeaders`: define which headers should be added in the response when the limit is reached. Defaults all the headers will be shown | ||
@@ -137,2 +148,32 @@ `keyGenerator` example usage: | ||
Custom `store` example usage: | ||
```js | ||
function CustomStore (options) { | ||
this.options = options | ||
this.current = 0 | ||
} | ||
CustomStore.prototype.incr = function (key, cb) { | ||
const timeWindow = this.options.timeWindow | ||
this.current++ | ||
cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) }) | ||
} | ||
CustomStore.prototype.child = function (routeOptions) { | ||
// We create a merged copy of the current parent parameters with the specific | ||
// route parameters and pass them into the child store. | ||
const childParams = Object.assign(this.options, routeOptions.config.rateLimit) | ||
const store = new CustomStore(childParams) | ||
// Here is where you may want to do some custom calls on the store with the information | ||
// in routeOptions first... | ||
// store.setSubKey(routeOptions.method + routeOptions.url) | ||
return store | ||
} | ||
fastify.register(require('fastify-rate-limit'), { | ||
/* ... */ | ||
store: CustomStore | ||
}) | ||
``` | ||
### Options on the endpoint itself | ||
@@ -139,0 +180,0 @@ |
'use strict' | ||
const lru = require('tiny-lru') | ||
const ms = require('ms') | ||
function LocalStore (timeWindow, cache, app) { | ||
this.lru = lru(cache || 5000) | ||
this.interval = setInterval(this.lru.clear.bind(this.lru), timeWindow).unref() | ||
this.interval = setInterval(beat.bind(this), timeWindow).unref() | ||
this.app = app | ||
this.timeWindow = timeWindow | ||
@@ -14,2 +14,7 @@ app.addHook('onClose', (done) => { | ||
}) | ||
function beat () { | ||
this.lru.clear() | ||
this.msLastBeat = null | ||
} | ||
} | ||
@@ -20,14 +25,16 @@ | ||
this.lru.set(ip, ++current) | ||
cb(null, current) | ||
} | ||
LocalStore.prototype.child = function (routeOptions) { | ||
let timeWindow = routeOptions.config.rateLimit.timeWindow | ||
if (typeof timeWindow === 'string') { | ||
timeWindow = ms(timeWindow) | ||
// start counting from the first request/increment | ||
if (!this.msLastBeat) { | ||
this.msLastBeat = Date.now() | ||
} | ||
return new LocalStore(timeWindow, routeOptions.config.rateLimit.cache, this.app) | ||
cb(null, { current, ttl: this.timeWindow - (Date.now() - this.msLastBeat) }) | ||
} | ||
LocalStore.prototype.child = function (routeOptions) { | ||
return new LocalStore(routeOptions.config.rateLimit.timeWindow, | ||
routeOptions.config.rateLimit.cache, this.app) | ||
} | ||
module.exports = LocalStore |
'use strict' | ||
const ms = require('ms') | ||
const noop = () => {} | ||
@@ -18,8 +17,13 @@ | ||
.exec((err, result) => { | ||
if (err) return cb(err, 0) | ||
if (result[0][0]) return cb(result[0][0], 0) | ||
/** | ||
* result[0] => incr response: [0]: error, [1]: new incr value | ||
* result[1] => pttl response: [0]: error, [1]: ttl remaining | ||
*/ | ||
if (err) return cb(err, { current: 0 }) | ||
if (result[0][0]) return cb(result[0][0], { current: 0 }) | ||
if (result[1][1] === -1) { | ||
this.redis.pexpire(key, this.timeWindow, noop) | ||
result[1][1] = this.timeWindow | ||
} | ||
cb(null, result[0][1]) | ||
cb(null, { current: result[0][1], ttl: result[1][1] }) | ||
}) | ||
@@ -29,9 +33,5 @@ } | ||
RedisStore.prototype.child = function (routeOptions) { | ||
let timeWindow = routeOptions.config.rateLimit.timeWindow | ||
if (typeof timeWindow === 'string') { | ||
timeWindow = ms(timeWindow) | ||
} | ||
const child = Object.create(this) | ||
child.key = this.key + routeOptions.method + routeOptions.url + '-' | ||
child.timeWindow = timeWindow | ||
child.timeWindow = routeOptions.config.rateLimit.timeWindow | ||
return child | ||
@@ -38,0 +38,0 @@ } |
@@ -197,3 +197,3 @@ 'use strict' | ||
test('With redis store', t => { | ||
t.plan(19) | ||
t.plan(23) | ||
const fastify = Fastify() | ||
@@ -216,2 +216,3 @@ const redis = new Redis({ host: REDIS_HOST }) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
@@ -223,2 +224,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
@@ -231,2 +233,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
t.strictEqual(res.headers['retry-after'], 1000) | ||
@@ -252,2 +255,3 @@ t.deepEqual({ | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
}) | ||
@@ -363,2 +367,63 @@ } | ||
test('With CustomStore', t => { | ||
t.plan(18) | ||
function CustomStore (options) { | ||
this.options = options | ||
this.current = 0 | ||
} | ||
CustomStore.prototype.incr = function (key, cb) { | ||
const timeWindow = this.options.timeWindow | ||
this.current++ | ||
cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) }) | ||
} | ||
CustomStore.prototype.child = function (routeOptions) { | ||
const store = new CustomStore(Object.assign(this.options, routeOptions.config.rateLimit)) | ||
return store | ||
} | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
max: 2, | ||
timeWindow: 10000, | ||
store: CustomStore | ||
}) | ||
fastify.get('/', (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 9) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 8) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
t.strictEqual(res.headers['content-type'], 'application/json') | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 7) | ||
t.strictEqual(res.headers['retry-after'], 10000) | ||
t.deepEqual({ | ||
statusCode: 429, | ||
error: 'Too Many Requests', | ||
message: 'Rate limit exceeded, retry in 10 seconds' | ||
}, JSON.parse(res.payload)) | ||
}) | ||
}) | ||
}) | ||
}) | ||
test('does not override the preHandler', t => { | ||
@@ -470,1 +535,76 @@ t.plan(5) | ||
}) | ||
test('hide rate limit headers', t => { | ||
t.plan(17) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
max: 1, | ||
timeWindow: 1000, | ||
addHeaders: { | ||
'x-ratelimit-limit': false, | ||
'x-ratelimit-remaining': false, | ||
'x-ratelimit-reset': false, | ||
'retry-after': false | ||
} | ||
}) | ||
fastify.get('/', (req, res) => { res.send('hello') }) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
t.strictEqual(res.headers['content-type'], 'application/json') | ||
t.notOk(res.headers['x-ratelimit-limit'], 'the header must be missing') | ||
t.notOk(res.headers['x-ratelimit-remaining'], 'the header must be missing') | ||
t.notOk(res.headers['x-ratelimit-reset'], 'the header must be missing') | ||
t.notOk(res.headers['retry-after'], 'the header must be missing') | ||
setTimeout(retry, 1100) | ||
}) | ||
}) | ||
function retry () { | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
}) | ||
} | ||
}) | ||
test('With ban', t => { | ||
t.plan(6) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
max: 1, | ||
ban: 1 | ||
}) | ||
fastify.get('/', (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 403) | ||
}) | ||
}) | ||
}) | ||
}) |
@@ -23,3 +23,3 @@ 'use strict' | ||
test('Basic', t => { | ||
t.plan(19) | ||
t.plan(23) | ||
const fastify = Fastify() | ||
@@ -39,2 +39,3 @@ fastify.register(rateLimit, { global: false }) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
@@ -46,2 +47,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
@@ -55,2 +57,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['retry-after'], 1000) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
t.deepEqual({ | ||
@@ -73,2 +76,3 @@ statusCode: 429, | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
}) | ||
@@ -218,3 +222,3 @@ } | ||
test('With redis store', t => { | ||
t.plan(19) | ||
t.plan(23) | ||
const fastify = Fastify() | ||
@@ -238,2 +242,3 @@ const redis = new Redis({ host: REDIS_HOST }) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
@@ -245,2 +250,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
@@ -254,2 +260,3 @@ fastify.inject('/', (err, res) => { | ||
t.strictEqual(res.headers['retry-after'], 1000) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 0) | ||
t.deepEqual({ | ||
@@ -274,2 +281,3 @@ statusCode: 429, | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
}) | ||
@@ -441,2 +449,31 @@ } | ||
test('With ban', t => { | ||
t.plan(6) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
global: false | ||
}) | ||
fastify.get('/', { | ||
config: { rateLimit: { max: 1, ban: 1 } } | ||
}, (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 403) | ||
}) | ||
}) | ||
}) | ||
}) | ||
test('route can disable the global limit', t => { | ||
@@ -633,1 +670,188 @@ t.plan(4) | ||
}) | ||
test('limit reset per Local storage', t => { | ||
t.plan(12) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { global: false }) | ||
fastify.get('/', { | ||
config: { | ||
rateLimit: { | ||
max: 1, | ||
timeWindow: 4000 | ||
} | ||
} | ||
}, (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
setTimeout(doRequest.bind(null, 4), 0) | ||
setTimeout(doRequest.bind(null, 3), 1000) | ||
setTimeout(doRequest.bind(null, 2), 2000) | ||
setTimeout(doRequest.bind(null, 1), 3000) | ||
setTimeout(doRequest.bind(null, 0), 4000) | ||
setTimeout(doRequest.bind(null, 4), 4100) | ||
function doRequest (resetValue) { | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], resetValue) | ||
}) | ||
} | ||
}) | ||
test('hide rate limit headers', t => { | ||
t.plan(17) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
max: 1, | ||
timeWindow: 1000, | ||
addHeaders: { | ||
'x-ratelimit-limit': false, | ||
'x-ratelimit-remaining': false, | ||
'x-ratelimit-reset': false, | ||
'retry-after': false | ||
} | ||
}) | ||
fastify.get('/', { | ||
config: { | ||
rateLimit: { | ||
timeWindow: 1000, | ||
addHeaders: { | ||
'x-ratelimit-limit': true, // this must override the global one | ||
'x-ratelimit-remaining': false, | ||
'x-ratelimit-reset': false, | ||
'retry-after': false | ||
} | ||
} | ||
} | ||
}, (req, res) => { res.send('hello') }) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
t.strictEqual(res.headers['content-type'], 'application/json') | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 1) | ||
t.notOk(res.headers['x-ratelimit-remaining'], 'the header must be missing') | ||
t.notOk(res.headers['x-ratelimit-reset'], 'the header must be missing') | ||
t.notOk(res.headers['retry-after'], 'the header must be missing') | ||
setTimeout(retry, 1100) | ||
}) | ||
}) | ||
function retry () { | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 1) | ||
}) | ||
} | ||
}) | ||
test('global timeWindow when not set in routes', t => { | ||
t.plan(6) | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
global: false, | ||
timeWindow: 6000 | ||
}) | ||
fastify.get('/six', { | ||
config: { rateLimit: { max: 6 } } | ||
}, (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.get('/four', { | ||
config: { rateLimit: { max: 4, timeWindow: 4000 } } | ||
}, (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.inject('/six', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 6) | ||
fastify.inject('/four', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 4) | ||
}) | ||
}) | ||
}) | ||
test('With CustomStore', t => { | ||
t.plan(18) | ||
function CustomStore (options) { | ||
this.options = options | ||
this.current = 0 | ||
} | ||
CustomStore.prototype.incr = function (key, cb) { | ||
const timeWindow = this.options.timeWindow | ||
this.current++ | ||
cb(null, { current: this.current, ttl: timeWindow - (this.current * 1000) }) | ||
} | ||
CustomStore.prototype.child = function (routeOptions) { | ||
const store = new CustomStore(Object.assign(this.options, routeOptions.config.rateLimit)) | ||
return store | ||
} | ||
const fastify = Fastify() | ||
fastify.register(rateLimit, { | ||
global: false, | ||
max: 1, | ||
timeWindow: 10000, | ||
store: CustomStore | ||
}) | ||
fastify.get('/', { | ||
config: { rateLimit: { max: 2, timeWindow: 10000 } } | ||
}, (req, reply) => { | ||
reply.send('hello!') | ||
}) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 1) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 9) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 200) | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 8) | ||
fastify.inject('/', (err, res) => { | ||
t.error(err) | ||
t.strictEqual(res.statusCode, 429) | ||
t.strictEqual(res.headers['content-type'], 'application/json') | ||
t.strictEqual(res.headers['x-ratelimit-limit'], 2) | ||
t.strictEqual(res.headers['x-ratelimit-remaining'], 0) | ||
t.strictEqual(res.headers['x-ratelimit-reset'], 7) | ||
t.strictEqual(res.headers['retry-after'], 10000) | ||
t.deepEqual({ | ||
statusCode: 429, | ||
error: 'Too Many Requests', | ||
message: 'Rate limit exceeded, retry in 10 seconds' | ||
}, JSON.parse(res.payload)) | ||
}) | ||
}) | ||
}) | ||
}) |
import * as http from 'http' | ||
import * as fastify from 'fastify'; | ||
import * as types from '../../index'; | ||
import * as fastifyRateLimit from '../../../fastify-rate-limit'; | ||
@@ -16,4 +17,11 @@ import * as ioredis from 'ioredis'; | ||
skipOnError: true, | ||
ban: 10, | ||
keyGenerator: (req: fastify.FastifyRequest<http.IncomingMessage>) => req.ip, | ||
errorResponseBuilder: (req: fastify.FastifyRequest<http.IncomingMessage>, context) => ({ code: 429, timeWindow: context.after, limit: context.max }) | ||
errorResponseBuilder: (req: fastify.FastifyRequest<http.IncomingMessage>, context) => ({ code: 429, timeWindow: context.after, limit: context.max }), | ||
addHeaders: { | ||
'x-ratelimit-limit': false, | ||
'x-ratelimit-remaining': false, | ||
'x-ratelimit-reset': false, | ||
'retry-after': false | ||
} | ||
}); | ||
@@ -27,1 +35,16 @@ | ||
}); | ||
class CustomStore implements types.FastifyRateLimitStore { | ||
constructor(options: types.FastifyRateLimitOptions) {} | ||
incr(key: string, callback: ( error: Error|null, result?: { current: number, ttl: number } ) => void) {} | ||
child(routeOptions: fastify.RouteOptions<http.Server, http.IncomingMessage, http.ServerResponse> & { path: string, prefix: string }) { | ||
return <CustomStore>(<types.FastifyRateLimitOptions>{}) | ||
} | ||
} | ||
app.register(fastifyRateLimit, { | ||
global: true, | ||
max: (req: fastify.FastifyRequest<http.IncomingMessage>, key: string) => (42), | ||
timeWindow: 5000, | ||
store: CustomStore | ||
}); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
70010
15
1782
259
9