├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── docker-compose.valkey-cluster.yml ├── docker-compose.yml ├── img ├── chart-exec-evenly-10r-end.png ├── chart-exec-evenly-10r-start.png ├── express-brute-example.png ├── heap-cluster-master.png ├── heap-cluster-worker.png ├── heap-memcache.png ├── heap-memory.png ├── heap-mongo.png ├── heap-mysql.png ├── heap-postgres.png ├── heap-redis.png └── rlflx-logo-small.png ├── index.js ├── lib ├── BurstyRateLimiter.js ├── ExpressBruteFlexible.js ├── RLWrapperBlackAndWhite.js ├── RateLimiterAbstract.js ├── RateLimiterCluster.js ├── RateLimiterDynamo.js ├── RateLimiterEtcd.js ├── RateLimiterEtcdNonAtomic.js ├── RateLimiterMemcache.js ├── RateLimiterMemory.js ├── RateLimiterMongo.js ├── RateLimiterMySQL.js ├── RateLimiterPostgres.js ├── RateLimiterPrisma.js ├── RateLimiterQueue.js ├── RateLimiterRedis.js ├── RateLimiterRes.js ├── RateLimiterSQLite.js ├── RateLimiterStoreAbstract.js ├── RateLimiterUnion.js ├── RateLimiterValkey.js ├── RateLimiterValkeyGlide.js ├── component │ ├── BlockedKeys │ │ ├── BlockedKeys.js │ │ └── index.js │ ├── MemoryStorage │ │ ├── MemoryStorage.js │ │ ├── Record.js │ │ └── index.js │ ├── RateLimiterEtcdTransactionFailedError.js │ ├── RateLimiterQueueError.js │ ├── RateLimiterSetupError.js │ └── index.d.ts ├── constants.js └── index.d.ts ├── package.json └── test ├── BurstyRateLimiter.test.js ├── ExpressBruteFlexible.test.js ├── RLWrapperBlackAndWhite.test.js ├── RateLimiterAbstract.test.js ├── RateLimiterCluster.test.js ├── RateLimiterDynamo.test.js ├── RateLimiterEtcd.test.js ├── RateLimiterEtcdNonAtomic.test.js ├── RateLimiterMemcache.test.js ├── RateLimiterMemory.test.js ├── RateLimiterMongo.test.js ├── RateLimiterMySQL.test.js ├── RateLimiterPostgres.test.js ├── RateLimiterPrisma └── Postgres │ ├── RateLimiterPrismaPostgres.test.js │ └── schema.prisma ├── RateLimiterQueue.test.js ├── RateLimiterRedis.ioredis.test.js ├── RateLimiterRedis.redis.test.js ├── RateLimiterRes.test.js ├── RateLimiterSQLite.test.js ├── RateLimiterStoreAbstract.test.js ├── RateLimiterUnion.test.js ├── RateLimiterValkey.iovalkey.test.js ├── RateLimiterValkeyGlide.test.js ├── RedisOptions.js ├── component ├── BlockedKeys │ └── BlockedKeys.test.js ├── MemoryStorage │ ├── MemoryStorage.test.js │ └── Record.test.js └── RateLimiterQueueError.test.js └── scripts └── cluster-setup.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | insert_final_newline = false 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "node", 4 | "security" 5 | ], 6 | "extends": [ 7 | "plugin:node/recommended", 8 | "plugin:security/recommended", 9 | "eslint:recommended", 10 | "airbnb-base" 11 | ], 12 | "env": { 13 | "node": true 14 | }, 15 | "rules": { 16 | "no-underscore-dangle": "off", 17 | "no-param-reassign": "off", 18 | "no-plusplus": "off", 19 | "radix": ["error", "as-needed"], 20 | "consistent-return": "off", 21 | "class-methods-use-this": "off", 22 | "max-len": ["error", { "code": 140 }], 23 | "node/no-unpublished-require": ["error", { 24 | "allowModules": ["mocha", "chai", "redis-mock"] 25 | }], 26 | "node/no-unsupported-features": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: animir 2 | patreon: animir 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | workflow_dispatch: 12 | 13 | concurrency: 14 | group: test-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read # to fetch code (actions/checkout) 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: "lts/*" 28 | cache: "npm" 29 | cache-dependency-path: ./package.json 30 | - name: Install dependencies 31 | run: | 32 | npm install 33 | - name: ESLint 34 | run: | 35 | npm run eslint 36 | 37 | compatibility: 38 | runs-on: ubuntu-latest 39 | 40 | name: testing node@${{ matrix.node-version }}, valkey@${{ matrix.valkey-version }} 41 | 42 | strategy: 43 | matrix: 44 | node-version: [20.x, 22.x, 23.x] 45 | valkey-version: [7.2, 8] 46 | 47 | services: 48 | postgres: 49 | image: postgres 50 | env: 51 | POSTGRES_PASSWORD: secret 52 | POSTGRES_USER: root 53 | ports: 54 | - 5432:5432 55 | # Set health checks to wait until postgres has started 56 | options: >- 57 | --health-cmd pg_isready 58 | --health-interval 10s 59 | --health-timeout 5s 60 | --health-retries 5 61 | 62 | steps: 63 | - name: Checkout repository 64 | uses: actions/checkout@v4.0.0 65 | 66 | - name: Use Node.js ${{ matrix.node-version }} 67 | uses: actions/setup-node@v3.8.1 68 | with: 69 | node-version: ${{ matrix.node-version }} 70 | cache: npm 71 | cache-dependency-path: ./package.json 72 | 73 | - name: Start Redis 74 | uses: supercharge/redis-github-action@1.7.0 75 | with: 76 | redis-version: 7 77 | 78 | - uses: shogo82148/actions-setup-redis@v1 79 | with: 80 | distribution: "valkey" 81 | redis-version: ${{ matrix.valkey-version }} 82 | redis-port: 8080 83 | 84 | - name: Start DynamoDB local 85 | uses: rrainn/dynamodb-action@v3.0.0 86 | 87 | - run: npm install 88 | - run: npm run test 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | coverage 4 | package-lock.json 5 | .nyc_output/ 6 | dump.rdb 7 | nodes.conf 8 | data/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .gitignore 3 | .github 4 | .npmignore 5 | *.json 6 | .editorconfig 7 | test 8 | coverage 9 | img 10 | .github 11 | *.yml 12 | *.yaml 13 | .nyc_output 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | - "12" 6 | - "14" 7 | script: 8 | - npm run eslint 9 | - npm run test 10 | after_success: 'npm run coveralls' 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## ISC License (ISC) 2 | 3 | Copyright 2019 Roman Voloboev 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /docker-compose.valkey-cluster.yml: -------------------------------------------------------------------------------- 1 | services: 2 | valkey-cluster: 3 | image: valkey/valkey:latest 4 | ipc: host 5 | ports: 6 | - "7001-7003:7001-7003" 7 | volumes: 8 | - ./test/scripts/cluster-setup.sh:/usr/local/bin/cluster-setup.sh:ro 9 | command: ["bash", "/usr/local/bin/cluster-setup.sh"] 10 | environment: 11 | - SKIP_SYSCTL=1 12 | network_mode: "host" 13 | healthcheck: 14 | test: ["CMD", "valkey-cli", "-p", "7001", "cluster", "info"] 15 | interval: 10s 16 | timeout: 5s 17 | retries: 5 18 | privileged: true 19 | restart: unless-stopped 20 | stop_grace_period: 30s 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | container_name: cache 5 | ports: 6 | - 6379:6379 7 | 8 | valkey: 9 | image: valkey/valkey:8-alpine 10 | container_name: valkey-cache 11 | command: valkey-server --port 8080 12 | ports: 13 | - 8080:8080 14 | networks: 15 | - valkey-network 16 | 17 | valkey-cluster: 18 | image: valkey/valkey:8-alpine 19 | container_name: valkey-cluster 20 | command: > 21 | sh -c " 22 | valkey-server --port 8081 --cluster-enabled yes & 23 | sleep 5; 24 | valkey-cli -p 8081 cluster addslotsrange 0 16383; 25 | tail -f /dev/null 26 | " 27 | ports: 28 | - 8081:8081 29 | networks: 30 | - valkey-network 31 | 32 | dynamodb: 33 | image: amazon/dynamodb-local 34 | container_name: dynamo 35 | ports: 36 | - 8000:8000 37 | 38 | postgres: 39 | image: postgres:latest 40 | restart: always 41 | environment: 42 | POSTGRES_USER: root 43 | POSTGRES_PASSWORD: secret 44 | ports: 45 | - "5432:5432" 46 | 47 | etcd: 48 | image: bitnami/etcd:3.5 49 | container_name: etcd 50 | environment: 51 | ALLOW_NONE_AUTHENTICATION: yes 52 | ports: 53 | - 2379:2379 54 | 55 | networks: 56 | valkey-network: 57 | driver: bridge 58 | -------------------------------------------------------------------------------- /img/chart-exec-evenly-10r-end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/chart-exec-evenly-10r-end.png -------------------------------------------------------------------------------- /img/chart-exec-evenly-10r-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/chart-exec-evenly-10r-start.png -------------------------------------------------------------------------------- /img/express-brute-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/express-brute-example.png -------------------------------------------------------------------------------- /img/heap-cluster-master.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-cluster-master.png -------------------------------------------------------------------------------- /img/heap-cluster-worker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-cluster-worker.png -------------------------------------------------------------------------------- /img/heap-memcache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-memcache.png -------------------------------------------------------------------------------- /img/heap-memory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-memory.png -------------------------------------------------------------------------------- /img/heap-mongo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-mongo.png -------------------------------------------------------------------------------- /img/heap-mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-mysql.png -------------------------------------------------------------------------------- /img/heap-postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-postgres.png -------------------------------------------------------------------------------- /img/heap-redis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/heap-redis.png -------------------------------------------------------------------------------- /img/rlflx-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/animir/node-rate-limiter-flexible/2906f1a95e9b39d11e9706bdc19e210d11f815b5/img/rlflx-logo-small.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const RateLimiterRedis = require('./lib/RateLimiterRedis'); 2 | const RateLimiterMongo = require('./lib/RateLimiterMongo'); 3 | const RateLimiterMySQL = require('./lib/RateLimiterMySQL'); 4 | const RateLimiterPostgres = require('./lib/RateLimiterPostgres'); 5 | const { RateLimiterClusterMaster, RateLimiterClusterMasterPM2, RateLimiterCluster } = require('./lib/RateLimiterCluster'); 6 | const RateLimiterMemory = require('./lib/RateLimiterMemory'); 7 | const RateLimiterMemcache = require('./lib/RateLimiterMemcache'); 8 | const RLWrapperBlackAndWhite = require('./lib/RLWrapperBlackAndWhite'); 9 | const RateLimiterUnion = require('./lib/RateLimiterUnion'); 10 | const RateLimiterQueue = require('./lib/RateLimiterQueue'); 11 | const BurstyRateLimiter = require('./lib/BurstyRateLimiter'); 12 | const RateLimiterRes = require('./lib/RateLimiterRes'); 13 | const RateLimiterDynamo = require('./lib/RateLimiterDynamo'); 14 | const RateLimiterPrisma = require('./lib/RateLimiterPrisma'); 15 | const RateLimiterValkey = require('./lib/RateLimiterValkey'); 16 | const RateLimiterValkeyGlide = require('./lib/RateLimiterValkeyGlide'); 17 | const RateLimiterSQLite = require('./lib/RateLimiterSQLite'); 18 | const RateLimiterEtcd = require('./lib/RateLimiterEtcd'); 19 | const RateLimiterEtcdNonAtomic = require('./lib/RateLimiterEtcdNonAtomic'); 20 | 21 | module.exports = { 22 | RateLimiterRedis, 23 | RateLimiterMongo, 24 | RateLimiterMySQL, 25 | RateLimiterPostgres, 26 | RateLimiterMemory, 27 | RateLimiterMemcache, 28 | RateLimiterClusterMaster, 29 | RateLimiterClusterMasterPM2, 30 | RateLimiterCluster, 31 | RLWrapperBlackAndWhite, 32 | RateLimiterUnion, 33 | RateLimiterQueue, 34 | BurstyRateLimiter, 35 | RateLimiterRes, 36 | RateLimiterDynamo, 37 | RateLimiterPrisma, 38 | RateLimiterValkey, 39 | RateLimiterValkeyGlide, 40 | RateLimiterSQLite, 41 | RateLimiterEtcd, 42 | RateLimiterEtcdNonAtomic, 43 | }; 44 | -------------------------------------------------------------------------------- /lib/BurstyRateLimiter.js: -------------------------------------------------------------------------------- 1 | const RateLimiterRes = require("./RateLimiterRes"); 2 | 3 | /** 4 | * Bursty rate limiter exposes only msBeforeNext time and doesn't expose points from bursty limiter by default 5 | * @type {BurstyRateLimiter} 6 | */ 7 | module.exports = class BurstyRateLimiter { 8 | constructor(rateLimiter, burstLimiter) { 9 | this._rateLimiter = rateLimiter; 10 | this._burstLimiter = burstLimiter 11 | } 12 | 13 | /** 14 | * Merge rate limiter response objects. Responses can be null 15 | * 16 | * @param {RateLimiterRes} [rlRes] Rate limiter response 17 | * @param {RateLimiterRes} [blRes] Bursty limiter response 18 | */ 19 | _combineRes(rlRes, blRes) { 20 | if (!rlRes) { 21 | return null 22 | } 23 | 24 | return new RateLimiterRes( 25 | rlRes.remainingPoints, 26 | Math.min(rlRes.msBeforeNext, blRes ? blRes.msBeforeNext : 0), 27 | rlRes.consumedPoints, 28 | rlRes.isFirstInDuration 29 | ) 30 | } 31 | 32 | /** 33 | * @param key 34 | * @param pointsToConsume 35 | * @param options 36 | * @returns {Promise<any>} 37 | */ 38 | consume(key, pointsToConsume = 1, options = {}) { 39 | return this._rateLimiter.consume(key, pointsToConsume, options) 40 | .catch((rlRej) => { 41 | if (rlRej instanceof RateLimiterRes) { 42 | return this._burstLimiter.consume(key, pointsToConsume, options) 43 | .then((blRes) => { 44 | return Promise.resolve(this._combineRes(rlRej, blRes)) 45 | }) 46 | .catch((blRej) => { 47 | if (blRej instanceof RateLimiterRes) { 48 | return Promise.reject(this._combineRes(rlRej, blRej)) 49 | } else { 50 | return Promise.reject(blRej) 51 | } 52 | } 53 | ) 54 | } else { 55 | return Promise.reject(rlRej) 56 | } 57 | }) 58 | } 59 | 60 | /** 61 | * It doesn't expose available points from burstLimiter 62 | * 63 | * @param key 64 | * @returns {Promise<RateLimiterRes>} 65 | */ 66 | get(key) { 67 | return Promise.all([ 68 | this._rateLimiter.get(key), 69 | this._burstLimiter.get(key), 70 | ]).then(([rlRes, blRes]) => { 71 | return this._combineRes(rlRes, blRes); 72 | }); 73 | } 74 | 75 | get points() { 76 | return this._rateLimiter.points; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /lib/ExpressBruteFlexible.js: -------------------------------------------------------------------------------- 1 | const { 2 | LIMITER_TYPES, 3 | ERR_UNKNOWN_LIMITER_TYPE_MESSAGE, 4 | } = require('./constants'); 5 | const crypto = require('crypto'); 6 | const { 7 | RateLimiterMemory, 8 | RateLimiterCluster, 9 | RateLimiterMemcache, 10 | RateLimiterMongo, 11 | RateLimiterMySQL, 12 | RateLimiterPostgres, 13 | RateLimiterRedis, 14 | RateLimiterValkey, 15 | RateLimiterValkeyGlide, 16 | } = require('../index'); 17 | 18 | function getDelayMs(count, delays, maxWait) { 19 | let msDelay = maxWait; 20 | const delayIndex = count - 1; 21 | if (delayIndex >= 0 && delayIndex < delays.length) { 22 | msDelay = delays[delayIndex]; 23 | } 24 | 25 | return msDelay; 26 | } 27 | 28 | const ExpressBruteFlexible = function (limiterType, options) { 29 | ExpressBruteFlexible.instanceCount++; 30 | this.name = `brute${ExpressBruteFlexible.instanceCount}`; 31 | 32 | this.options = Object.assign({}, ExpressBruteFlexible.defaults, options); 33 | if (this.options.minWait < 1) { 34 | this.options.minWait = 1; 35 | } 36 | 37 | const validLimiterTypes = Object.keys(ExpressBruteFlexible.LIMITER_TYPES).map(k => ExpressBruteFlexible.LIMITER_TYPES[k]); 38 | if (!validLimiterTypes.includes(limiterType)) { 39 | throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE); 40 | } 41 | this.limiterType = limiterType; 42 | 43 | this.delays = [this.options.minWait]; 44 | while (this.delays[this.delays.length - 1] < this.options.maxWait) { 45 | const nextNum = this.delays[this.delays.length - 1] + (this.delays.length > 1 ? this.delays[this.delays.length - 2] : 0); 46 | this.delays.push(nextNum); 47 | } 48 | this.delays[this.delays.length - 1] = this.options.maxWait; 49 | 50 | // set default lifetime 51 | if (typeof this.options.lifetime === 'undefined') { 52 | this.options.lifetime = Math.ceil((this.options.maxWait / 1000) * (this.delays.length + this.options.freeRetries)); 53 | } 54 | 55 | this.prevent = this.getMiddleware({ 56 | prefix: this.options.prefix, 57 | }); 58 | }; 59 | 60 | ExpressBruteFlexible.prototype.getMiddleware = function (options) { 61 | const opts = Object.assign({}, options); 62 | const commonKeyPrefix = opts.prefix || ''; 63 | const freeLimiterOptions = { 64 | storeClient: this.options.storeClient, 65 | storeType: this.options.storeType, 66 | keyPrefix: `${commonKeyPrefix}free`, 67 | dbName: this.options.dbName, 68 | tableName: this.options.tableName, 69 | points: this.options.freeRetries > 0 ? this.options.freeRetries - 1 : 0, 70 | duration: this.options.lifetime, 71 | }; 72 | 73 | const blockLimiterOptions = { 74 | storeClient: this.options.storeClient, 75 | storeType: this.options.storeType, 76 | keyPrefix: `${commonKeyPrefix}block`, 77 | dbName: this.options.dbName, 78 | tableName: this.options.tableName, 79 | points: 1, 80 | duration: Math.min(this.options.lifetime, Math.ceil((this.options.maxWait / 1000))), 81 | }; 82 | 83 | const counterLimiterOptions = { 84 | storeClient: this.options.storeClient, 85 | storeType: this.options.storeType, 86 | keyPrefix: `${commonKeyPrefix}counter`, 87 | dbName: this.options.dbName, 88 | tableName: this.options.tableName, 89 | points: 1, 90 | duration: this.options.lifetime, 91 | }; 92 | 93 | switch (this.limiterType) { 94 | case 'memory': 95 | this.freeLimiter = new RateLimiterMemory(freeLimiterOptions); 96 | this.blockLimiter = new RateLimiterMemory(blockLimiterOptions); 97 | this.counterLimiter = new RateLimiterMemory(counterLimiterOptions); 98 | break; 99 | case 'cluster': 100 | this.freeLimiter = new RateLimiterCluster(freeLimiterOptions); 101 | this.blockLimiter = new RateLimiterCluster(blockLimiterOptions); 102 | this.counterLimiter = new RateLimiterCluster(counterLimiterOptions); 103 | break; 104 | case 'memcache': 105 | this.freeLimiter = new RateLimiterMemcache(freeLimiterOptions); 106 | this.blockLimiter = new RateLimiterMemcache(blockLimiterOptions); 107 | this.counterLimiter = new RateLimiterMemcache(counterLimiterOptions); 108 | break; 109 | case 'mongo': 110 | this.freeLimiter = new RateLimiterMongo(freeLimiterOptions); 111 | this.blockLimiter = new RateLimiterMongo(blockLimiterOptions); 112 | this.counterLimiter = new RateLimiterMongo(counterLimiterOptions); 113 | break; 114 | case 'mysql': 115 | this.freeLimiter = new RateLimiterMySQL(freeLimiterOptions); 116 | this.blockLimiter = new RateLimiterMySQL(blockLimiterOptions); 117 | this.counterLimiter = new RateLimiterMySQL(counterLimiterOptions); 118 | break; 119 | case 'postgres': 120 | this.freeLimiter = new RateLimiterPostgres(freeLimiterOptions); 121 | this.blockLimiter = new RateLimiterPostgres(blockLimiterOptions); 122 | this.counterLimiter = new RateLimiterPostgres(counterLimiterOptions); 123 | break; 124 | case 'valkey-glide': 125 | this.freeLimiter = new RateLimiterValkeyGlide(freeLimiterOptions); 126 | this.blockLimiter = new RateLimiterValkeyGlide(blockLimiterOptions); 127 | this.counterLimiter = new RateLimiterValkeyGlide(counterLimiterOptions); 128 | break; 129 | case 'valkey': 130 | this.freeLimiter = new RateLimiterValkey(freeLimiterOptions); 131 | this.blockLimiter = new RateLimiterValkey(blockLimiterOptions); 132 | this.counterLimiter = new RateLimiterValkey(counterLimiterOptions); 133 | break; 134 | case 'redis': 135 | this.freeLimiter = new RateLimiterRedis(freeLimiterOptions); 136 | this.blockLimiter = new RateLimiterRedis(blockLimiterOptions); 137 | this.counterLimiter = new RateLimiterRedis(counterLimiterOptions); 138 | break; 139 | default: 140 | throw new Error(ERR_UNKNOWN_LIMITER_TYPE_MESSAGE); 141 | } 142 | 143 | let keyFunc = opts.key; 144 | if (typeof keyFunc !== 'function') { 145 | keyFunc = function (req, res, next) { 146 | next(opts.key); 147 | }; 148 | } 149 | 150 | const getFailCallback = (() => (typeof opts.failCallback === 'undefined' ? this.options.failCallback : opts.failCallback)); 151 | 152 | return (req, res, next) => { 153 | const cannotIncrementErrorObjectBase = { 154 | req, 155 | res, 156 | next, 157 | message: 'Cannot increment request count', 158 | }; 159 | 160 | keyFunc(req, res, (key) => { 161 | if (!opts.ignoreIP) { 162 | key = ExpressBruteFlexible._getKey([req.ip, this.name, key]); 163 | } else { 164 | key = ExpressBruteFlexible._getKey([this.name, key]); 165 | } 166 | 167 | // attach a simpler "reset" function to req.brute.reset 168 | if (this.options.attachResetToRequest) { 169 | let reset = ((callback) => { 170 | Promise.all([ 171 | this.freeLimiter.delete(key), 172 | this.blockLimiter.delete(key), 173 | this.counterLimiter.delete(key), 174 | ]).then(() => { 175 | if (typeof callback === 'function') { 176 | process.nextTick(() => { 177 | callback(); 178 | }); 179 | } 180 | }).catch((err) => { 181 | if (typeof callback === 'function') { 182 | process.nextTick(() => { 183 | callback(err); 184 | }); 185 | } 186 | }); 187 | }); 188 | 189 | if (req.brute && req.brute.reset) { 190 | // wrap existing reset if one exists 191 | const oldReset = req.brute.reset; 192 | const newReset = reset; 193 | reset = function (callback) { 194 | oldReset(() => { 195 | newReset(callback); 196 | }); 197 | }; 198 | } 199 | req.brute = { 200 | reset, 201 | }; 202 | } 203 | 204 | this.freeLimiter.consume(key) 205 | .then(() => { 206 | if (typeof next === 'function') { 207 | next(); 208 | } 209 | }) 210 | .catch(() => { 211 | Promise.all([ 212 | this.blockLimiter.get(key), 213 | this.counterLimiter.get(key), 214 | ]) 215 | .then((allRes) => { 216 | const [blockRes, counterRes] = allRes; 217 | 218 | if (blockRes === null) { 219 | const msDelay = getDelayMs( 220 | counterRes ? counterRes.consumedPoints + 1 : 1, 221 | this.delays, 222 | // eslint-disable-next-line 223 | this.options.maxWait 224 | ); 225 | 226 | this.blockLimiter.penalty(key, 1, { customDuration: Math.ceil(msDelay / 1000) }) 227 | .then((blockPenaltyRes) => { 228 | if (blockPenaltyRes.consumedPoints === 1) { 229 | this.counterLimiter.penalty(key) 230 | .then(() => { 231 | if (typeof next === 'function') { 232 | next(); 233 | } 234 | }) 235 | .catch((err) => { 236 | this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err })); 237 | }); 238 | } else { 239 | const nextValidDate = new Date(Date.now() + blockPenaltyRes.msBeforeNext); 240 | 241 | const failCallback = getFailCallback(); 242 | if (typeof failCallback === 'function') { 243 | failCallback(req, res, next, nextValidDate); 244 | } 245 | } 246 | }) 247 | .catch((err) => { 248 | this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err })); 249 | }); 250 | } else { 251 | const nextValidDate = new Date(Date.now() + blockRes.msBeforeNext); 252 | 253 | const failCallback = getFailCallback(); 254 | if (typeof failCallback === 'function') { 255 | failCallback(req, res, next, nextValidDate); 256 | } 257 | } 258 | }) 259 | .catch((err) => { 260 | this.options.handleStoreError(Object.assign({}, cannotIncrementErrorObjectBase, { parent: err })); 261 | }); 262 | }); 263 | }); 264 | }; 265 | }; 266 | 267 | ExpressBruteFlexible.prototype.reset = function (ip, key, callback) { 268 | let keyArgs = []; 269 | if (ip) { 270 | keyArgs.push(ip) 271 | } 272 | keyArgs.push(this.name); 273 | keyArgs.push(key); 274 | const ebKey = ExpressBruteFlexible._getKey(keyArgs); 275 | 276 | Promise.all([ 277 | this.freeLimiter.delete(ebKey), 278 | this.blockLimiter.delete(ebKey), 279 | this.counterLimiter.delete(ebKey), 280 | ]).then(() => { 281 | if (typeof callback === 'function') { 282 | process.nextTick(() => { 283 | callback(); 284 | }); 285 | } 286 | }).catch((err) => { 287 | this.options.handleStoreError({ 288 | message: 'Cannot reset request count', 289 | parent: err, 290 | key, 291 | ip, 292 | }); 293 | }); 294 | }; 295 | 296 | ExpressBruteFlexible._getKey = function (arr) { 297 | let key = ''; 298 | 299 | arr.forEach((part) => { 300 | if (part) { 301 | key += crypto.createHash('sha256').update(part).digest('base64'); 302 | } 303 | }); 304 | 305 | return crypto.createHash('sha256').update(key).digest('base64'); 306 | }; 307 | 308 | const setRetryAfter = function (res, nextValidRequestDate) { 309 | const secondUntilNextRequest = Math.ceil((nextValidRequestDate.getTime() - Date.now()) / 1000); 310 | res.header('Retry-After', secondUntilNextRequest); 311 | }; 312 | ExpressBruteFlexible.FailTooManyRequests = function (req, res, next, nextValidRequestDate) { 313 | setRetryAfter(res, nextValidRequestDate); 314 | res.status(429); 315 | res.send({ 316 | error: { 317 | text: 'Too many requests in this time frame.', 318 | nextValidRequestDate, 319 | }, 320 | }); 321 | }; 322 | ExpressBruteFlexible.FailForbidden = function (req, res, next, nextValidRequestDate) { 323 | setRetryAfter(res, nextValidRequestDate); 324 | res.status(403); 325 | res.send({ 326 | error: { 327 | text: 'Too many requests in this time frame.', 328 | nextValidRequestDate, 329 | }, 330 | }); 331 | }; 332 | ExpressBruteFlexible.FailMark = function (req, res, next, nextValidRequestDate) { 333 | res.status(429); 334 | setRetryAfter(res, nextValidRequestDate); 335 | res.nextValidRequestDate = nextValidRequestDate; 336 | next(); 337 | }; 338 | 339 | ExpressBruteFlexible.defaults = { 340 | freeRetries: 2, 341 | attachResetToRequest: true, 342 | minWait: 500, 343 | maxWait: 1000 * 60 * 15, 344 | failCallback: ExpressBruteFlexible.FailTooManyRequests, 345 | handleStoreError(err) { 346 | // eslint-disable-next-line 347 | throw { 348 | message: err.message, 349 | parent: err.parent, 350 | }; 351 | }, 352 | }; 353 | 354 | ExpressBruteFlexible.LIMITER_TYPES = LIMITER_TYPES; 355 | 356 | ExpressBruteFlexible.instanceCount = 0; 357 | 358 | 359 | module.exports = ExpressBruteFlexible; 360 | -------------------------------------------------------------------------------- /lib/RLWrapperBlackAndWhite.js: -------------------------------------------------------------------------------- 1 | const RateLimiterRes = require('./RateLimiterRes'); 2 | 3 | module.exports = class RLWrapperBlackAndWhite { 4 | constructor(opts = {}) { 5 | this.limiter = opts.limiter; 6 | this.blackList = opts.blackList; 7 | this.whiteList = opts.whiteList; 8 | this.isBlackListed = opts.isBlackListed; 9 | this.isWhiteListed = opts.isWhiteListed; 10 | this.runActionAnyway = opts.runActionAnyway; 11 | } 12 | 13 | get limiter() { 14 | return this._limiter; 15 | } 16 | 17 | set limiter(value) { 18 | if (typeof value === 'undefined') { 19 | throw new Error('limiter is not set'); 20 | } 21 | 22 | this._limiter = value; 23 | } 24 | 25 | get runActionAnyway() { 26 | return this._runActionAnyway; 27 | } 28 | 29 | set runActionAnyway(value) { 30 | this._runActionAnyway = typeof value === 'undefined' ? false : value; 31 | } 32 | 33 | get blackList() { 34 | return this._blackList; 35 | } 36 | 37 | set blackList(value) { 38 | this._blackList = Array.isArray(value) ? value : []; 39 | } 40 | 41 | get isBlackListed() { 42 | return this._isBlackListed; 43 | } 44 | 45 | set isBlackListed(func) { 46 | if (typeof func === 'undefined') { 47 | func = () => false; 48 | } 49 | if (typeof func !== 'function') { 50 | throw new Error('isBlackListed must be function'); 51 | } 52 | this._isBlackListed = func; 53 | } 54 | 55 | get whiteList() { 56 | return this._whiteList; 57 | } 58 | 59 | set whiteList(value) { 60 | this._whiteList = Array.isArray(value) ? value : []; 61 | } 62 | 63 | get isWhiteListed() { 64 | return this._isWhiteListed; 65 | } 66 | 67 | set isWhiteListed(func) { 68 | if (typeof func === 'undefined') { 69 | func = () => false; 70 | } 71 | if (typeof func !== 'function') { 72 | throw new Error('isWhiteListed must be function'); 73 | } 74 | this._isWhiteListed = func; 75 | } 76 | 77 | isBlackListedSomewhere(key) { 78 | return this.blackList.indexOf(key) >= 0 || this.isBlackListed(key); 79 | } 80 | 81 | isWhiteListedSomewhere(key) { 82 | return this.whiteList.indexOf(key) >= 0 || this.isWhiteListed(key); 83 | } 84 | 85 | getBlackRes() { 86 | return new RateLimiterRes(0, Number.MAX_SAFE_INTEGER, 0, false); 87 | } 88 | 89 | getWhiteRes() { 90 | return new RateLimiterRes(Number.MAX_SAFE_INTEGER, 0, 0, false); 91 | } 92 | 93 | rejectBlack() { 94 | return Promise.reject(this.getBlackRes()); 95 | } 96 | 97 | resolveBlack() { 98 | return Promise.resolve(this.getBlackRes()); 99 | } 100 | 101 | resolveWhite() { 102 | return Promise.resolve(this.getWhiteRes()); 103 | } 104 | 105 | consume(key, pointsToConsume = 1) { 106 | let res; 107 | if (this.isWhiteListedSomewhere(key)) { 108 | res = this.resolveWhite(); 109 | } else if (this.isBlackListedSomewhere(key)) { 110 | res = this.rejectBlack(); 111 | } 112 | 113 | if (typeof res === 'undefined') { 114 | return this.limiter.consume(key, pointsToConsume); 115 | } 116 | 117 | if (this.runActionAnyway) { 118 | this.limiter.consume(key, pointsToConsume).catch(() => {}); 119 | } 120 | return res; 121 | } 122 | 123 | block(key, secDuration) { 124 | let res; 125 | if (this.isWhiteListedSomewhere(key)) { 126 | res = this.resolveWhite(); 127 | } else if (this.isBlackListedSomewhere(key)) { 128 | res = this.resolveBlack(); 129 | } 130 | 131 | if (typeof res === 'undefined') { 132 | return this.limiter.block(key, secDuration); 133 | } 134 | 135 | if (this.runActionAnyway) { 136 | this.limiter.block(key, secDuration).catch(() => {}); 137 | } 138 | return res; 139 | } 140 | 141 | penalty(key, points) { 142 | let res; 143 | if (this.isWhiteListedSomewhere(key)) { 144 | res = this.resolveWhite(); 145 | } else if (this.isBlackListedSomewhere(key)) { 146 | res = this.resolveBlack(); 147 | } 148 | 149 | if (typeof res === 'undefined') { 150 | return this.limiter.penalty(key, points); 151 | } 152 | 153 | if (this.runActionAnyway) { 154 | this.limiter.penalty(key, points).catch(() => {}); 155 | } 156 | return res; 157 | } 158 | 159 | reward(key, points) { 160 | let res; 161 | if (this.isWhiteListedSomewhere(key)) { 162 | res = this.resolveWhite(); 163 | } else if (this.isBlackListedSomewhere(key)) { 164 | res = this.resolveBlack(); 165 | } 166 | 167 | if (typeof res === 'undefined') { 168 | return this.limiter.reward(key, points); 169 | } 170 | 171 | if (this.runActionAnyway) { 172 | this.limiter.reward(key, points).catch(() => {}); 173 | } 174 | return res; 175 | } 176 | 177 | get(key) { 178 | let res; 179 | if (this.isWhiteListedSomewhere(key)) { 180 | res = this.resolveWhite(); 181 | } else if (this.isBlackListedSomewhere(key)) { 182 | res = this.resolveBlack(); 183 | } 184 | 185 | if (typeof res === 'undefined' || this.runActionAnyway) { 186 | return this.limiter.get(key); 187 | } 188 | 189 | return res; 190 | } 191 | 192 | delete(key) { 193 | return this.limiter.delete(key); 194 | } 195 | }; 196 | -------------------------------------------------------------------------------- /lib/RateLimiterAbstract.js: -------------------------------------------------------------------------------- 1 | module.exports = class RateLimiterAbstract { 2 | /** 3 | * 4 | * @param opts Object Defaults { 5 | * points: 4, // Number of points 6 | * duration: 1, // Per seconds 7 | * blockDuration: 0, // Block if consumed more than points in current duration for blockDuration seconds 8 | * execEvenly: false, // Execute allowed actions evenly over duration 9 | * execEvenlyMinDelayMs: duration * 1000 / points, // ms, works with execEvenly=true option 10 | * keyPrefix: 'rlflx', 11 | * } 12 | */ 13 | constructor(opts = {}) { 14 | this.points = opts.points; 15 | this.duration = opts.duration; 16 | this.blockDuration = opts.blockDuration; 17 | this.execEvenly = opts.execEvenly; 18 | this.execEvenlyMinDelayMs = opts.execEvenlyMinDelayMs; 19 | this.keyPrefix = opts.keyPrefix; 20 | } 21 | 22 | get points() { 23 | return this._points; 24 | } 25 | 26 | set points(value) { 27 | this._points = value >= 0 ? value : 4; 28 | } 29 | 30 | get duration() { 31 | return this._duration; 32 | } 33 | 34 | set duration(value) { 35 | this._duration = typeof value === 'undefined' ? 1 : value; 36 | } 37 | 38 | get msDuration() { 39 | return this.duration * 1000; 40 | } 41 | 42 | get blockDuration() { 43 | return this._blockDuration; 44 | } 45 | 46 | set blockDuration(value) { 47 | this._blockDuration = typeof value === 'undefined' ? 0 : value; 48 | } 49 | 50 | get msBlockDuration() { 51 | return this.blockDuration * 1000; 52 | } 53 | 54 | get execEvenly() { 55 | return this._execEvenly; 56 | } 57 | 58 | set execEvenly(value) { 59 | this._execEvenly = typeof value === 'undefined' ? false : Boolean(value); 60 | } 61 | 62 | get execEvenlyMinDelayMs() { 63 | return this._execEvenlyMinDelayMs; 64 | } 65 | 66 | set execEvenlyMinDelayMs(value) { 67 | this._execEvenlyMinDelayMs = typeof value === 'undefined' ? Math.ceil(this.msDuration / this.points) : value; 68 | } 69 | 70 | get keyPrefix() { 71 | return this._keyPrefix; 72 | } 73 | 74 | set keyPrefix(value) { 75 | if (typeof value === 'undefined') { 76 | value = 'rlflx'; 77 | } 78 | if (typeof value !== 'string') { 79 | throw new Error('keyPrefix must be string'); 80 | } 81 | this._keyPrefix = value; 82 | } 83 | 84 | _getKeySecDuration(options = {}) { 85 | return options && options.customDuration >= 0 86 | ? options.customDuration 87 | : this.duration; 88 | } 89 | 90 | getKey(key) { 91 | return this.keyPrefix.length > 0 ? `${this.keyPrefix}:${key}` : key; 92 | } 93 | 94 | parseKey(rlKey) { 95 | return rlKey.substring(this.keyPrefix.length); 96 | } 97 | 98 | consume() { 99 | throw new Error("You have to implement the method 'consume'!"); 100 | } 101 | 102 | penalty() { 103 | throw new Error("You have to implement the method 'penalty'!"); 104 | } 105 | 106 | reward() { 107 | throw new Error("You have to implement the method 'reward'!"); 108 | } 109 | 110 | get() { 111 | throw new Error("You have to implement the method 'get'!"); 112 | } 113 | 114 | set() { 115 | throw new Error("You have to implement the method 'set'!"); 116 | } 117 | 118 | block() { 119 | throw new Error("You have to implement the method 'block'!"); 120 | } 121 | 122 | delete() { 123 | throw new Error("You have to implement the method 'delete'!"); 124 | } 125 | }; 126 | -------------------------------------------------------------------------------- /lib/RateLimiterCluster.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements rate limiting in cluster using built-in IPC 3 | * 4 | * Two classes are described here: master and worker 5 | * Master have to be create in the master process without any options. 6 | * Any number of rate limiters can be created in workers, but each rate limiter must be with unique keyPrefix 7 | * 8 | * Workflow: 9 | * 1. master rate limiter created in master process 10 | * 2. worker rate limiter sends 'init' message with necessary options during creating 11 | * 3. master receives options and adds new rate limiter by keyPrefix if it isn't created yet 12 | * 4. master sends 'init' back to worker's rate limiter 13 | * 5. worker can process requests immediately, 14 | * but they will be postponed by 'workerWaitInit' until master sends 'init' to worker 15 | * 6. every request to worker rate limiter creates a promise 16 | * 7. if master doesn't response for 'timeout', promise is rejected 17 | * 8. master sends 'resolve' or 'reject' command to worker 18 | * 9. worker resolves or rejects promise depending on message from master 19 | * 20 | */ 21 | 22 | const cluster = require('cluster'); 23 | const crypto = require('crypto'); 24 | const RateLimiterAbstract = require('./RateLimiterAbstract'); 25 | const RateLimiterMemory = require('./RateLimiterMemory'); 26 | const RateLimiterRes = require('./RateLimiterRes'); 27 | 28 | const channel = 'rate_limiter_flexible'; 29 | let masterInstance = null; 30 | 31 | const masterSendToWorker = function (worker, msg, type, res) { 32 | let data; 33 | if (res === null || res === true || res === false) { 34 | data = res; 35 | } else { 36 | data = { 37 | remainingPoints: res.remainingPoints, 38 | msBeforeNext: res.msBeforeNext, 39 | consumedPoints: res.consumedPoints, 40 | isFirstInDuration: res.isFirstInDuration, 41 | }; 42 | } 43 | worker.send({ 44 | channel, 45 | keyPrefix: msg.keyPrefix, // which rate limiter exactly 46 | promiseId: msg.promiseId, 47 | type, 48 | data, 49 | }); 50 | }; 51 | 52 | const workerWaitInit = function (payload) { 53 | setTimeout(() => { 54 | if (this._initiated) { 55 | process.send(payload); 56 | // Promise will be removed by timeout if too long 57 | } else if (typeof this._promises[payload.promiseId] !== 'undefined') { 58 | workerWaitInit.call(this, payload); 59 | } 60 | }, 30); 61 | }; 62 | 63 | const workerSendToMaster = function (func, promiseId, key, arg, opts) { 64 | const payload = { 65 | channel, 66 | keyPrefix: this.keyPrefix, 67 | func, 68 | promiseId, 69 | data: { 70 | key, 71 | arg, 72 | opts, 73 | }, 74 | }; 75 | 76 | if (!this._initiated) { 77 | // Wait init before sending messages to master 78 | workerWaitInit.call(this, payload); 79 | } else { 80 | process.send(payload); 81 | } 82 | }; 83 | 84 | const masterProcessMsg = function (worker, msg) { 85 | if (!msg || msg.channel !== channel || typeof this._rateLimiters[msg.keyPrefix] === 'undefined') { 86 | return false; 87 | } 88 | 89 | let promise; 90 | 91 | switch (msg.func) { 92 | case 'consume': 93 | promise = this._rateLimiters[msg.keyPrefix].consume(msg.data.key, msg.data.arg, msg.data.opts); 94 | break; 95 | case 'penalty': 96 | promise = this._rateLimiters[msg.keyPrefix].penalty(msg.data.key, msg.data.arg, msg.data.opts); 97 | break; 98 | case 'reward': 99 | promise = this._rateLimiters[msg.keyPrefix].reward(msg.data.key, msg.data.arg, msg.data.opts); 100 | break; 101 | case 'block': 102 | promise = this._rateLimiters[msg.keyPrefix].block(msg.data.key, msg.data.arg, msg.data.opts); 103 | break; 104 | case 'get': 105 | promise = this._rateLimiters[msg.keyPrefix].get(msg.data.key, msg.data.opts); 106 | break; 107 | case 'delete': 108 | promise = this._rateLimiters[msg.keyPrefix].delete(msg.data.key, msg.data.opts); 109 | break; 110 | default: 111 | return false; 112 | } 113 | 114 | if (promise) { 115 | promise 116 | .then((res) => { 117 | masterSendToWorker(worker, msg, 'resolve', res); 118 | }) 119 | .catch((rejRes) => { 120 | masterSendToWorker(worker, msg, 'reject', rejRes); 121 | }); 122 | } 123 | }; 124 | 125 | const workerProcessMsg = function (msg) { 126 | if (!msg || msg.channel !== channel || msg.keyPrefix !== this.keyPrefix) { 127 | return false; 128 | } 129 | 130 | if (this._promises[msg.promiseId]) { 131 | clearTimeout(this._promises[msg.promiseId].timeoutId); 132 | let res; 133 | if (msg.data === null || msg.data === true || msg.data === false) { 134 | res = msg.data; 135 | } else { 136 | res = new RateLimiterRes( 137 | msg.data.remainingPoints, 138 | msg.data.msBeforeNext, 139 | msg.data.consumedPoints, 140 | msg.data.isFirstInDuration // eslint-disable-line comma-dangle 141 | ); 142 | } 143 | 144 | switch (msg.type) { 145 | case 'resolve': 146 | this._promises[msg.promiseId].resolve(res); 147 | break; 148 | case 'reject': 149 | this._promises[msg.promiseId].reject(res); 150 | break; 151 | default: 152 | throw new Error(`RateLimiterCluster: no such message type '${msg.type}'`); 153 | } 154 | 155 | delete this._promises[msg.promiseId]; 156 | } 157 | }; 158 | /** 159 | * Prepare options to send to master 160 | * Master will create rate limiter depending on options 161 | * 162 | * @returns {{points: *, duration: *, blockDuration: *, execEvenly: *, execEvenlyMinDelayMs: *, keyPrefix: *}} 163 | */ 164 | const getOpts = function () { 165 | return { 166 | points: this.points, 167 | duration: this.duration, 168 | blockDuration: this.blockDuration, 169 | execEvenly: this.execEvenly, 170 | execEvenlyMinDelayMs: this.execEvenlyMinDelayMs, 171 | keyPrefix: this.keyPrefix, 172 | }; 173 | }; 174 | 175 | const savePromise = function (resolve, reject) { 176 | const hrtime = process.hrtime(); 177 | let promiseId = hrtime[0].toString() + hrtime[1].toString(); 178 | 179 | if (typeof this._promises[promiseId] !== 'undefined') { 180 | promiseId += crypto.randomBytes(12).toString('base64'); 181 | } 182 | 183 | this._promises[promiseId] = { 184 | resolve, 185 | reject, 186 | timeoutId: setTimeout(() => { 187 | delete this._promises[promiseId]; 188 | reject(new Error('RateLimiterCluster timeout: no answer from master in time')); 189 | }, this.timeoutMs), 190 | }; 191 | 192 | return promiseId; 193 | }; 194 | 195 | class RateLimiterClusterMaster { 196 | constructor() { 197 | if (masterInstance) { 198 | return masterInstance; 199 | } 200 | 201 | this._rateLimiters = {}; 202 | 203 | cluster.setMaxListeners(0); 204 | 205 | cluster.on('message', (worker, msg) => { 206 | if (msg && msg.channel === channel && msg.type === 'init') { 207 | // If init request, check or create rate limiter by key prefix and send 'init' back to worker 208 | if (typeof this._rateLimiters[msg.opts.keyPrefix] === 'undefined') { 209 | this._rateLimiters[msg.opts.keyPrefix] = new RateLimiterMemory(msg.opts); 210 | } 211 | 212 | worker.send({ 213 | channel, 214 | type: 'init', 215 | keyPrefix: msg.opts.keyPrefix, 216 | }); 217 | } else { 218 | masterProcessMsg.call(this, worker, msg); 219 | } 220 | }); 221 | 222 | masterInstance = this; 223 | } 224 | } 225 | 226 | class RateLimiterClusterMasterPM2 { 227 | constructor(pm2) { 228 | if (masterInstance) { 229 | return masterInstance; 230 | } 231 | 232 | this._rateLimiters = {}; 233 | 234 | pm2.launchBus((err, pm2Bus) => { 235 | pm2Bus.on('process:msg', (packet) => { 236 | const msg = packet.raw; 237 | if (msg && msg.channel === channel && msg.type === 'init') { 238 | // If init request, check or create rate limiter by key prefix and send 'init' back to worker 239 | if (typeof this._rateLimiters[msg.opts.keyPrefix] === 'undefined') { 240 | this._rateLimiters[msg.opts.keyPrefix] = new RateLimiterMemory(msg.opts); 241 | } 242 | 243 | pm2.sendDataToProcessId(packet.process.pm_id, { 244 | data: {}, 245 | topic: channel, 246 | channel, 247 | type: 'init', 248 | keyPrefix: msg.opts.keyPrefix, 249 | }, (sendErr, res) => { 250 | if (sendErr) { 251 | console.log(sendErr, res); 252 | } 253 | }); 254 | } else { 255 | const worker = { 256 | send: (msgData) => { 257 | const pm2Message = msgData; 258 | pm2Message.topic = channel; 259 | if (typeof pm2Message.data === 'undefined') { 260 | pm2Message.data = {}; 261 | } 262 | pm2.sendDataToProcessId(packet.process.pm_id, pm2Message, (sendErr, res) => { 263 | if (sendErr) { 264 | console.log(sendErr, res); 265 | } 266 | }); 267 | }, 268 | }; 269 | masterProcessMsg.call(this, worker, msg); 270 | } 271 | }); 272 | }); 273 | 274 | masterInstance = this; 275 | } 276 | } 277 | 278 | class RateLimiterClusterWorker extends RateLimiterAbstract { 279 | get timeoutMs() { 280 | return this._timeoutMs; 281 | } 282 | 283 | set timeoutMs(value) { 284 | this._timeoutMs = typeof value === 'undefined' ? 5000 : Math.abs(parseInt(value)); 285 | } 286 | 287 | constructor(opts = {}) { 288 | super(opts); 289 | 290 | process.setMaxListeners(0); 291 | 292 | this.timeoutMs = opts.timeoutMs; 293 | 294 | this._initiated = false; 295 | 296 | process.on('message', (msg) => { 297 | if (msg && msg.channel === channel && msg.type === 'init' && msg.keyPrefix === this.keyPrefix) { 298 | this._initiated = true; 299 | } else { 300 | workerProcessMsg.call(this, msg); 301 | } 302 | }); 303 | 304 | // Create limiter on master with specific options 305 | process.send({ 306 | channel, 307 | type: 'init', 308 | opts: getOpts.call(this), 309 | }); 310 | 311 | this._promises = {}; 312 | } 313 | 314 | consume(key, pointsToConsume = 1, options = {}) { 315 | return new Promise((resolve, reject) => { 316 | const promiseId = savePromise.call(this, resolve, reject); 317 | 318 | workerSendToMaster.call(this, 'consume', promiseId, key, pointsToConsume, options); 319 | }); 320 | } 321 | 322 | penalty(key, points = 1, options = {}) { 323 | return new Promise((resolve, reject) => { 324 | const promiseId = savePromise.call(this, resolve, reject); 325 | 326 | workerSendToMaster.call(this, 'penalty', promiseId, key, points, options); 327 | }); 328 | } 329 | 330 | reward(key, points = 1, options = {}) { 331 | return new Promise((resolve, reject) => { 332 | const promiseId = savePromise.call(this, resolve, reject); 333 | 334 | workerSendToMaster.call(this, 'reward', promiseId, key, points, options); 335 | }); 336 | } 337 | 338 | block(key, secDuration, options = {}) { 339 | return new Promise((resolve, reject) => { 340 | const promiseId = savePromise.call(this, resolve, reject); 341 | 342 | workerSendToMaster.call(this, 'block', promiseId, key, secDuration, options); 343 | }); 344 | } 345 | 346 | get(key, options = {}) { 347 | return new Promise((resolve, reject) => { 348 | const promiseId = savePromise.call(this, resolve, reject); 349 | 350 | workerSendToMaster.call(this, 'get', promiseId, key, options); 351 | }); 352 | } 353 | 354 | delete(key, options = {}) { 355 | return new Promise((resolve, reject) => { 356 | const promiseId = savePromise.call(this, resolve, reject); 357 | 358 | workerSendToMaster.call(this, 'delete', promiseId, key, options); 359 | }); 360 | } 361 | } 362 | 363 | module.exports = { 364 | RateLimiterClusterMaster, 365 | RateLimiterClusterMasterPM2, 366 | RateLimiterCluster: RateLimiterClusterWorker, 367 | }; 368 | -------------------------------------------------------------------------------- /lib/RateLimiterEtcd.js: -------------------------------------------------------------------------------- 1 | const RateLimiterEtcdTransactionFailedError = require('./component/RateLimiterEtcdTransactionFailedError'); 2 | const RateLimiterEtcdNonAtomic = require('./RateLimiterEtcdNonAtomic'); 3 | 4 | const MAX_TRANSACTION_TRIES = 5; 5 | 6 | class RateLimiterEtcd extends RateLimiterEtcdNonAtomic { 7 | /** 8 | * Resolve with object used for {@link _getRateLimiterRes} to generate {@link RateLimiterRes}. 9 | */ 10 | async _upsert(rlKey, points, msDuration, forceExpire = false) { 11 | const expire = msDuration > 0 ? Date.now() + msDuration : null; 12 | 13 | let newValue = { points, expire }; 14 | let oldValue; 15 | 16 | // If we need to force the expiration, just set the key. 17 | if (forceExpire) { 18 | await this.client 19 | .put(rlKey) 20 | .value(JSON.stringify(newValue)); 21 | } else { 22 | // First try to add a new key 23 | const added = await this.client 24 | .if(rlKey, 'Version', '===', '0') 25 | .then(this.client 26 | .put(rlKey) 27 | .value(JSON.stringify(newValue))) 28 | .commit() 29 | .then(result => !!result.succeeded); 30 | 31 | // If the key already existed, try to update it in a transaction 32 | if (!added) { 33 | let success = false; 34 | 35 | for (let i = 0; i < MAX_TRANSACTION_TRIES; i++) { 36 | // eslint-disable-next-line no-await-in-loop 37 | oldValue = await this._get(rlKey); 38 | newValue = { points: oldValue.points + points, expire }; 39 | 40 | // eslint-disable-next-line no-await-in-loop 41 | success = await this.client 42 | .if(rlKey, 'Value', '===', JSON.stringify(oldValue)) 43 | .then(this.client 44 | .put(rlKey) 45 | .value(JSON.stringify(newValue))) 46 | .commit() 47 | .then(result => !!result.succeeded); 48 | if (success) { 49 | break; 50 | } 51 | } 52 | 53 | if (!success) { 54 | throw new RateLimiterEtcdTransactionFailedError('Could not set new value in a transaction.'); 55 | } 56 | } 57 | } 58 | 59 | return newValue; 60 | } 61 | } 62 | 63 | module.exports = RateLimiterEtcd; 64 | -------------------------------------------------------------------------------- /lib/RateLimiterEtcdNonAtomic.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | const RateLimiterSetupError = require('./component/RateLimiterSetupError'); 4 | 5 | class RateLimiterEtcdNonAtomic extends RateLimiterStoreAbstract { 6 | /** 7 | * @param {Object} opts 8 | */ 9 | constructor(opts) { 10 | super(opts); 11 | 12 | if (!opts.storeClient) { 13 | throw new RateLimiterSetupError('You need to set the option "storeClient" to an instance of class "Etcd3".'); 14 | } 15 | 16 | this.client = opts.storeClient; 17 | } 18 | 19 | /** 20 | * Get RateLimiterRes object filled depending on storeResult, which specific for exact store. 21 | */ 22 | _getRateLimiterRes(rlKey, changedPoints, result) { 23 | const res = new RateLimiterRes(); 24 | 25 | res.isFirstInDuration = changedPoints === result.points; 26 | res.consumedPoints = res.isFirstInDuration ? changedPoints : result.points; 27 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 28 | res.msBeforeNext = result.expire ? Math.max(result.expire - Date.now(), 0) : -1; 29 | 30 | return res; 31 | } 32 | 33 | /** 34 | * Resolve with object used for {@link _getRateLimiterRes} to generate {@link RateLimiterRes}. 35 | */ 36 | async _upsert(rlKey, points, msDuration, forceExpire = false) { 37 | const expire = msDuration > 0 ? Date.now() + msDuration : null; 38 | 39 | let newValue = { points, expire }; 40 | 41 | // If we need to force the expiration, just set the key. 42 | if (forceExpire) { 43 | await this.client 44 | .put(rlKey) 45 | .value(JSON.stringify(newValue)); 46 | } else { 47 | const oldValue = await this._get(rlKey); 48 | newValue = { points: (oldValue !== null ? oldValue.points : 0) + points, expire }; 49 | await this.client 50 | .put(rlKey) 51 | .value(JSON.stringify(newValue)); 52 | } 53 | 54 | return newValue; 55 | } 56 | 57 | /** 58 | * Resolve with raw result from Store OR null if rlKey is not set 59 | * or Reject with error 60 | */ 61 | async _get(rlKey) { 62 | return this.client 63 | .get(rlKey) 64 | .string() 65 | .then(result => (result !== null ? JSON.parse(result) : null)); 66 | } 67 | 68 | /** 69 | * Resolve with true OR false if rlKey doesn't exist. 70 | * or Reject with error. 71 | */ 72 | async _delete(rlKey) { 73 | return this.client 74 | .delete() 75 | .key(rlKey) 76 | .then(result => result.deleted === '1'); 77 | } 78 | } 79 | 80 | module.exports = RateLimiterEtcdNonAtomic; 81 | -------------------------------------------------------------------------------- /lib/RateLimiterMemcache.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | class RateLimiterMemcache extends RateLimiterStoreAbstract { 5 | /** 6 | * 7 | * @param {Object} opts 8 | * Defaults { 9 | * ... see other in RateLimiterStoreAbstract 10 | * 11 | * storeClient: memcacheClient 12 | * } 13 | */ 14 | constructor(opts) { 15 | super(opts); 16 | 17 | this.client = opts.storeClient; 18 | } 19 | 20 | _getRateLimiterRes(rlKey, changedPoints, result) { 21 | const res = new RateLimiterRes(); 22 | res.consumedPoints = parseInt(result.consumedPoints); 23 | res.isFirstInDuration = result.consumedPoints === changedPoints; 24 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 25 | res.msBeforeNext = result.msBeforeNext; 26 | 27 | return res; 28 | } 29 | 30 | _upsert(rlKey, points, msDuration, forceExpire = false, options = {}) { 31 | return new Promise((resolve, reject) => { 32 | const nowMs = Date.now(); 33 | const secDuration = Math.floor(msDuration / 1000); 34 | 35 | if (forceExpire) { 36 | this.client.set(rlKey, points, secDuration, (err) => { 37 | if (!err) { 38 | this.client.set( 39 | `${rlKey}_expire`, 40 | secDuration > 0 ? nowMs + (secDuration * 1000) : -1, 41 | secDuration, 42 | () => { 43 | const res = { 44 | consumedPoints: points, 45 | msBeforeNext: secDuration > 0 ? secDuration * 1000 : -1, 46 | }; 47 | resolve(res); 48 | } 49 | ); 50 | } else { 51 | reject(err); 52 | } 53 | }); 54 | } else { 55 | this.client.incr(rlKey, points, (err, consumedPoints) => { 56 | if (err || consumedPoints === false) { 57 | this.client.add(rlKey, points, secDuration, (errAddKey, createdNew) => { 58 | if (errAddKey || !createdNew) { 59 | // Try to upsert again in case of race condition 60 | if (typeof options.attemptNumber === 'undefined' || options.attemptNumber < 3) { 61 | const nextOptions = Object.assign({}, options); 62 | nextOptions.attemptNumber = nextOptions.attemptNumber ? (nextOptions.attemptNumber + 1) : 1; 63 | 64 | this._upsert(rlKey, points, msDuration, forceExpire, nextOptions) 65 | .then(resUpsert => resolve(resUpsert)) 66 | .catch(errUpsert => reject(errUpsert)); 67 | } else { 68 | reject(new Error('Can not add key')); 69 | } 70 | } else { 71 | this.client.add( 72 | `${rlKey}_expire`, 73 | secDuration > 0 ? nowMs + (secDuration * 1000) : -1, 74 | secDuration, 75 | () => { 76 | const res = { 77 | consumedPoints: points, 78 | msBeforeNext: secDuration > 0 ? secDuration * 1000 : -1, 79 | }; 80 | resolve(res); 81 | } 82 | ); 83 | } 84 | }); 85 | } else { 86 | this.client.get(`${rlKey}_expire`, (errGetExpire, resGetExpireMs) => { 87 | if (errGetExpire) { 88 | reject(errGetExpire); 89 | } else { 90 | const expireMs = resGetExpireMs === false ? 0 : resGetExpireMs; 91 | const res = { 92 | consumedPoints, 93 | msBeforeNext: expireMs >= 0 ? Math.max(expireMs - nowMs, 0) : -1, 94 | }; 95 | resolve(res); 96 | } 97 | }); 98 | } 99 | }); 100 | } 101 | }); 102 | } 103 | 104 | _get(rlKey) { 105 | return new Promise((resolve, reject) => { 106 | const nowMs = Date.now(); 107 | 108 | this.client.get(rlKey, (err, consumedPoints) => { 109 | if (!consumedPoints) { 110 | resolve(null); 111 | } else { 112 | this.client.get(`${rlKey}_expire`, (errGetExpire, resGetExpireMs) => { 113 | if (errGetExpire) { 114 | reject(errGetExpire); 115 | } else { 116 | const expireMs = resGetExpireMs === false ? 0 : resGetExpireMs; 117 | const res = { 118 | consumedPoints, 119 | msBeforeNext: expireMs >= 0 ? Math.max(expireMs - nowMs, 0) : -1, 120 | }; 121 | resolve(res); 122 | } 123 | }); 124 | } 125 | }); 126 | }); 127 | } 128 | 129 | _delete(rlKey) { 130 | return new Promise((resolve, reject) => { 131 | this.client.del(rlKey, (err, res) => { 132 | if (err) { 133 | reject(err); 134 | } else if (res === false) { 135 | resolve(res); 136 | } else { 137 | this.client.del(`${rlKey}_expire`, (errDelExpire) => { 138 | if (errDelExpire) { 139 | reject(errDelExpire); 140 | } else { 141 | resolve(res); 142 | } 143 | }); 144 | } 145 | }); 146 | }); 147 | } 148 | } 149 | 150 | module.exports = RateLimiterMemcache; 151 | -------------------------------------------------------------------------------- /lib/RateLimiterMemory.js: -------------------------------------------------------------------------------- 1 | const RateLimiterAbstract = require('./RateLimiterAbstract'); 2 | const MemoryStorage = require('./component/MemoryStorage/MemoryStorage'); 3 | const RateLimiterRes = require('./RateLimiterRes'); 4 | 5 | class RateLimiterMemory extends RateLimiterAbstract { 6 | constructor(opts = {}) { 7 | super(opts); 8 | 9 | this._memoryStorage = new MemoryStorage(); 10 | } 11 | /** 12 | * 13 | * @param key 14 | * @param pointsToConsume 15 | * @param {Object} options 16 | * @returns {Promise<RateLimiterRes>} 17 | */ 18 | consume(key, pointsToConsume = 1, options = {}) { 19 | return new Promise((resolve, reject) => { 20 | const rlKey = this.getKey(key); 21 | const secDuration = this._getKeySecDuration(options); 22 | let res = this._memoryStorage.incrby(rlKey, pointsToConsume, secDuration); 23 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 24 | 25 | if (res.consumedPoints > this.points) { 26 | // Block only first time when consumed more than points 27 | if (this.blockDuration > 0 && res.consumedPoints <= (this.points + pointsToConsume)) { 28 | // Block key 29 | res = this._memoryStorage.set(rlKey, res.consumedPoints, this.blockDuration); 30 | } 31 | reject(res); 32 | } else if (this.execEvenly && res.msBeforeNext > 0 && !res.isFirstInDuration) { 33 | // Execute evenly 34 | let delay = Math.ceil(res.msBeforeNext / (res.remainingPoints + 2)); 35 | if (delay < this.execEvenlyMinDelayMs) { 36 | delay = res.consumedPoints * this.execEvenlyMinDelayMs; 37 | } 38 | 39 | setTimeout(resolve, delay, res); 40 | } else { 41 | resolve(res); 42 | } 43 | }); 44 | } 45 | 46 | penalty(key, points = 1, options = {}) { 47 | const rlKey = this.getKey(key); 48 | return new Promise((resolve) => { 49 | const secDuration = this._getKeySecDuration(options); 50 | const res = this._memoryStorage.incrby(rlKey, points, secDuration); 51 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 52 | resolve(res); 53 | }); 54 | } 55 | 56 | reward(key, points = 1, options = {}) { 57 | const rlKey = this.getKey(key); 58 | return new Promise((resolve) => { 59 | const secDuration = this._getKeySecDuration(options); 60 | const res = this._memoryStorage.incrby(rlKey, -points, secDuration); 61 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 62 | resolve(res); 63 | }); 64 | } 65 | 66 | /** 67 | * Block any key for secDuration seconds 68 | * 69 | * @param key 70 | * @param secDuration 71 | */ 72 | block(key, secDuration) { 73 | const msDuration = secDuration * 1000; 74 | const initPoints = this.points + 1; 75 | 76 | this._memoryStorage.set(this.getKey(key), initPoints, secDuration); 77 | return Promise.resolve( 78 | new RateLimiterRes(0, msDuration === 0 ? -1 : msDuration, initPoints) 79 | ); 80 | } 81 | 82 | set(key, points, secDuration) { 83 | const msDuration = (secDuration >= 0 ? secDuration : this.duration) * 1000; 84 | 85 | this._memoryStorage.set(this.getKey(key), points, secDuration); 86 | return Promise.resolve( 87 | new RateLimiterRes(0, msDuration === 0 ? -1 : msDuration, points) 88 | ); 89 | } 90 | 91 | get(key) { 92 | const res = this._memoryStorage.get(this.getKey(key)); 93 | if (res !== null) { 94 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 95 | } 96 | 97 | return Promise.resolve(res); 98 | } 99 | 100 | delete(key) { 101 | return Promise.resolve(this._memoryStorage.delete(this.getKey(key))); 102 | } 103 | } 104 | 105 | module.exports = RateLimiterMemory; 106 | 107 | -------------------------------------------------------------------------------- /lib/RateLimiterMongo.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | /** 5 | * Get MongoDB driver version as upsert options differ 6 | * @params {Object} Client instance 7 | * @returns {Object} Version Object containing major, feature & minor versions. 8 | */ 9 | function getDriverVersion(client) { 10 | try { 11 | const _client = client.client ? client.client : client; 12 | 13 | let _v = [0, 0, 0]; 14 | if (typeof _client.topology === 'undefined') { 15 | const { version } = _client.options.metadata.driver; 16 | _v = version.split('|', 1)[0].split('.').map(v => parseInt(v)); 17 | } else { 18 | const { version } = _client.topology.s.options.metadata.driver; 19 | _v = version.split('.').map(v => parseInt(v)); 20 | } 21 | 22 | return { 23 | major: _v[0], 24 | feature: _v[1], 25 | patch: _v[2], 26 | }; 27 | } catch (err) { 28 | return { major: 0, feature: 0, patch: 0 }; 29 | } 30 | } 31 | 32 | class RateLimiterMongo extends RateLimiterStoreAbstract { 33 | /** 34 | * 35 | * @param {Object} opts 36 | * Defaults { 37 | * indexKeyPrefix: {attr1: 1, attr2: 1} 38 | * ... see other in RateLimiterStoreAbstract 39 | * 40 | * mongo: MongoClient 41 | * } 42 | */ 43 | constructor(opts) { 44 | super(opts); 45 | 46 | this.dbName = opts.dbName; 47 | this.tableName = opts.tableName; 48 | this.indexKeyPrefix = opts.indexKeyPrefix; 49 | 50 | if (opts.mongo) { 51 | this.client = opts.mongo; 52 | } else { 53 | this.client = opts.storeClient; 54 | } 55 | if (typeof this.client.then === 'function') { 56 | // If Promise 57 | this.client 58 | .then((conn) => { 59 | this.client = conn; 60 | this._initCollection(); 61 | this._driverVersion = getDriverVersion(this.client); 62 | }); 63 | } else { 64 | this._initCollection(); 65 | this._driverVersion = getDriverVersion(this.client); 66 | } 67 | } 68 | 69 | get dbName() { 70 | return this._dbName; 71 | } 72 | 73 | set dbName(value) { 74 | this._dbName = typeof value === 'undefined' ? RateLimiterMongo.getDbName() : value; 75 | } 76 | 77 | static getDbName() { 78 | return 'node-rate-limiter-flexible'; 79 | } 80 | 81 | get tableName() { 82 | return this._tableName; 83 | } 84 | 85 | set tableName(value) { 86 | this._tableName = typeof value === 'undefined' ? this.keyPrefix : value; 87 | } 88 | 89 | get client() { 90 | return this._client; 91 | } 92 | 93 | set client(value) { 94 | if (typeof value === 'undefined') { 95 | throw new Error('mongo is not set'); 96 | } 97 | this._client = value; 98 | } 99 | 100 | get indexKeyPrefix() { 101 | return this._indexKeyPrefix; 102 | } 103 | 104 | set indexKeyPrefix(obj) { 105 | this._indexKeyPrefix = obj || {}; 106 | } 107 | 108 | _initCollection() { 109 | const db = typeof this.client.db === 'function' 110 | ? this.client.db(this.dbName) 111 | : this.client; 112 | 113 | const collection = db.collection(this.tableName); 114 | collection.createIndex({ expire: -1 }, { expireAfterSeconds: 0 }); 115 | collection.createIndex(Object.assign({}, this.indexKeyPrefix, { key: 1 }), { unique: true }); 116 | 117 | this._collection = collection; 118 | } 119 | 120 | _getRateLimiterRes(rlKey, changedPoints, result) { 121 | const res = new RateLimiterRes(); 122 | 123 | let doc; 124 | if (typeof result.value === 'undefined') { 125 | doc = result; 126 | } else { 127 | doc = result.value; 128 | } 129 | 130 | res.isFirstInDuration = doc.points === changedPoints; 131 | res.consumedPoints = doc.points; 132 | 133 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 134 | res.msBeforeNext = doc.expire !== null 135 | ? Math.max(new Date(doc.expire).getTime() - Date.now(), 0) 136 | : -1; 137 | 138 | return res; 139 | } 140 | 141 | _upsert(key, points, msDuration, forceExpire = false, options = {}) { 142 | if (!this._collection) { 143 | return Promise.reject(Error('Mongo connection is not established')); 144 | } 145 | 146 | const docAttrs = options.attrs || {}; 147 | 148 | let where; 149 | let upsertData; 150 | if (forceExpire) { 151 | where = { key }; 152 | where = Object.assign(where, docAttrs); 153 | upsertData = { 154 | $set: { 155 | key, 156 | points, 157 | expire: msDuration > 0 ? new Date(Date.now() + msDuration) : null, 158 | }, 159 | }; 160 | upsertData.$set = Object.assign(upsertData.$set, docAttrs); 161 | } else { 162 | where = { 163 | $or: [ 164 | { expire: { $gt: new Date() } }, 165 | { expire: { $eq: null } }, 166 | ], 167 | key, 168 | }; 169 | where = Object.assign(where, docAttrs); 170 | upsertData = { 171 | $setOnInsert: { 172 | key, 173 | expire: msDuration > 0 ? new Date(Date.now() + msDuration) : null, 174 | }, 175 | $inc: { points }, 176 | }; 177 | upsertData.$setOnInsert = Object.assign(upsertData.$setOnInsert, docAttrs); 178 | } 179 | 180 | // Options for collection updates differ between driver versions 181 | const upsertOptions = { 182 | upsert: true, 183 | }; 184 | if ((this._driverVersion.major >= 4) || 185 | (this._driverVersion.major === 3 && 186 | (this._driverVersion.feature >=7) || 187 | (this._driverVersion.feature >= 6 && 188 | this._driverVersion.patch >= 7 ))) 189 | { 190 | upsertOptions.returnDocument = 'after'; 191 | } else { 192 | upsertOptions.returnOriginal = false; 193 | } 194 | 195 | /* 196 | * 1. Find actual limit and increment points 197 | * 2. If limit expired, but Mongo doesn't clean doc by TTL yet, try to replace limit doc completely 198 | * 3. If 2 or more Mongo threads try to insert the new limit doc, only the first succeed 199 | * 4. Try to upsert from step 1. Actual limit is created now, points are incremented without problems 200 | */ 201 | return new Promise((resolve, reject) => { 202 | this._collection.findOneAndUpdate( 203 | where, 204 | upsertData, 205 | upsertOptions 206 | ).then((res) => { 207 | resolve(res); 208 | }).catch((errUpsert) => { 209 | if (errUpsert && errUpsert.code === 11000) { // E11000 duplicate key error collection 210 | const replaceWhere = Object.assign({ // try to replace OLD limit doc 211 | $or: [ 212 | { expire: { $lte: new Date() } }, 213 | { expire: { $eq: null } }, 214 | ], 215 | key, 216 | }, docAttrs); 217 | 218 | const replaceTo = { 219 | $set: Object.assign({ 220 | key, 221 | points, 222 | expire: msDuration > 0 ? new Date(Date.now() + msDuration) : null, 223 | }, docAttrs) 224 | }; 225 | 226 | this._collection.findOneAndUpdate( 227 | replaceWhere, 228 | replaceTo, 229 | upsertOptions 230 | ).then((res) => { 231 | resolve(res); 232 | }).catch((errReplace) => { 233 | if (errReplace && errReplace.code === 11000) { // E11000 duplicate key error collection 234 | this._upsert(key, points, msDuration, forceExpire) 235 | .then(res => resolve(res)) 236 | .catch(err => reject(err)); 237 | } else { 238 | reject(errReplace); 239 | } 240 | }); 241 | } else { 242 | reject(errUpsert); 243 | } 244 | }); 245 | }); 246 | } 247 | 248 | _get(rlKey, options = {}) { 249 | if (!this._collection) { 250 | return Promise.reject(Error('Mongo connection is not established')); 251 | } 252 | 253 | const docAttrs = options.attrs || {}; 254 | 255 | const where = Object.assign({ 256 | key: rlKey, 257 | $or: [ 258 | { expire: { $gt: new Date() } }, 259 | { expire: { $eq: null } }, 260 | ], 261 | }, docAttrs); 262 | 263 | return this._collection.findOne(where); 264 | } 265 | 266 | _delete(rlKey, options = {}) { 267 | if (!this._collection) { 268 | return Promise.reject(Error('Mongo connection is not established')); 269 | } 270 | 271 | const docAttrs = options.attrs || {}; 272 | const where = Object.assign({ key: rlKey }, docAttrs); 273 | 274 | return this._collection.deleteOne(where) 275 | .then(res => res.deletedCount > 0); 276 | } 277 | } 278 | 279 | module.exports = RateLimiterMongo; 280 | -------------------------------------------------------------------------------- /lib/RateLimiterMySQL.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | class RateLimiterMySQL extends RateLimiterStoreAbstract { 5 | /** 6 | * @callback callback 7 | * @param {Object} err 8 | * 9 | * @param {Object} opts 10 | * @param {callback} cb 11 | * Defaults { 12 | * ... see other in RateLimiterStoreAbstract 13 | * 14 | * storeClient: anySqlClient, 15 | * storeType: 'knex', // required only for Knex instance 16 | * dbName: 'string', 17 | * tableName: 'string', 18 | * } 19 | */ 20 | constructor(opts, cb = null) { 21 | super(opts); 22 | 23 | this.client = opts.storeClient; 24 | this.clientType = opts.storeType; 25 | 26 | this.dbName = opts.dbName; 27 | this.tableName = opts.tableName; 28 | 29 | this.clearExpiredByTimeout = opts.clearExpiredByTimeout; 30 | 31 | this.tableCreated = opts.tableCreated; 32 | if (!this.tableCreated) { 33 | this._createDbAndTable() 34 | .then(() => { 35 | this.tableCreated = true; 36 | if (this.clearExpiredByTimeout) { 37 | this._clearExpiredHourAgo(); 38 | } 39 | if (typeof cb === 'function') { 40 | cb(); 41 | } 42 | }) 43 | .catch((err) => { 44 | if (typeof cb === 'function') { 45 | cb(err); 46 | } else { 47 | throw err; 48 | } 49 | }); 50 | } else { 51 | if (this.clearExpiredByTimeout) { 52 | this._clearExpiredHourAgo(); 53 | } 54 | if (typeof cb === 'function') { 55 | cb(); 56 | } 57 | } 58 | } 59 | 60 | clearExpired(expire) { 61 | return new Promise((resolve) => { 62 | this._getConnection() 63 | .then((conn) => { 64 | conn.query(`DELETE FROM ??.?? WHERE expire < ?`, [this.dbName, this.tableName, expire], () => { 65 | this._releaseConnection(conn); 66 | resolve(); 67 | }); 68 | }) 69 | .catch(() => { 70 | resolve(); 71 | }); 72 | }); 73 | } 74 | 75 | _clearExpiredHourAgo() { 76 | if (this._clearExpiredTimeoutId) { 77 | clearTimeout(this._clearExpiredTimeoutId); 78 | } 79 | this._clearExpiredTimeoutId = setTimeout(() => { 80 | this.clearExpired(Date.now() - 3600000) // Never rejected 81 | .then(() => { 82 | this._clearExpiredHourAgo(); 83 | }); 84 | }, 300000); 85 | this._clearExpiredTimeoutId.unref(); 86 | } 87 | 88 | /** 89 | * 90 | * @return Promise<any> 91 | * @private 92 | */ 93 | _getConnection() { 94 | switch (this.clientType) { 95 | case 'pool': 96 | return new Promise((resolve, reject) => { 97 | this.client.getConnection((errConn, conn) => { 98 | if (errConn) { 99 | return reject(errConn); 100 | } 101 | 102 | resolve(conn); 103 | }); 104 | }); 105 | case 'sequelize': 106 | return this.client.connectionManager.getConnection(); 107 | case 'knex': 108 | return this.client.client.acquireConnection(); 109 | default: 110 | return Promise.resolve(this.client); 111 | } 112 | } 113 | 114 | _releaseConnection(conn) { 115 | switch (this.clientType) { 116 | case 'pool': 117 | return conn.release(); 118 | case 'sequelize': 119 | return this.client.connectionManager.releaseConnection(conn); 120 | case 'knex': 121 | return this.client.client.releaseConnection(conn); 122 | default: 123 | return true; 124 | } 125 | } 126 | 127 | /** 128 | * 129 | * @returns {Promise<any>} 130 | * @private 131 | */ 132 | _createDbAndTable() { 133 | return new Promise((resolve, reject) => { 134 | this._getConnection() 135 | .then((conn) => { 136 | conn.query(`CREATE DATABASE IF NOT EXISTS \`${this.dbName}\`;`, (errDb) => { 137 | if (errDb) { 138 | this._releaseConnection(conn); 139 | return reject(errDb); 140 | } 141 | conn.query(this._getCreateTableStmt(), (err) => { 142 | if (err) { 143 | this._releaseConnection(conn); 144 | return reject(err); 145 | } 146 | this._releaseConnection(conn); 147 | resolve(); 148 | }); 149 | }); 150 | }) 151 | .catch((err) => { 152 | reject(err); 153 | }); 154 | }); 155 | } 156 | 157 | _getCreateTableStmt() { 158 | return `CREATE TABLE IF NOT EXISTS \`${this.dbName}\`.\`${this.tableName}\` (` + 159 | '`key` VARCHAR(255) CHARACTER SET utf8 NOT NULL,' + 160 | '`points` INT(9) NOT NULL default 0,' + 161 | '`expire` BIGINT UNSIGNED,' + 162 | 'PRIMARY KEY (`key`)' + 163 | ') ENGINE = INNODB;'; 164 | } 165 | 166 | get clientType() { 167 | return this._clientType; 168 | } 169 | 170 | set clientType(value) { 171 | if (typeof value === 'undefined') { 172 | if (this.client.constructor.name === 'Connection') { 173 | value = 'connection'; 174 | } else if (this.client.constructor.name === 'Pool') { 175 | value = 'pool'; 176 | } else if (this.client.constructor.name === 'Sequelize') { 177 | value = 'sequelize'; 178 | } else { 179 | throw new Error('storeType is not defined'); 180 | } 181 | } 182 | this._clientType = value.toLowerCase(); 183 | } 184 | 185 | get dbName() { 186 | return this._dbName; 187 | } 188 | 189 | set dbName(value) { 190 | this._dbName = typeof value === 'undefined' ? 'rtlmtrflx' : value; 191 | } 192 | 193 | get tableName() { 194 | return this._tableName; 195 | } 196 | 197 | set tableName(value) { 198 | this._tableName = typeof value === 'undefined' ? this.keyPrefix : value; 199 | } 200 | 201 | get tableCreated() { 202 | return this._tableCreated 203 | } 204 | 205 | set tableCreated(value) { 206 | this._tableCreated = typeof value === 'undefined' ? false : !!value; 207 | } 208 | 209 | get clearExpiredByTimeout() { 210 | return this._clearExpiredByTimeout; 211 | } 212 | 213 | set clearExpiredByTimeout(value) { 214 | this._clearExpiredByTimeout = typeof value === 'undefined' ? true : Boolean(value); 215 | } 216 | 217 | _getRateLimiterRes(rlKey, changedPoints, result) { 218 | const res = new RateLimiterRes(); 219 | const [row] = result; 220 | 221 | res.isFirstInDuration = changedPoints === row.points; 222 | res.consumedPoints = res.isFirstInDuration ? changedPoints : row.points; 223 | 224 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 225 | res.msBeforeNext = row.expire 226 | ? Math.max(row.expire - Date.now(), 0) 227 | : -1; 228 | 229 | return res; 230 | } 231 | 232 | _upsertTransaction(conn, key, points, msDuration, forceExpire) { 233 | return new Promise((resolve, reject) => { 234 | conn.query('BEGIN', (errBegin) => { 235 | if (errBegin) { 236 | conn.rollback(); 237 | 238 | return reject(errBegin); 239 | } 240 | 241 | const dateNow = Date.now(); 242 | const newExpire = msDuration > 0 ? dateNow + msDuration : null; 243 | 244 | let q; 245 | let values; 246 | if (forceExpire) { 247 | q = `INSERT INTO ??.?? VALUES (?, ?, ?) 248 | ON DUPLICATE KEY UPDATE 249 | points = ?, 250 | expire = ?;`; 251 | values = [ 252 | this.dbName, this.tableName, key, points, newExpire, 253 | points, 254 | newExpire, 255 | ]; 256 | } else { 257 | q = `INSERT INTO ??.?? VALUES (?, ?, ?) 258 | ON DUPLICATE KEY UPDATE 259 | points = IF(expire <= ?, ?, points + (?)), 260 | expire = IF(expire <= ?, ?, expire);`; 261 | values = [ 262 | this.dbName, this.tableName, key, points, newExpire, 263 | dateNow, points, points, 264 | dateNow, newExpire, 265 | ]; 266 | } 267 | 268 | conn.query(q, values, (errUpsert) => { 269 | if (errUpsert) { 270 | conn.rollback(); 271 | 272 | return reject(errUpsert); 273 | } 274 | conn.query('SELECT points, expire FROM ??.?? WHERE `key` = ?;', [this.dbName, this.tableName, key], (errSelect, res) => { 275 | if (errSelect) { 276 | conn.rollback(); 277 | 278 | return reject(errSelect); 279 | } 280 | 281 | conn.query('COMMIT', (err) => { 282 | if (err) { 283 | conn.rollback(); 284 | 285 | return reject(err); 286 | } 287 | 288 | resolve(res); 289 | }); 290 | }); 291 | }); 292 | }); 293 | }); 294 | } 295 | 296 | _upsert(key, points, msDuration, forceExpire = false) { 297 | if (!this.tableCreated) { 298 | return Promise.reject(Error('Table is not created yet')); 299 | } 300 | 301 | return new Promise((resolve, reject) => { 302 | this._getConnection() 303 | .then((conn) => { 304 | this._upsertTransaction(conn, key, points, msDuration, forceExpire) 305 | .then((res) => { 306 | resolve(res); 307 | this._releaseConnection(conn); 308 | }) 309 | .catch((err) => { 310 | reject(err); 311 | this._releaseConnection(conn); 312 | }); 313 | }) 314 | .catch((err) => { 315 | reject(err); 316 | }); 317 | }); 318 | } 319 | 320 | _get(rlKey) { 321 | if (!this.tableCreated) { 322 | return Promise.reject(Error('Table is not created yet')); 323 | } 324 | 325 | return new Promise((resolve, reject) => { 326 | this._getConnection() 327 | .then((conn) => { 328 | conn.query( 329 | 'SELECT points, expire FROM ??.?? WHERE `key` = ? AND (`expire` > ? OR `expire` IS NULL)', 330 | [this.dbName, this.tableName, rlKey, Date.now()], 331 | (err, res) => { 332 | if (err) { 333 | reject(err); 334 | } else if (res.length === 0) { 335 | resolve(null); 336 | } else { 337 | resolve(res); 338 | } 339 | 340 | this._releaseConnection(conn); 341 | } // eslint-disable-line 342 | ); 343 | }) 344 | .catch((err) => { 345 | reject(err); 346 | }); 347 | }); 348 | } 349 | 350 | _delete(rlKey) { 351 | if (!this.tableCreated) { 352 | return Promise.reject(Error('Table is not created yet')); 353 | } 354 | 355 | return new Promise((resolve, reject) => { 356 | this._getConnection() 357 | .then((conn) => { 358 | conn.query( 359 | 'DELETE FROM ??.?? WHERE `key` = ?', 360 | [this.dbName, this.tableName, rlKey], 361 | (err, res) => { 362 | if (err) { 363 | reject(err); 364 | } else { 365 | resolve(res.affectedRows > 0); 366 | } 367 | 368 | this._releaseConnection(conn); 369 | } // eslint-disable-line 370 | ); 371 | }) 372 | .catch((err) => { 373 | reject(err); 374 | }); 375 | }); 376 | } 377 | } 378 | 379 | module.exports = RateLimiterMySQL; 380 | -------------------------------------------------------------------------------- /lib/RateLimiterPostgres.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | class RateLimiterPostgres extends RateLimiterStoreAbstract { 5 | /** 6 | * @callback callback 7 | * @param {Object} err 8 | * 9 | * @param {Object} opts 10 | * @param {callback} cb 11 | * Defaults { 12 | * ... see other in RateLimiterStoreAbstract 13 | * 14 | * storeClient: postgresClient, 15 | * storeType: 'knex', // required only for Knex instance 16 | * tableName: 'string', 17 | * schemaName: 'string', // optional 18 | * } 19 | */ 20 | constructor(opts, cb = null) { 21 | super(opts); 22 | 23 | this.client = opts.storeClient; 24 | this.clientType = opts.storeType; 25 | 26 | this.tableName = opts.tableName; 27 | this.schemaName = opts.schemaName; 28 | 29 | this.clearExpiredByTimeout = opts.clearExpiredByTimeout; 30 | 31 | this.tableCreated = opts.tableCreated; 32 | if (!this.tableCreated) { 33 | this._createTable() 34 | .then(() => { 35 | this.tableCreated = true; 36 | if (this.clearExpiredByTimeout) { 37 | this._clearExpiredHourAgo(); 38 | } 39 | if (typeof cb === 'function') { 40 | cb(); 41 | } 42 | }) 43 | .catch((err) => { 44 | if (typeof cb === 'function') { 45 | cb(err); 46 | } else { 47 | throw err; 48 | } 49 | }); 50 | } else { 51 | if (this.clearExpiredByTimeout) { 52 | this._clearExpiredHourAgo(); 53 | } 54 | if (typeof cb === 'function') { 55 | cb(); 56 | } 57 | } 58 | } 59 | 60 | _getTableIdentifier() { 61 | return this.schemaName ? `"${this.schemaName}"."${this.tableName}"` : `"${this.tableName}"`; 62 | } 63 | 64 | clearExpired(expire) { 65 | return new Promise((resolve) => { 66 | const q = { 67 | name: 'rlflx-clear-expired', 68 | text: `DELETE FROM ${this._getTableIdentifier()} WHERE expire < $1`, 69 | values: [expire], 70 | }; 71 | this._query(q) 72 | .then(() => { 73 | resolve(); 74 | }) 75 | .catch(() => { 76 | // Deleting expired query is not critical 77 | resolve(); 78 | }); 79 | }); 80 | } 81 | 82 | /** 83 | * Delete all rows expired 1 hour ago once per 5 minutes 84 | * 85 | * @private 86 | */ 87 | _clearExpiredHourAgo() { 88 | if (this._clearExpiredTimeoutId) { 89 | clearTimeout(this._clearExpiredTimeoutId); 90 | } 91 | this._clearExpiredTimeoutId = setTimeout(() => { 92 | this.clearExpired(Date.now() - 3600000) // Never rejected 93 | .then(() => { 94 | this._clearExpiredHourAgo(); 95 | }); 96 | }, 300000); 97 | this._clearExpiredTimeoutId.unref(); 98 | } 99 | 100 | /** 101 | * 102 | * @return Promise<any> 103 | * @private 104 | */ 105 | _getConnection() { 106 | switch (this.clientType) { 107 | case 'pool': 108 | return Promise.resolve(this.client); 109 | case 'sequelize': 110 | return this.client.connectionManager.getConnection(); 111 | case 'knex': 112 | return this.client.client.acquireConnection(); 113 | case 'typeorm': 114 | return Promise.resolve(this.client.driver.master); 115 | default: 116 | return Promise.resolve(this.client); 117 | } 118 | } 119 | 120 | _releaseConnection(conn) { 121 | switch (this.clientType) { 122 | case 'pool': 123 | return true; 124 | case 'sequelize': 125 | return this.client.connectionManager.releaseConnection(conn); 126 | case 'knex': 127 | return this.client.client.releaseConnection(conn); 128 | case 'typeorm': 129 | return true; 130 | default: 131 | return true; 132 | } 133 | } 134 | 135 | /** 136 | * 137 | * @returns {Promise<any>} 138 | * @private 139 | */ 140 | _createTable() { 141 | return new Promise((resolve, reject) => { 142 | this._query({ 143 | text: this._getCreateTableStmt(), 144 | }) 145 | .then(() => { 146 | resolve(); 147 | }) 148 | .catch((err) => { 149 | if (err.code === '23505') { 150 | // Error: duplicate key value violates unique constraint "pg_type_typname_nsp_index" 151 | // Postgres doesn't handle concurrent table creation 152 | // It is supposed, that table is created by another worker 153 | resolve(); 154 | } else { 155 | reject(err); 156 | } 157 | }); 158 | }); 159 | } 160 | 161 | _getCreateTableStmt() { 162 | return `CREATE TABLE IF NOT EXISTS ${this._getTableIdentifier()} ( 163 | key varchar(255) PRIMARY KEY, 164 | points integer NOT NULL DEFAULT 0, 165 | expire bigint 166 | );`; 167 | } 168 | 169 | get clientType() { 170 | return this._clientType; 171 | } 172 | 173 | set clientType(value) { 174 | const constructorName = this.client.constructor.name; 175 | 176 | if (typeof value === 'undefined') { 177 | if (constructorName === 'Client') { 178 | value = 'client'; 179 | } else if ( 180 | constructorName === 'Pool' || 181 | constructorName === 'BoundPool' 182 | ) { 183 | value = 'pool'; 184 | } else if (constructorName === 'Sequelize') { 185 | value = 'sequelize'; 186 | } else { 187 | throw new Error('storeType is not defined'); 188 | } 189 | } 190 | 191 | this._clientType = value.toLowerCase(); 192 | } 193 | 194 | get tableName() { 195 | return this._tableName; 196 | } 197 | 198 | set tableName(value) { 199 | this._tableName = typeof value === 'undefined' ? this.keyPrefix : value; 200 | } 201 | 202 | get schemaName() { 203 | return this._schemaName; 204 | } 205 | 206 | set schemaName(value) { 207 | this._schemaName = value; 208 | } 209 | 210 | get tableCreated() { 211 | return this._tableCreated; 212 | } 213 | 214 | set tableCreated(value) { 215 | this._tableCreated = typeof value === 'undefined' ? false : !!value; 216 | } 217 | 218 | get clearExpiredByTimeout() { 219 | return this._clearExpiredByTimeout; 220 | } 221 | 222 | set clearExpiredByTimeout(value) { 223 | this._clearExpiredByTimeout = typeof value === 'undefined' ? true : Boolean(value); 224 | } 225 | 226 | _getRateLimiterRes(rlKey, changedPoints, result) { 227 | const res = new RateLimiterRes(); 228 | const row = result.rows[0]; 229 | 230 | res.isFirstInDuration = changedPoints === row.points; 231 | res.consumedPoints = res.isFirstInDuration ? changedPoints : row.points; 232 | 233 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 234 | res.msBeforeNext = row.expire 235 | ? Math.max(row.expire - Date.now(), 0) 236 | : -1; 237 | 238 | return res; 239 | } 240 | 241 | _query(q) { 242 | const prefix = this.tableName.toLowerCase(); 243 | const queryObj = { name: `${prefix}:${q.name}`, text: q.text, values: q.values }; 244 | return new Promise((resolve, reject) => { 245 | this._getConnection() 246 | .then((conn) => { 247 | conn.query(queryObj) 248 | .then((res) => { 249 | resolve(res); 250 | this._releaseConnection(conn); 251 | }) 252 | .catch((err) => { 253 | reject(err); 254 | this._releaseConnection(conn); 255 | }); 256 | }) 257 | .catch((err) => { 258 | reject(err); 259 | }); 260 | }); 261 | } 262 | 263 | _upsert(key, points, msDuration, forceExpire = false) { 264 | if (!this.tableCreated) { 265 | return Promise.reject(Error('Table is not created yet')); 266 | } 267 | 268 | const newExpire = msDuration > 0 ? Date.now() + msDuration : null; 269 | const expireQ = forceExpire 270 | ? ' $3 ' 271 | : ` CASE 272 | WHEN ${this._getTableIdentifier()}.expire <= $4 THEN $3 273 | ELSE ${this._getTableIdentifier()}.expire 274 | END `; 275 | 276 | return this._query({ 277 | name: forceExpire ? 'rlflx-upsert-force' : 'rlflx-upsert', 278 | text: ` 279 | INSERT INTO ${this._getTableIdentifier()} VALUES ($1, $2, $3) 280 | ON CONFLICT(key) DO UPDATE SET 281 | points = CASE 282 | WHEN (${this._getTableIdentifier()}.expire <= $4 OR 1=${forceExpire ? 1 : 0}) THEN $2 283 | ELSE ${this._getTableIdentifier()}.points + ($2) 284 | END, 285 | expire = ${expireQ} 286 | RETURNING points, expire;`, 287 | values: [key, points, newExpire, Date.now()], 288 | }); 289 | } 290 | 291 | _get(rlKey) { 292 | if (!this.tableCreated) { 293 | return Promise.reject(Error('Table is not created yet')); 294 | } 295 | 296 | return new Promise((resolve, reject) => { 297 | this._query({ 298 | name: 'rlflx-get', 299 | text: ` 300 | SELECT points, expire FROM ${this._getTableIdentifier()} WHERE key = $1 AND (expire > $2 OR expire IS NULL);`, 301 | values: [rlKey, Date.now()], 302 | }) 303 | .then((res) => { 304 | if (res.rowCount === 0) { 305 | res = null; 306 | } 307 | resolve(res); 308 | }) 309 | .catch((err) => { 310 | reject(err); 311 | }); 312 | }); 313 | } 314 | 315 | _delete(rlKey) { 316 | if (!this.tableCreated) { 317 | return Promise.reject(Error('Table is not created yet')); 318 | } 319 | 320 | return this._query({ 321 | name: 'rlflx-delete', 322 | text: `DELETE FROM ${this._getTableIdentifier()} WHERE key = $1`, 323 | values: [rlKey], 324 | }) 325 | .then(res => res.rowCount > 0); 326 | } 327 | } 328 | 329 | module.exports = RateLimiterPostgres; 330 | -------------------------------------------------------------------------------- /lib/RateLimiterPrisma.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | class RateLimiterPrisma extends RateLimiterStoreAbstract { 5 | /** 6 | * Constructor for the rate limiter 7 | * @param {Object} opts - Options for the rate limiter 8 | */ 9 | constructor(opts) { 10 | super(opts); 11 | 12 | this.modelName = opts.tableName || 'RateLimiterFlexible'; 13 | this.prismaClient = opts.storeClient; 14 | this.clearExpiredByTimeout = opts.clearExpiredByTimeout || true; 15 | 16 | if (!this.prismaClient) { 17 | throw new Error('Prisma client is not provided'); 18 | } 19 | 20 | if (this.clearExpiredByTimeout) { 21 | this._clearExpiredHourAgo(); 22 | } 23 | } 24 | 25 | _getRateLimiterRes(rlKey, changedPoints, result) { 26 | const res = new RateLimiterRes(); 27 | 28 | let doc = result; 29 | 30 | res.isFirstInDuration = doc.points === changedPoints; 31 | res.consumedPoints = doc.points; 32 | 33 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 34 | res.msBeforeNext = doc.expire !== null 35 | ? Math.max(new Date(doc.expire).getTime() - Date.now(), 0) 36 | : -1; 37 | 38 | return res; 39 | } 40 | 41 | _upsert(key, points, msDuration, forceExpire = false) { 42 | if (!this.prismaClient) { 43 | return Promise.reject(new Error('Prisma client is not established')); 44 | } 45 | 46 | const now = new Date(); 47 | const newExpire = msDuration > 0 ? new Date(now.getTime() + msDuration) : null; 48 | 49 | return this.prismaClient.$transaction(async (prisma) => { 50 | const existingRecord = await prisma[this.modelName].findFirst({ 51 | where: { key: key }, 52 | }); 53 | 54 | if (existingRecord) { 55 | // Determine if we should update the expire field 56 | const shouldUpdateExpire = forceExpire || !existingRecord.expire || existingRecord.expire <= now || newExpire === null; 57 | 58 | return prisma[this.modelName].update({ 59 | where: { key: key }, 60 | data: { 61 | points: !shouldUpdateExpire ? existingRecord.points + points : points, 62 | ...(shouldUpdateExpire && { expire: newExpire }), 63 | }, 64 | }); 65 | } else { 66 | return prisma[this.modelName].create({ 67 | data: { 68 | key: key, 69 | points: points, 70 | expire: newExpire, 71 | }, 72 | }); 73 | } 74 | }); 75 | } 76 | 77 | _get(rlKey) { 78 | if (!this.prismaClient) { 79 | return Promise.reject(new Error('Prisma client is not established')); 80 | } 81 | 82 | return this.prismaClient[this.modelName].findFirst({ 83 | where: { 84 | AND: [ 85 | { key: rlKey }, 86 | { 87 | OR: [ 88 | { expire: { gt: new Date() } }, 89 | { expire: null }, 90 | ], 91 | }, 92 | ], 93 | }, 94 | }); 95 | } 96 | 97 | _delete(rlKey) { 98 | if (!this.prismaClient) { 99 | return Promise.reject(new Error('Prisma client is not established')); 100 | } 101 | 102 | return this.prismaClient[this.modelName].deleteMany({ 103 | where: { 104 | key: rlKey, 105 | }, 106 | }).then(res => res.count > 0); 107 | } 108 | 109 | _clearExpiredHourAgo() { 110 | if (this._clearExpiredTimeoutId) { 111 | clearTimeout(this._clearExpiredTimeoutId); 112 | } 113 | this._clearExpiredTimeoutId = setTimeout(async () => { 114 | await this.prismaClient[this.modelName].deleteMany({ 115 | where: { 116 | expire: { 117 | lt: new Date(Date.now() - 3600000), 118 | }, 119 | }, 120 | }); 121 | this._clearExpiredHourAgo(); 122 | }, 300000); // Clear every 5 minutes 123 | this._clearExpiredTimeoutId.unref(); 124 | } 125 | } 126 | 127 | module.exports = RateLimiterPrisma; 128 | -------------------------------------------------------------------------------- /lib/RateLimiterQueue.js: -------------------------------------------------------------------------------- 1 | const RateLimiterQueueError = require('./component/RateLimiterQueueError') 2 | const MAX_QUEUE_SIZE = 4294967295; 3 | const KEY_DEFAULT = 'limiter'; 4 | 5 | module.exports = class RateLimiterQueue { 6 | constructor(limiterFlexible, opts = { 7 | maxQueueSize: MAX_QUEUE_SIZE, 8 | }) { 9 | this._queueLimiters = { 10 | KEY_DEFAULT: new RateLimiterQueueInternal(limiterFlexible, opts) 11 | }; 12 | this._limiterFlexible = limiterFlexible; 13 | this._maxQueueSize = opts.maxQueueSize 14 | } 15 | 16 | getTokensRemaining(key = KEY_DEFAULT) { 17 | if (this._queueLimiters[key]) { 18 | return this._queueLimiters[key].getTokensRemaining() 19 | } else { 20 | return Promise.resolve(this._limiterFlexible.points) 21 | } 22 | } 23 | 24 | removeTokens(tokens, key = KEY_DEFAULT) { 25 | if (!this._queueLimiters[key]) { 26 | this._queueLimiters[key] = new RateLimiterQueueInternal( 27 | this._limiterFlexible, { 28 | key, 29 | maxQueueSize: this._maxQueueSize, 30 | }) 31 | } 32 | 33 | return this._queueLimiters[key].removeTokens(tokens) 34 | } 35 | }; 36 | 37 | class RateLimiterQueueInternal { 38 | 39 | constructor(limiterFlexible, opts = { 40 | maxQueueSize: MAX_QUEUE_SIZE, 41 | key: KEY_DEFAULT, 42 | }) { 43 | this._key = opts.key; 44 | this._waitTimeout = null; 45 | this._queue = []; 46 | this._limiterFlexible = limiterFlexible; 47 | 48 | this._maxQueueSize = opts.maxQueueSize 49 | } 50 | 51 | getTokensRemaining() { 52 | return this._limiterFlexible.get(this._key) 53 | .then((rlRes) => { 54 | return rlRes !== null ? rlRes.remainingPoints : this._limiterFlexible.points; 55 | }) 56 | } 57 | 58 | removeTokens(tokens) { 59 | const _this = this; 60 | 61 | return new Promise((resolve, reject) => { 62 | if (tokens > _this._limiterFlexible.points) { 63 | reject(new RateLimiterQueueError(`Requested tokens ${tokens} exceeds maximum ${_this._limiterFlexible.points} tokens per interval`)); 64 | return 65 | } 66 | 67 | if (_this._queue.length > 0) { 68 | _this._queueRequest.call(_this, resolve, reject, tokens); 69 | } else { 70 | _this._limiterFlexible.consume(_this._key, tokens) 71 | .then((res) => { 72 | resolve(res.remainingPoints); 73 | }) 74 | .catch((rej) => { 75 | if (rej instanceof Error) { 76 | reject(rej); 77 | } else { 78 | _this._queueRequest.call(_this, resolve, reject, tokens); 79 | if (_this._waitTimeout === null) { 80 | _this._waitTimeout = setTimeout(_this._processFIFO.bind(_this), rej.msBeforeNext); 81 | } 82 | } 83 | }); 84 | } 85 | }) 86 | } 87 | 88 | _queueRequest(resolve, reject, tokens) { 89 | const _this = this; 90 | if (_this._queue.length < _this._maxQueueSize) { 91 | _this._queue.push({resolve, reject, tokens}); 92 | } else { 93 | reject(new RateLimiterQueueError(`Number of requests reached it's maximum ${_this._maxQueueSize}`)) 94 | } 95 | } 96 | 97 | _processFIFO() { 98 | const _this = this; 99 | 100 | if (_this._waitTimeout !== null) { 101 | clearTimeout(_this._waitTimeout); 102 | _this._waitTimeout = null; 103 | } 104 | 105 | if (_this._queue.length === 0) { 106 | return; 107 | } 108 | 109 | const item = _this._queue.shift(); 110 | _this._limiterFlexible.consume(_this._key, item.tokens) 111 | .then((res) => { 112 | item.resolve(res.remainingPoints); 113 | _this._processFIFO.call(_this); 114 | }) 115 | .catch((rej) => { 116 | if (rej instanceof Error) { 117 | item.reject(rej); 118 | _this._processFIFO.call(_this); 119 | } else { 120 | _this._queue.unshift(item); 121 | if (_this._waitTimeout === null) { 122 | _this._waitTimeout = setTimeout(_this._processFIFO.bind(_this), rej.msBeforeNext); 123 | } 124 | } 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/RateLimiterRedis.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | const incrTtlLuaScript = `redis.call('set', KEYS[1], 0, 'EX', ARGV[2], 'NX') \ 5 | local consumed = redis.call('incrby', KEYS[1], ARGV[1]) \ 6 | local ttl = redis.call('pttl', KEYS[1]) \ 7 | if ttl == -1 then \ 8 | redis.call('expire', KEYS[1], ARGV[2]) \ 9 | ttl = 1000 * ARGV[2] \ 10 | end \ 11 | return {consumed, ttl} \ 12 | `; 13 | 14 | class RateLimiterRedis extends RateLimiterStoreAbstract { 15 | /** 16 | * 17 | * @param {Object} opts 18 | * Defaults { 19 | * ... see other in RateLimiterStoreAbstract 20 | * 21 | * redis: RedisClient 22 | * rejectIfRedisNotReady: boolean = false - reject / invoke insuranceLimiter immediately when redis connection is not "ready" 23 | * } 24 | */ 25 | constructor(opts) { 26 | super(opts); 27 | this.client = opts.storeClient; 28 | 29 | this._rejectIfRedisNotReady = !!opts.rejectIfRedisNotReady; 30 | this._incrTtlLuaScript = opts.customIncrTtlLuaScript || incrTtlLuaScript; 31 | 32 | this.useRedisPackage = opts.useRedisPackage || this.client.constructor.name === 'Commander' || false; 33 | this.useRedis3AndLowerPackage = opts.useRedis3AndLowerPackage; 34 | if (typeof this.client.defineCommand === 'function') { 35 | this.client.defineCommand("rlflxIncr", { 36 | numberOfKeys: 1, 37 | lua: this._incrTtlLuaScript, 38 | }); 39 | } 40 | } 41 | 42 | /** 43 | * Prevent actual redis call if redis connection is not ready 44 | * Because of different connection state checks for ioredis and node-redis, only this clients would be actually checked. 45 | * For any other clients all the requests would be passed directly to redis client 46 | * @return {boolean} 47 | * @private 48 | */ 49 | _isRedisReady() { 50 | if (!this._rejectIfRedisNotReady) { 51 | return true; 52 | } 53 | // ioredis client 54 | if (this.client.status && this.client.status !== 'ready') { 55 | return false; 56 | } 57 | // node-redis client 58 | if (typeof this.client.isReady === 'function' && !this.client.isReady()) { 59 | return false; 60 | } 61 | return true; 62 | } 63 | 64 | _getRateLimiterRes(rlKey, changedPoints, result) { 65 | let [consumed, resTtlMs] = result; 66 | // Support ioredis results format 67 | if (Array.isArray(consumed)) { 68 | [, consumed] = consumed; 69 | [, resTtlMs] = resTtlMs; 70 | } 71 | 72 | const res = new RateLimiterRes(); 73 | res.consumedPoints = parseInt(consumed); 74 | res.isFirstInDuration = res.consumedPoints === changedPoints; 75 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 76 | res.msBeforeNext = resTtlMs; 77 | 78 | return res; 79 | } 80 | 81 | async _upsert(rlKey, points, msDuration, forceExpire = false) { 82 | if( 83 | typeof points == 'string' 84 | ){ 85 | if(!RegExp("^[1-9][0-9]*quot;).test(points)){ 86 | throw new Error("Consuming string different than integer values is not supported by this package"); 87 | } 88 | } else if (!Number.isInteger(points)){ 89 | throw new Error("Consuming decimal number of points is not supported by this package"); 90 | } 91 | 92 | if (!this._isRedisReady()) { 93 | throw new Error('Redis connection is not ready'); 94 | } 95 | 96 | const secDuration = Math.floor(msDuration / 1000); 97 | const multi = this.client.multi(); 98 | 99 | if (forceExpire) { 100 | if (secDuration > 0) { 101 | if(!this.useRedisPackage && !this.useRedis3AndLowerPackage){ 102 | multi.set(rlKey, points, "EX", secDuration); 103 | }else{ 104 | multi.set(rlKey, points, { EX: secDuration }); 105 | } 106 | } else { 107 | multi.set(rlKey, points); 108 | } 109 | 110 | if(!this.useRedisPackage && !this.useRedis3AndLowerPackage){ 111 | return multi.pttl(rlKey).exec(true); 112 | } 113 | return multi.pTTL(rlKey).exec(true); 114 | } 115 | 116 | if (secDuration > 0) { 117 | if(!this.useRedisPackage && !this.useRedis3AndLowerPackage){ 118 | return this.client.rlflxIncr( 119 | [rlKey].concat([String(points), String(secDuration), String(this.points), String(this.duration)])); 120 | } 121 | if (this.useRedis3AndLowerPackage) { 122 | return new Promise((resolve, reject) => { 123 | const incrCallback = function (err, result) { 124 | if (err) { 125 | return reject(err); 126 | } 127 | 128 | return resolve(result); 129 | }; 130 | 131 | if (typeof this.client.rlflxIncr === 'function') { 132 | this.client.rlflxIncr(rlKey, points, secDuration, this.points, this.duration, incrCallback); 133 | } else { 134 | this.client.eval(this._incrTtlLuaScript, 1, rlKey, points, secDuration, this.points, this.duration, incrCallback); 135 | } 136 | }); 137 | } else { 138 | return this.client.eval(this._incrTtlLuaScript, { 139 | keys: [rlKey], 140 | arguments: [String(points), String(secDuration), String(this.points), String(this.duration)], 141 | }); 142 | } 143 | } else { 144 | if(!this.useRedisPackage && !this.useRedis3AndLowerPackage){ 145 | return multi.incrby(rlKey, points).pttl(rlKey).exec(true); 146 | } 147 | 148 | return multi.incrBy(rlKey, points).pTTL(rlKey).exec(true); 149 | } 150 | } 151 | 152 | async _get(rlKey) { 153 | if (!this._isRedisReady()) { 154 | throw new Error('Redis connection is not ready'); 155 | } 156 | if(!this.useRedisPackage && !this.useRedis3AndLowerPackage){ 157 | return this.client 158 | .multi() 159 | .get(rlKey) 160 | .pttl(rlKey) 161 | .exec() 162 | .then((result) => { 163 | const [[,points]] = result; 164 | if (points === null) return null; 165 | return result; 166 | }); 167 | } 168 | 169 | return this.client 170 | .multi() 171 | .get(rlKey) 172 | .pTTL(rlKey) 173 | .exec(true) 174 | .then((result) => { 175 | const [points] = result; 176 | if (points === null) return null; 177 | return result; 178 | }); 179 | } 180 | 181 | _delete(rlKey) { 182 | return this.client 183 | .del(rlKey) 184 | .then(result => result > 0); 185 | } 186 | } 187 | 188 | module.exports = RateLimiterRedis; 189 | -------------------------------------------------------------------------------- /lib/RateLimiterRes.js: -------------------------------------------------------------------------------- 1 | module.exports = class RateLimiterRes { 2 | constructor(remainingPoints, msBeforeNext, consumedPoints, isFirstInDuration) { 3 | this.remainingPoints = typeof remainingPoints === 'undefined' ? 0 : remainingPoints; // Remaining points in current duration 4 | this.msBeforeNext = typeof msBeforeNext === 'undefined' ? 0 : msBeforeNext; // Milliseconds before next action 5 | this.consumedPoints = typeof consumedPoints === 'undefined' ? 0 : consumedPoints; // Consumed points in current duration 6 | this.isFirstInDuration = typeof isFirstInDuration === 'undefined' ? false : isFirstInDuration; 7 | } 8 | 9 | get msBeforeNext() { 10 | return this._msBeforeNext; 11 | } 12 | 13 | set msBeforeNext(ms) { 14 | this._msBeforeNext = ms; 15 | return this; 16 | } 17 | 18 | get remainingPoints() { 19 | return this._remainingPoints; 20 | } 21 | 22 | set remainingPoints(p) { 23 | this._remainingPoints = p; 24 | return this; 25 | } 26 | 27 | get consumedPoints() { 28 | return this._consumedPoints; 29 | } 30 | 31 | set consumedPoints(p) { 32 | this._consumedPoints = p; 33 | return this; 34 | } 35 | 36 | get isFirstInDuration() { 37 | return this._isFirstInDuration; 38 | } 39 | 40 | set isFirstInDuration(value) { 41 | this._isFirstInDuration = Boolean(value); 42 | } 43 | 44 | _getDecoratedProperties() { 45 | return { 46 | remainingPoints: this.remainingPoints, 47 | msBeforeNext: this.msBeforeNext, 48 | consumedPoints: this.consumedPoints, 49 | isFirstInDuration: this.isFirstInDuration, 50 | }; 51 | } 52 | 53 | [Symbol.for("nodejs.util.inspect.custom")]() { 54 | return this._getDecoratedProperties(); 55 | } 56 | 57 | toString() { 58 | return JSON.stringify(this._getDecoratedProperties()); 59 | } 60 | 61 | toJSON() { 62 | return this._getDecoratedProperties(); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /lib/RateLimiterSQLite.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require("./RateLimiterStoreAbstract"); 2 | const RateLimiterRes = require("./RateLimiterRes"); 3 | 4 | class RateLimiterSQLite extends RateLimiterStoreAbstract { 5 | /** 6 | * Internal store type used to determine the SQLite client in use. 7 | * It can be one of the following: 8 | * - `"sqlite3". 9 | * - `"better-sqlite3". 10 | * 11 | * @type {("sqlite3" | "better-sqlite3" | null)} 12 | * @private 13 | */ 14 | _internalStoreType = null; 15 | 16 | /** 17 | * @callback callback 18 | * @param {Object} err 19 | * 20 | * @param {Object} opts 21 | * @param {callback} cb 22 | * Defaults { 23 | * ... see other in RateLimiterStoreAbstract 24 | * storeClient: sqliteClient, // SQLite database instance (sqlite3, better-sqlite3, or knex instance) 25 | * storeType: 'sqlite3' | 'better-sqlite3' | 'knex', // Optional, defaults to 'sqlite3' 26 | * tableName: 'string', 27 | * tableCreated: boolean, 28 | * clearExpiredByTimeout: boolean, 29 | * } 30 | */ 31 | constructor(opts, cb = null) { 32 | super(opts); 33 | 34 | this.client = opts.storeClient; 35 | this.storeType = opts.storeType || "sqlite3"; 36 | this.tableName = opts.tableName; 37 | this.tableCreated = opts.tableCreated || false; 38 | this.clearExpiredByTimeout = opts.clearExpiredByTimeout; 39 | 40 | this._validateStoreTypes(cb); 41 | this._validateStoreClient(cb); 42 | this._setInternalStoreType(cb); 43 | this._validateTableName(cb); 44 | 45 | if (!this.tableCreated) { 46 | this._createDbAndTable() 47 | .then(() => { 48 | this.tableCreated = true; 49 | if (this.clearExpiredByTimeout) this._clearExpiredHourAgo(); 50 | if (typeof cb === "function") cb(); 51 | }) 52 | .catch((err) => { 53 | if (typeof cb === "function") cb(err); 54 | else throw err; 55 | }); 56 | } else { 57 | if (this.clearExpiredByTimeout) this._clearExpiredHourAgo(); 58 | if (typeof cb === "function") cb(); 59 | } 60 | } 61 | _validateStoreTypes(cb) { 62 | const validStoreTypes = ["sqlite3", "better-sqlite3", "knex"]; 63 | if (!validStoreTypes.includes(this.storeType)) { 64 | const err = new Error( 65 | `storeType must be one of: ${validStoreTypes.join(", ")}` 66 | ); 67 | if (typeof cb === "function") return cb(err); 68 | throw err; 69 | } 70 | } 71 | _validateStoreClient(cb) { 72 | if (this.storeType === "sqlite3") { 73 | if (typeof this.client.run !== "function") { 74 | const err = new Error( 75 | "storeClient must be an instance of sqlite3.Database when storeType is 'sqlite3' or no storeType was provided" 76 | ); 77 | if (typeof cb === "function") return cb(err); 78 | throw err; 79 | } 80 | } else if (this.storeType === "better-sqlite3") { 81 | if ( 82 | typeof this.client.prepare !== "function" || 83 | typeof this.client.run !== "undefined" 84 | ) { 85 | const err = new Error( 86 | "storeClient must be an instance of better-sqlite3.Database when storeType is 'better-sqlite3'" 87 | ); 88 | if (typeof cb === "function") return cb(err); 89 | throw err; 90 | } 91 | } else if (this.storeType === "knex") { 92 | if (typeof this.client.raw !== "function") { 93 | const err = new Error( 94 | "storeClient must be an instance of Knex when storeType is 'knex'" 95 | ); 96 | if (typeof cb === "function") return cb(err); 97 | throw err; 98 | } 99 | } 100 | } 101 | _setInternalStoreType(cb) { 102 | if (this.storeType === "knex") { 103 | const knexClientType = this.client.client.config.client; 104 | if (knexClientType === "sqlite3") { 105 | this._internalStoreType = "sqlite3"; 106 | } else if (knexClientType === "better-sqlite3") { 107 | this._internalStoreType = "better-sqlite3"; 108 | } else { 109 | const err = new Error( 110 | "Knex must be configured with 'sqlite3' or 'better-sqlite3' for RateLimiterSQLite" 111 | ); 112 | if (typeof cb === "function") return cb(err); 113 | throw err; 114 | } 115 | } else { 116 | this._internalStoreType = this.storeType; 117 | } 118 | } 119 | _validateTableName(cb) { 120 | if (!/^[A-Za-z0-9_]*$/.test(this.tableName)) { 121 | const err = new Error("Table name must contain only letters and numbers"); 122 | if (typeof cb === "function") return cb(err); 123 | throw err; 124 | } 125 | } 126 | 127 | /** 128 | * Acquires the database connection based on the storeType. 129 | * @returns {Promise<Object>} The database client or connection 130 | */ 131 | async _getConnection() { 132 | if (this.storeType === "knex") { 133 | return this.client.client.acquireConnection(); // Acquire raw connection from knex pool 134 | } 135 | return this.client; // For sqlite3 and better-sqlite3, return the client directly 136 | } 137 | 138 | /** 139 | * Releases the database connection if necessary. 140 | * @param {Object} conn The database client or connection 141 | */ 142 | _releaseConnection(conn) { 143 | if (this.storeType === "knex") { 144 | this.client.client.releaseConnection(conn); 145 | } 146 | // No release needed for direct sqlite3 or better-sqlite3 clients 147 | } 148 | 149 | async _createDbAndTable() { 150 | const conn = await this._getConnection(); 151 | try { 152 | switch (this._internalStoreType) { 153 | case "sqlite3": 154 | await new Promise((resolve, reject) => { 155 | conn.run(this._getCreateTableSQL(), (err) => 156 | err ? reject(err) : resolve() 157 | ); 158 | }); 159 | break; 160 | case "better-sqlite3": 161 | conn.prepare(this._getCreateTableSQL()).run(); 162 | break; 163 | default: 164 | throw new Error("Unsupported internalStoreType"); 165 | } 166 | } finally { 167 | this._releaseConnection(conn); 168 | } 169 | } 170 | 171 | _getCreateTableSQL() { 172 | return `CREATE TABLE IF NOT EXISTS ${this.tableName} ( 173 | key TEXT PRIMARY KEY, 174 | points INTEGER NOT NULL DEFAULT 0, 175 | expire INTEGER 176 | )`; 177 | } 178 | 179 | _clearExpiredHourAgo() { 180 | if (this._clearExpiredTimeoutId) clearTimeout(this._clearExpiredTimeoutId); 181 | this._clearExpiredTimeoutId = setTimeout(() => { 182 | this.clearExpired(Date.now() - 3600000) // 1 hour ago 183 | .then(() => this._clearExpiredHourAgo()); 184 | }, 300000); // Every 5 minutes 185 | this._clearExpiredTimeoutId.unref(); 186 | } 187 | 188 | async clearExpired(nowMs) { 189 | const sql = `DELETE FROM ${this.tableName} WHERE expire < ?`; 190 | const conn = await this._getConnection(); 191 | try { 192 | switch (this._internalStoreType) { 193 | case "sqlite3": 194 | await new Promise((resolve, reject) => { 195 | conn.run(sql, [nowMs], (err) => (err ? reject(err) : resolve())); 196 | }); 197 | break; 198 | case "better-sqlite3": 199 | conn.prepare(sql).run(nowMs); 200 | break; 201 | default: 202 | throw new Error("Unsupported internalStoreType"); 203 | } 204 | } finally { 205 | this._releaseConnection(conn); 206 | } 207 | } 208 | 209 | _getRateLimiterRes(rlKey, changedPoints, result) { 210 | const res = new RateLimiterRes(); 211 | res.isFirstInDuration = changedPoints === result.points; 212 | res.consumedPoints = res.isFirstInDuration ? changedPoints : result.points; 213 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 214 | res.msBeforeNext = result.expire 215 | ? Math.max(result.expire - Date.now(), 0) 216 | : -1; 217 | return res; 218 | } 219 | 220 | async _upsertTransactionSQLite3(conn, upsertQuery, upsertParams) { 221 | return await new Promise((resolve, reject) => { 222 | conn.serialize(() => { 223 | conn.run("SAVEPOINT rate_limiter_trx;", (err) => { 224 | if (err) return reject(err); 225 | conn.get(upsertQuery, upsertParams, (err, row) => { 226 | if (err) { 227 | conn.run("ROLLBACK TO SAVEPOINT rate_limiter_trx;", () => 228 | reject(err) 229 | ); 230 | return; 231 | } 232 | conn.run("RELEASE SAVEPOINT rate_limiter_trx;", () => resolve(row)); 233 | }); 234 | }); 235 | }); 236 | }); 237 | } 238 | 239 | async _upsertTransactionBetterSQLite3(conn, upsertQuery, upsertParams) { 240 | return conn.transaction(() => 241 | conn.prepare(upsertQuery).get(...upsertParams) 242 | )(); 243 | } 244 | async _upsertTransaction(rlKey, points, msDuration, forceExpire) { 245 | const dateNow = Date.now(); 246 | const newExpire = msDuration > 0 ? dateNow + msDuration : null; 247 | const upsertQuery = forceExpire 248 | ? `INSERT OR REPLACE INTO ${this.tableName} (key, points, expire) VALUES (?, ?, ?) RETURNING points, expire` 249 | : `INSERT INTO ${this.tableName} (key, points, expire) 250 | VALUES (?, ?, ?) 251 | ON CONFLICT(key) DO UPDATE SET 252 | points = CASE WHEN expire IS NULL OR expire > ? THEN points + excluded.points ELSE excluded.points END, 253 | expire = CASE WHEN expire IS NULL OR expire > ? THEN expire ELSE excluded.expire END 254 | RETURNING points, expire`; 255 | const upsertParams = forceExpire 256 | ? [rlKey, points, newExpire] 257 | : [rlKey, points, newExpire, dateNow, dateNow]; 258 | 259 | const conn = await this._getConnection(); 260 | try { 261 | switch (this._internalStoreType) { 262 | case "sqlite3": 263 | return this._upsertTransactionSQLite3( 264 | conn, 265 | upsertQuery, 266 | upsertParams 267 | ); 268 | case "better-sqlite3": 269 | return this._upsertTransactionBetterSQLite3( 270 | conn, 271 | upsertQuery, 272 | upsertParams 273 | ); 274 | default: 275 | throw new Error("Unsupported internalStoreType"); 276 | } 277 | } finally { 278 | this._releaseConnection(conn); 279 | } 280 | } 281 | 282 | _upsert(rlKey, points, msDuration, forceExpire = false) { 283 | if (!this.tableCreated) { 284 | return Promise.reject(new Error("Table is not created yet")); 285 | } 286 | return this._upsertTransaction(rlKey, points, msDuration, forceExpire); 287 | } 288 | 289 | async _get(rlKey) { 290 | const sql = `SELECT points, expire FROM ${this.tableName} WHERE key = ? AND (expire > ? OR expire IS NULL)`; 291 | const now = Date.now(); 292 | const conn = await this._getConnection(); 293 | try { 294 | switch (this._internalStoreType) { 295 | case "sqlite3": 296 | return await new Promise((resolve, reject) => { 297 | conn.get(sql, [rlKey, now], (err, row) => 298 | err ? reject(err) : resolve(row || null) 299 | ); 300 | }); 301 | case "better-sqlite3": 302 | return conn.prepare(sql).get(rlKey, now) || null; 303 | default: 304 | throw new Error("Unsupported internalStoreType"); 305 | } 306 | } finally { 307 | this._releaseConnection(conn); 308 | } 309 | } 310 | 311 | async _delete(rlKey) { 312 | if (!this.tableCreated) { 313 | return Promise.reject(new Error("Table is not created yet")); 314 | } 315 | const sql = `DELETE FROM ${this.tableName} WHERE key = ?`; 316 | const conn = await this._getConnection(); 317 | try { 318 | switch (this._internalStoreType) { 319 | case "sqlite3": 320 | return await new Promise((resolve, reject) => { 321 | conn.run(sql, [rlKey], function (err) { 322 | if (err) reject(err); 323 | else resolve(this.changes > 0); 324 | }); 325 | }); 326 | case "better-sqlite3": 327 | const result = conn.prepare(sql).run(rlKey); 328 | return result.changes > 0; 329 | default: 330 | throw new Error("Unsupported internalStoreType"); 331 | } 332 | } finally { 333 | this._releaseConnection(conn); 334 | } 335 | } 336 | } 337 | 338 | module.exports = RateLimiterSQLite; 339 | -------------------------------------------------------------------------------- /lib/RateLimiterStoreAbstract.js: -------------------------------------------------------------------------------- 1 | const RateLimiterAbstract = require('./RateLimiterAbstract'); 2 | const BlockedKeys = require('./component/BlockedKeys'); 3 | const RateLimiterRes = require('./RateLimiterRes'); 4 | 5 | module.exports = class RateLimiterStoreAbstract extends RateLimiterAbstract { 6 | /** 7 | * 8 | * @param opts Object Defaults { 9 | * ... see other in RateLimiterAbstract 10 | * 11 | * inMemoryBlockOnConsumed: 40, // Number of points when key is blocked 12 | * inMemoryBlockDuration: 10, // Block duration in seconds 13 | * insuranceLimiter: RateLimiterAbstract 14 | * } 15 | */ 16 | constructor(opts = {}) { 17 | super(opts); 18 | 19 | this.inMemoryBlockOnConsumed = opts.inMemoryBlockOnConsumed; 20 | this.inMemoryBlockDuration = opts.inMemoryBlockDuration; 21 | this.insuranceLimiter = opts.insuranceLimiter; 22 | this._inMemoryBlockedKeys = new BlockedKeys(); 23 | } 24 | 25 | get client() { 26 | return this._client; 27 | } 28 | 29 | set client(value) { 30 | if (typeof value === 'undefined') { 31 | throw new Error('storeClient is not set'); 32 | } 33 | this._client = value; 34 | } 35 | 36 | /** 37 | * Have to be launched after consume 38 | * It blocks key and execute evenly depending on result from store 39 | * 40 | * It uses _getRateLimiterRes function to prepare RateLimiterRes from store result 41 | * 42 | * @param resolve 43 | * @param reject 44 | * @param rlKey 45 | * @param changedPoints 46 | * @param storeResult 47 | * @param {Object} options 48 | * @private 49 | */ 50 | _afterConsume(resolve, reject, rlKey, changedPoints, storeResult, options = {}) { 51 | const res = this._getRateLimiterRes(rlKey, changedPoints, storeResult); 52 | 53 | if (this.inMemoryBlockOnConsumed > 0 && !(this.inMemoryBlockDuration > 0) 54 | && res.consumedPoints >= this.inMemoryBlockOnConsumed 55 | ) { 56 | this._inMemoryBlockedKeys.addMs(rlKey, res.msBeforeNext); 57 | if (res.consumedPoints > this.points) { 58 | return reject(res); 59 | } else { 60 | return resolve(res) 61 | } 62 | } else if (res.consumedPoints > this.points) { 63 | let blockPromise = Promise.resolve(); 64 | // Block only first time when consumed more than points 65 | if (this.blockDuration > 0 && res.consumedPoints <= (this.points + changedPoints)) { 66 | res.msBeforeNext = this.msBlockDuration; 67 | blockPromise = this._block(rlKey, res.consumedPoints, this.msBlockDuration, options); 68 | } 69 | 70 | if (this.inMemoryBlockOnConsumed > 0 && res.consumedPoints >= this.inMemoryBlockOnConsumed) { 71 | // Block key for this.inMemoryBlockDuration seconds 72 | this._inMemoryBlockedKeys.add(rlKey, this.inMemoryBlockDuration); 73 | res.msBeforeNext = this.msInMemoryBlockDuration; 74 | } 75 | 76 | blockPromise 77 | .then(() => { 78 | reject(res); 79 | }) 80 | .catch((err) => { 81 | reject(err); 82 | }); 83 | } else if (this.execEvenly && res.msBeforeNext > 0 && !res.isFirstInDuration) { 84 | let delay = Math.ceil(res.msBeforeNext / (res.remainingPoints + 2)); 85 | if (delay < this.execEvenlyMinDelayMs) { 86 | delay = res.consumedPoints * this.execEvenlyMinDelayMs; 87 | } 88 | 89 | setTimeout(resolve, delay, res); 90 | } else { 91 | resolve(res); 92 | } 93 | } 94 | 95 | _handleError(err, funcName, resolve, reject, key, data = false, options = {}) { 96 | if (!(this.insuranceLimiter instanceof RateLimiterAbstract)) { 97 | reject(err); 98 | } else { 99 | this.insuranceLimiter[funcName](key, data, options) 100 | .then((res) => { 101 | resolve(res); 102 | }) 103 | .catch((res) => { 104 | reject(res); 105 | }); 106 | } 107 | } 108 | 109 | getInMemoryBlockMsBeforeExpire(rlKey) { 110 | if (this.inMemoryBlockOnConsumed > 0) { 111 | return this._inMemoryBlockedKeys.msBeforeExpire(rlKey); 112 | } 113 | 114 | return 0; 115 | } 116 | 117 | get inMemoryBlockOnConsumed() { 118 | return this._inMemoryBlockOnConsumed; 119 | } 120 | 121 | set inMemoryBlockOnConsumed(value) { 122 | this._inMemoryBlockOnConsumed = value ? parseInt(value) : 0; 123 | if (this.inMemoryBlockOnConsumed > 0 && this.points > this.inMemoryBlockOnConsumed) { 124 | throw new Error('inMemoryBlockOnConsumed option must be greater or equal "points" option'); 125 | } 126 | } 127 | 128 | get inMemoryBlockDuration() { 129 | return this._inMemoryBlockDuration; 130 | } 131 | 132 | set inMemoryBlockDuration(value) { 133 | this._inMemoryBlockDuration = value ? parseInt(value) : 0; 134 | if (this.inMemoryBlockDuration > 0 && this.inMemoryBlockOnConsumed === 0) { 135 | throw new Error('inMemoryBlockOnConsumed option must be set up'); 136 | } 137 | } 138 | 139 | get msInMemoryBlockDuration() { 140 | return this._inMemoryBlockDuration * 1000; 141 | } 142 | 143 | get insuranceLimiter() { 144 | return this._insuranceLimiter; 145 | } 146 | 147 | set insuranceLimiter(value) { 148 | if (typeof value !== 'undefined' && !(value instanceof RateLimiterAbstract)) { 149 | throw new Error('insuranceLimiter must be instance of RateLimiterAbstract'); 150 | } 151 | this._insuranceLimiter = value; 152 | if (this._insuranceLimiter) { 153 | this._insuranceLimiter.blockDuration = this.blockDuration; 154 | this._insuranceLimiter.execEvenly = this.execEvenly; 155 | } 156 | } 157 | 158 | /** 159 | * Block any key for secDuration seconds 160 | * 161 | * @param key 162 | * @param secDuration 163 | * @param {Object} options 164 | * 165 | * @return Promise<RateLimiterRes> 166 | */ 167 | block(key, secDuration, options = {}) { 168 | const msDuration = secDuration * 1000; 169 | return this._block(this.getKey(key), this.points + 1, msDuration, options); 170 | } 171 | 172 | /** 173 | * Set points by key for any duration 174 | * 175 | * @param key 176 | * @param points 177 | * @param secDuration 178 | * @param {Object} options 179 | * 180 | * @return Promise<RateLimiterRes> 181 | */ 182 | set(key, points, secDuration, options = {}) { 183 | const msDuration = (secDuration >= 0 ? secDuration : this.duration) * 1000; 184 | return this._block(this.getKey(key), points, msDuration, options); 185 | } 186 | 187 | /** 188 | * 189 | * @param key 190 | * @param pointsToConsume 191 | * @param {Object} options 192 | * @returns Promise<RateLimiterRes> 193 | */ 194 | consume(key, pointsToConsume = 1, options = {}) { 195 | return new Promise((resolve, reject) => { 196 | const rlKey = this.getKey(key); 197 | 198 | const inMemoryBlockMsBeforeExpire = this.getInMemoryBlockMsBeforeExpire(rlKey); 199 | if (inMemoryBlockMsBeforeExpire > 0) { 200 | return reject(new RateLimiterRes(0, inMemoryBlockMsBeforeExpire)); 201 | } 202 | 203 | this._upsert(rlKey, pointsToConsume, this._getKeySecDuration(options) * 1000, false, options) 204 | .then((res) => { 205 | this._afterConsume(resolve, reject, rlKey, pointsToConsume, res); 206 | }) 207 | .catch((err) => { 208 | this._handleError(err, 'consume', resolve, reject, key, pointsToConsume, options); 209 | }); 210 | }); 211 | } 212 | 213 | /** 214 | * 215 | * @param key 216 | * @param points 217 | * @param {Object} options 218 | * @returns Promise<RateLimiterRes> 219 | */ 220 | penalty(key, points = 1, options = {}) { 221 | const rlKey = this.getKey(key); 222 | return new Promise((resolve, reject) => { 223 | this._upsert(rlKey, points, this._getKeySecDuration(options) * 1000, false, options) 224 | .then((res) => { 225 | resolve(this._getRateLimiterRes(rlKey, points, res)); 226 | }) 227 | .catch((err) => { 228 | this._handleError(err, 'penalty', resolve, reject, key, points, options); 229 | }); 230 | }); 231 | } 232 | 233 | /** 234 | * 235 | * @param key 236 | * @param points 237 | * @param {Object} options 238 | * @returns Promise<RateLimiterRes> 239 | */ 240 | reward(key, points = 1, options = {}) { 241 | const rlKey = this.getKey(key); 242 | return new Promise((resolve, reject) => { 243 | this._upsert(rlKey, -points, this._getKeySecDuration(options) * 1000, false, options) 244 | .then((res) => { 245 | resolve(this._getRateLimiterRes(rlKey, -points, res)); 246 | }) 247 | .catch((err) => { 248 | this._handleError(err, 'reward', resolve, reject, key, points, options); 249 | }); 250 | }); 251 | } 252 | 253 | /** 254 | * 255 | * @param key 256 | * @param {Object} options 257 | * @returns Promise<RateLimiterRes>|null 258 | */ 259 | get(key, options = {}) { 260 | const rlKey = this.getKey(key); 261 | return new Promise((resolve, reject) => { 262 | this._get(rlKey, options) 263 | .then((res) => { 264 | if (res === null || typeof res === 'undefined') { 265 | resolve(null); 266 | } else { 267 | resolve(this._getRateLimiterRes(rlKey, 0, res)); 268 | } 269 | }) 270 | .catch((err) => { 271 | this._handleError(err, 'get', resolve, reject, key, options); 272 | }); 273 | }); 274 | } 275 | 276 | /** 277 | * 278 | * @param key 279 | * @param {Object} options 280 | * @returns Promise<boolean> 281 | */ 282 | delete(key, options = {}) { 283 | const rlKey = this.getKey(key); 284 | return new Promise((resolve, reject) => { 285 | this._delete(rlKey, options) 286 | .then((res) => { 287 | this._inMemoryBlockedKeys.delete(rlKey); 288 | resolve(res); 289 | }) 290 | .catch((err) => { 291 | this._handleError(err, 'delete', resolve, reject, key, options); 292 | }); 293 | }); 294 | } 295 | 296 | /** 297 | * Cleanup keys no-matter expired or not. 298 | */ 299 | deleteInMemoryBlockedAll() { 300 | this._inMemoryBlockedKeys.delete(); 301 | } 302 | 303 | /** 304 | * Get RateLimiterRes object filled depending on storeResult, which specific for exact store 305 | * 306 | * @param rlKey 307 | * @param changedPoints 308 | * @param storeResult 309 | * @private 310 | */ 311 | _getRateLimiterRes(rlKey, changedPoints, storeResult) { // eslint-disable-line no-unused-vars 312 | throw new Error("You have to implement the method '_getRateLimiterRes'!"); 313 | } 314 | 315 | /** 316 | * Block key for this.msBlockDuration milliseconds 317 | * Usually, it just prolongs lifetime of key 318 | * 319 | * @param rlKey 320 | * @param initPoints 321 | * @param msDuration 322 | * @param {Object} options 323 | * 324 | * @return Promise<any> 325 | */ 326 | _block(rlKey, initPoints, msDuration, options = {}) { 327 | return new Promise((resolve, reject) => { 328 | this._upsert(rlKey, initPoints, msDuration, true, options) 329 | .then(() => { 330 | resolve(new RateLimiterRes(0, msDuration > 0 ? msDuration : -1, initPoints)); 331 | }) 332 | .catch((err) => { 333 | this._handleError(err, 'block', resolve, reject, this.parseKey(rlKey), msDuration / 1000, options); 334 | }); 335 | }); 336 | } 337 | 338 | /** 339 | * Have to be implemented in every limiter 340 | * Resolve with raw result from Store OR null if rlKey is not set 341 | * or Reject with error 342 | * 343 | * @param rlKey 344 | * @param {Object} options 345 | * @private 346 | * 347 | * @return Promise<any> 348 | */ 349 | _get(rlKey, options = {}) { // eslint-disable-line no-unused-vars 350 | throw new Error("You have to implement the method '_get'!"); 351 | } 352 | 353 | /** 354 | * Have to be implemented 355 | * Resolve with true OR false if rlKey doesn't exist 356 | * or Reject with error 357 | * 358 | * @param rlKey 359 | * @param {Object} options 360 | * @private 361 | * 362 | * @return Promise<any> 363 | */ 364 | _delete(rlKey, options = {}) { // eslint-disable-line no-unused-vars 365 | throw new Error("You have to implement the method '_delete'!"); 366 | } 367 | 368 | /** 369 | * Have to be implemented 370 | * Resolve with object used for {@link _getRateLimiterRes} to generate {@link RateLimiterRes} 371 | * 372 | * @param {string} rlKey 373 | * @param {number} points 374 | * @param {number} msDuration 375 | * @param {boolean} forceExpire 376 | * @param {Object} options 377 | * @abstract 378 | * 379 | * @return Promise<Object> 380 | */ 381 | _upsert(rlKey, points, msDuration, forceExpire = false, options = {}) { 382 | throw new Error("You have to implement the method '_upsert'!"); 383 | } 384 | }; 385 | -------------------------------------------------------------------------------- /lib/RateLimiterUnion.js: -------------------------------------------------------------------------------- 1 | const RateLimiterAbstract = require('./RateLimiterAbstract'); 2 | 3 | module.exports = class RateLimiterUnion { 4 | constructor(...limiters) { 5 | if (limiters.length < 1) { 6 | throw new Error('RateLimiterUnion: at least one limiter have to be passed'); 7 | } 8 | limiters.forEach((limiter) => { 9 | if (!(limiter instanceof RateLimiterAbstract)) { 10 | throw new Error('RateLimiterUnion: all limiters have to be instance of RateLimiterAbstract'); 11 | } 12 | }); 13 | 14 | this._limiters = limiters; 15 | } 16 | 17 | consume(key, points = 1) { 18 | return new Promise((resolve, reject) => { 19 | const promises = []; 20 | this._limiters.forEach((limiter) => { 21 | promises.push(limiter.consume(key, points).catch(rej => ({ rejected: true, rej }))); 22 | }); 23 | 24 | Promise.all(promises) 25 | .then((res) => { 26 | const resObj = {}; 27 | let rejected = false; 28 | 29 | res.forEach((item) => { 30 | if (item.rejected === true) { 31 | rejected = true; 32 | } 33 | }); 34 | 35 | for (let i = 0; i < res.length; i++) { 36 | if (rejected && res[i].rejected === true) { 37 | resObj[this._limiters[i].keyPrefix] = res[i].rej; 38 | } else if (!rejected) { 39 | resObj[this._limiters[i].keyPrefix] = res[i]; 40 | } 41 | } 42 | 43 | if (rejected) { 44 | reject(resObj); 45 | } else { 46 | resolve(resObj); 47 | } 48 | }); 49 | }); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /lib/RateLimiterValkey.js: -------------------------------------------------------------------------------- 1 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 2 | const RateLimiterRes = require('./RateLimiterRes'); 3 | 4 | const incrTtlLuaScript = ` 5 | server.call('set', KEYS[1], 0, 'EX', ARGV[2], 'NX') 6 | local consumed = server.call('incrby', KEYS[1], ARGV[1]) 7 | local ttl = server.call('pttl', KEYS[1]) 8 | return {consumed, ttl} 9 | `; 10 | 11 | class RateLimiterValkey extends RateLimiterStoreAbstract { 12 | /** 13 | * 14 | * @param {Object} opts 15 | * Defaults { 16 | * ... see other in RateLimiterStoreAbstract 17 | * 18 | * storeClient: ValkeyClient 19 | * rejectIfValkeyNotReady: boolean = false - reject / invoke insuranceLimiter immediately when valkey connection is not "ready" 20 | * } 21 | */ 22 | constructor(opts) { 23 | super(opts); 24 | this.client = opts.storeClient; 25 | 26 | this._rejectIfValkeyNotReady = !!opts.rejectIfValkeyNotReady; 27 | this._incrTtlLuaScript = opts.customIncrTtlLuaScript || incrTtlLuaScript; 28 | 29 | this.client.defineCommand('rlflxIncr', { 30 | numberOfKeys: 1, 31 | lua: this._incrTtlLuaScript, 32 | }); 33 | } 34 | 35 | /** 36 | * Prevent actual valkey call if valkey connection is not ready 37 | * @return {boolean} 38 | * @private 39 | */ 40 | _isValkeyReady() { 41 | if (!this._rejectIfValkeyNotReady) { 42 | return true; 43 | } 44 | 45 | return this.client.status === 'ready'; 46 | } 47 | 48 | _getRateLimiterRes(rlKey, changedPoints, result) { 49 | let consumed; 50 | let resTtlMs; 51 | 52 | if (Array.isArray(result[0])) { 53 | [[, consumed], [, resTtlMs]] = result; 54 | } else { 55 | [consumed, resTtlMs] = result; 56 | } 57 | 58 | const res = new RateLimiterRes(); 59 | res.consumedPoints = +consumed; 60 | res.isFirstInDuration = res.consumedPoints === changedPoints; 61 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 62 | res.msBeforeNext = resTtlMs; 63 | 64 | return res; 65 | } 66 | 67 | _upsert(rlKey, points, msDuration, forceExpire = false) { 68 | if (!this._isValkeyReady()) { 69 | throw new Error('Valkey connection is not ready'); 70 | } 71 | 72 | const secDuration = Math.floor(msDuration / 1000); 73 | 74 | if (forceExpire) { 75 | const multi = this.client.multi(); 76 | 77 | if (secDuration > 0) { 78 | multi.set(rlKey, points, 'EX', secDuration); 79 | } else { 80 | multi.set(rlKey, points); 81 | } 82 | 83 | return multi.pttl(rlKey).exec(); 84 | } 85 | 86 | if (secDuration > 0) { 87 | return this.client.rlflxIncr([rlKey, String(points), String(secDuration), String(this.points), String(this.duration)]); 88 | } 89 | 90 | return this.client.multi().incrby(rlKey, points).pttl(rlKey).exec(); 91 | } 92 | 93 | _get(rlKey) { 94 | if (!this._isValkeyReady()) { 95 | throw new Error('Valkey connection is not ready'); 96 | } 97 | 98 | return this.client 99 | .multi() 100 | .get(rlKey) 101 | .pttl(rlKey) 102 | .exec() 103 | .then((result) => { 104 | const [[, points]] = result; 105 | if (points === null) return null; 106 | return result; 107 | }); 108 | } 109 | 110 | _delete(rlKey) { 111 | return this.client 112 | .del(rlKey) 113 | .then(result => result > 0); 114 | } 115 | } 116 | 117 | module.exports = RateLimiterValkey; 118 | -------------------------------------------------------------------------------- /lib/RateLimiterValkeyGlide.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract'); 3 | const RateLimiterRes = require('./RateLimiterRes'); 4 | 5 | /** 6 | * @typedef {import('@valkey/valkey-glide').GlideClient} GlideClient 7 | * @typedef {import('@valkey/valkey-glide').GlideClusterClient} GlideClusterClient 8 | */ 9 | 10 | const DEFAULT_LIBRARY_NAME = 'ratelimiterflexible'; 11 | 12 | const DEFAULT_VALKEY_SCRIPT = `local key = KEYS[1] 13 | local pointsToConsume = tonumber(ARGV[1]) 14 | if tonumber(ARGV[2]) > 0 then 15 | server.call('set', key, "0", 'EX', ARGV[2], 'NX') 16 | local consumed = server.call('incrby', key, pointsToConsume) 17 | local pttl = server.call('pttl', key) 18 | return {consumed, pttl} 19 | end 20 | local consumed = server.call('incrby', key, pointsToConsume) 21 | local pttl = server.call('pttl', key) 22 | return {consumed, pttl}`; 23 | 24 | const GET_VALKEY_SCRIPT = `local key = KEYS[1] 25 | local value = server.call('get', key) 26 | if value == nil then 27 | return value 28 | end 29 | local pttl = server.call('pttl', key) 30 | return {tonumber(value), pttl}`; 31 | 32 | class RateLimiterValkeyGlide extends RateLimiterStoreAbstract { 33 | /** 34 | * Constructor for RateLimiterValkeyGlide 35 | * 36 | * @param {Object} opts - Configuration options 37 | * @param {GlideClient|GlideClusterClient} opts.storeClient - Valkey Glide client instance (required) 38 | * @param {number} [opts.points=4] - Maximum number of points that can be consumed over duration 39 | * @param {number} [opts.duration=1] - Duration in seconds before points are reset 40 | * @param {number} [opts.blockDuration=0] - Duration in seconds that a key will be blocked for if consumed more than points 41 | * @param {boolean} [opts.rejectIfValkeyNotReady=false] - Whether to reject requests if Valkey is not ready 42 | * @param {boolean} [opts.execEvenly=false] - Delay actions to distribute them evenly over duration 43 | * @param {number} [opts.execEvenlyMinDelayMs] - Minimum delay between actions when execEvenly is true 44 | * @param {string} [opts.customFunction] - Custom Lua script for rate limiting logic 45 | * @param {number} [opts.inMemoryBlockOnConsumed] - Points threshold for in-memory blocking 46 | * @param {number} [opts.inMemoryBlockDuration] - Duration in seconds for in-memory blocking 47 | * @param {string} [opts.customFunctionLibName] - Custom name for the function library, defaults to 'ratelimiter'. 48 | * The name is used to identify the library of the lua function. An custom name should be used only if you 49 | * you want to use different libraries for different rate limiters, otherwise it is not needed. 50 | * @param {RateLimiterAbstract} [opts.insuranceLimiter] - Backup limiter to use when the primary client fails 51 | * 52 | * @example 53 | * const rateLimiter = new RateLimiterValkeyGlide({ 54 | * storeClient: glideClient, 55 | * points: 5, 56 | * duration: 1 57 | * }); 58 | * 59 | * @example <caption>With custom Lua function</caption> 60 | * const customScript = `local key = KEYS[1] 61 | * local pointsToConsume = tonumber(ARGV[1]) or 0 62 | * local secDuration = tonumber(ARGV[2]) or 0 63 | * 64 | * -- Custom implementation 65 | * -- ... 66 | * 67 | * -- Must return exactly two values: [consumed_points, ttl_in_ms] 68 | * return {consumed, ttl}` 69 | * 70 | * const rateLimiter = new RateLimiterValkeyGlide({ 71 | * storeClient: glideClient, 72 | * points: 5, 73 | * customFunction: customScript 74 | * }); 75 | * 76 | * @example <caption>With insurance limiter</caption> 77 | * const rateLimiter = new RateLimiterValkeyGlide({ 78 | * storeClient: primaryGlideClient, 79 | * points: 5, 80 | * duration: 2, 81 | * insuranceLimiter: new RateLimiterMemory({ 82 | * points: 5, 83 | * duration: 2 84 | * }) 85 | * }); 86 | * 87 | * @description 88 | * When providing a custom Lua script via `opts.customFunction`, it must: 89 | * 90 | * 1. Accept parameters: 91 | * - KEYS[1]: The key being rate limited 92 | * - ARGV[1]: Points to consume (as string, use tonumber() to convert) 93 | * - ARGV[2]: Duration in seconds (as string, use tonumber() to convert) 94 | * 95 | * 2. Return an array with exactly two elements: 96 | * - [0]: Consumed points (number) 97 | * - [1]: TTL in milliseconds (number) 98 | * 99 | * 3. Handle scenarios: 100 | * - New key creation: Initialize with expiry for fixed windows 101 | * - Key updates: Increment existing counters 102 | */ 103 | constructor(opts) { 104 | super(opts); 105 | this.client = opts.storeClient; 106 | this._scriptLoaded = false; 107 | this._getScriptLoaded = false; 108 | this._rejectIfValkeyNotReady = !!opts.rejectIfValkeyNotReady; 109 | this._luaScript = opts.customFunction || DEFAULT_VALKEY_SCRIPT; 110 | this._libraryName = opts.customFunctionLibName || DEFAULT_LIBRARY_NAME; 111 | } 112 | 113 | /** 114 | * Ensure scripts are loaded in the Valkey server 115 | * @returns {Promise<boolean>} True if scripts are loaded 116 | * @private 117 | */ 118 | async _loadScripts() { 119 | if (this._scriptLoaded && this._getScriptLoaded) { 120 | return true; 121 | } 122 | if (!this.client) { 123 | throw new Error('Valkey client is not set'); 124 | } 125 | const promises = []; 126 | if (!this._scriptLoaded) { 127 | const script = Buffer.from(`#!lua name=${this._libraryName} 128 | local function consume(KEYS, ARGV) 129 | ${this._luaScript.trim()} 130 | end 131 | server.register_function('consume', consume)`); 132 | promises.push(this.client.functionLoad(script, { replace: true })); 133 | } else promises.push(Promise.resolve(this._libraryName)); 134 | 135 | if (!this._getScriptLoaded) { 136 | const script = Buffer.from(`#!lua name=ratelimiter_get 137 | local function getValue(KEYS, ARGV) 138 | ${GET_VALKEY_SCRIPT.trim()} 139 | end 140 | server.register_function('getValue', getValue)`); 141 | promises.push(this.client.functionLoad(script, { replace: true })); 142 | } else promises.push(Promise.resolve('ratelimiter_get')); 143 | 144 | const results = await Promise.all(promises); 145 | this._scriptLoaded = results[0] === this._libraryName; 146 | this._getScriptLoaded = results[1] === 'ratelimiter_get'; 147 | 148 | if ((!this._scriptLoaded || !this._getScriptLoaded)) { 149 | throw new Error('Valkey connection is not ready, scripts not loaded'); 150 | } 151 | return true; 152 | } 153 | 154 | /** 155 | * Update or insert the rate limiter record 156 | * 157 | * @param {string} rlKey - The rate limiter key 158 | * @param {number} pointsToConsume - Points to be consumed 159 | * @param {number} msDuration - Duration in milliseconds 160 | * @param {boolean} [forceExpire=false] - Whether to force expiration 161 | * @param {Object} [options={}] - Additional options 162 | * @returns {Promise<Array>} Array containing consumed points and TTL 163 | * @private 164 | */ 165 | async _upsert(rlKey, pointsToConsume, msDuration, forceExpire = false, options = {}) { 166 | await this._loadScripts(); 167 | const secDuration = Math.floor(msDuration / 1000); 168 | if (forceExpire) { 169 | if (secDuration > 0) { 170 | await this.client.set( 171 | rlKey, 172 | String(pointsToConsume), 173 | { expiry: { type: 'EX', count: secDuration } }, 174 | ); 175 | return [pointsToConsume, secDuration * 1000]; 176 | } 177 | await this.client.set(rlKey, String(pointsToConsume)); 178 | return [pointsToConsume, -1]; 179 | } 180 | const result = await this.client.fcall( 181 | 'consume', 182 | [rlKey], 183 | [String(pointsToConsume), String(secDuration)], 184 | ); 185 | return result; 186 | } 187 | 188 | /** 189 | * Get the rate limiter record 190 | * 191 | * @param {string} rlKey - The rate limiter key 192 | * @param {Object} [options={}] - Additional options 193 | * @returns {Promise<Array|null>} Array containing consumed points and TTL, or null if not found 194 | * @private 195 | */ 196 | async _get(rlKey, options = {}) { 197 | await this._loadScripts(); 198 | const res = await this.client.fcall('getValue', [rlKey], []); 199 | return res.length > 0 ? res : null; 200 | } 201 | 202 | /** 203 | * Delete the rate limiter record 204 | * 205 | * @param {string} rlKey - The rate limiter key 206 | * @param {Object} [options={}] - Additional options 207 | * @returns {Promise<boolean>} True if successful, false otherwise 208 | * @private 209 | */ 210 | async _delete(rlKey, options = {}) { 211 | const result = await this.client.del([rlKey]); 212 | return result > 0; 213 | } 214 | 215 | /** 216 | * Convert raw result to RateLimiterRes object 217 | * 218 | * @param {string} rlKey - The rate limiter key 219 | * @param {number} changedPoints - Points changed in this operation 220 | * @param {Array|null} result - Result from Valkey operation 221 | * @returns {RateLimiterRes|null} RateLimiterRes object or null if result is null 222 | * @private 223 | */ 224 | _getRateLimiterRes(rlKey, changedPoints, result) { 225 | if (result === null) { 226 | return null; 227 | } 228 | const res = new RateLimiterRes(); 229 | const [consumedPointsStr, pttl] = result; 230 | const consumedPoints = Number(consumedPointsStr); 231 | 232 | // Handle consumed points 233 | res.isFirstInDuration = consumedPoints === changedPoints; 234 | res.consumedPoints = consumedPoints; 235 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 236 | res.msBeforeNext = pttl; 237 | return res; 238 | } 239 | 240 | /** 241 | * Close the rate limiter and release resources 242 | * Note: The method won't going to close the Valkey client, as it may be shared with other instances. 243 | * @returns {Promise<void>} Promise that resolves when the rate limiter is closed 244 | */ 245 | async close() { 246 | if (this._scriptLoaded) { 247 | await this.client.functionDelete(this._libraryName); 248 | this._scriptLoaded = false; 249 | } 250 | if (this._getScriptLoaded) { 251 | await this.client.functionDelete('ratelimiter_get'); 252 | this._getScriptLoaded = false; 253 | } 254 | if (this.insuranceLimiter) { 255 | try { 256 | await this.insuranceLimiter.close(); 257 | } catch (e) { 258 | // We can't assume that insuranceLimiter is a Valkey client or any 259 | // other insuranceLimiter type which implement close method. 260 | } 261 | } 262 | // Clear instance properties to let garbage collector free memory 263 | this.client = null; 264 | this._scriptLoaded = false; 265 | this._getScriptLoaded = false; 266 | this._rejectIfValkeyNotReady = false; 267 | this._luaScript = null; 268 | this._libraryName = null; 269 | this.insuranceLimiter = null; 270 | } 271 | } 272 | 273 | module.exports = RateLimiterValkeyGlide; 274 | -------------------------------------------------------------------------------- /lib/component/BlockedKeys/BlockedKeys.js: -------------------------------------------------------------------------------- 1 | module.exports = class BlockedKeys { 2 | constructor() { 3 | this._keys = {}; // {'key': 1526279430331} 4 | this._addedKeysAmount = 0; 5 | } 6 | 7 | collectExpired() { 8 | const now = Date.now(); 9 | 10 | Object.keys(this._keys).forEach((key) => { 11 | if (this._keys[key] <= now) { 12 | delete this._keys[key]; 13 | } 14 | }); 15 | 16 | this._addedKeysAmount = Object.keys(this._keys).length; 17 | } 18 | 19 | /** 20 | * Add new blocked key 21 | * 22 | * @param key String 23 | * @param sec Number 24 | */ 25 | add(key, sec) { 26 | this.addMs(key, sec * 1000); 27 | } 28 | 29 | /** 30 | * Add new blocked key for ms 31 | * 32 | * @param key String 33 | * @param ms Number 34 | */ 35 | addMs(key, ms) { 36 | this._keys[key] = Date.now() + ms; 37 | this._addedKeysAmount++; 38 | if (this._addedKeysAmount > 999) { 39 | this.collectExpired(); 40 | } 41 | } 42 | 43 | /** 44 | * 0 means not blocked 45 | * 46 | * @param key 47 | * @returns {number} 48 | */ 49 | msBeforeExpire(key) { 50 | const expire = this._keys[key]; 51 | 52 | if (expire && expire >= Date.now()) { 53 | this.collectExpired(); 54 | const now = Date.now(); 55 | return expire >= now ? expire - now : 0; 56 | } 57 | 58 | return 0; 59 | } 60 | 61 | /** 62 | * If key is not given, delete all data in memory 63 | * 64 | * @param {string|undefined} key 65 | */ 66 | delete(key) { 67 | if (key) { 68 | delete this._keys[key]; 69 | } else { 70 | Object.keys(this._keys).forEach((key) => { 71 | delete this._keys[key]; 72 | }); 73 | } 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /lib/component/BlockedKeys/index.js: -------------------------------------------------------------------------------- 1 | const BlockedKeys = require('./BlockedKeys'); 2 | 3 | module.exports = BlockedKeys; 4 | -------------------------------------------------------------------------------- /lib/component/MemoryStorage/MemoryStorage.js: -------------------------------------------------------------------------------- 1 | const Record = require('./Record'); 2 | const RateLimiterRes = require('../../RateLimiterRes'); 3 | 4 | module.exports = class MemoryStorage { 5 | constructor() { 6 | /** 7 | * @type {Object.<string, Record>} 8 | * @private 9 | */ 10 | this._storage = {}; 11 | } 12 | 13 | incrby(key, value, durationSec) { 14 | if (this._storage[key]) { 15 | const msBeforeExpires = this._storage[key].expiresAt 16 | ? this._storage[key].expiresAt.getTime() - new Date().getTime() 17 | : -1; 18 | if (!this._storage[key].expiresAt || msBeforeExpires > 0) { 19 | // Change value 20 | this._storage[key].value = this._storage[key].value + value; 21 | 22 | return new RateLimiterRes(0, msBeforeExpires, this._storage[key].value, false); 23 | } 24 | 25 | return this.set(key, value, durationSec); 26 | } 27 | return this.set(key, value, durationSec); 28 | } 29 | 30 | set(key, value, durationSec) { 31 | const durationMs = durationSec * 1000; 32 | 33 | if (this._storage[key] && this._storage[key].timeoutId) { 34 | clearTimeout(this._storage[key].timeoutId); 35 | } 36 | 37 | this._storage[key] = new Record( 38 | value, 39 | durationMs > 0 ? new Date(Date.now() + durationMs) : null 40 | ); 41 | if (durationMs > 0) { 42 | this._storage[key].timeoutId = setTimeout(() => { 43 | delete this._storage[key]; 44 | }, durationMs); 45 | if (this._storage[key].timeoutId.unref) { 46 | this._storage[key].timeoutId.unref(); 47 | } 48 | } 49 | 50 | return new RateLimiterRes(0, durationMs === 0 ? -1 : durationMs, this._storage[key].value, true); 51 | } 52 | 53 | /** 54 | * 55 | * @param key 56 | * @returns {*} 57 | */ 58 | get(key) { 59 | if (this._storage[key]) { 60 | const msBeforeExpires = this._storage[key].expiresAt 61 | ? this._storage[key].expiresAt.getTime() - new Date().getTime() 62 | : -1; 63 | return new RateLimiterRes(0, msBeforeExpires, this._storage[key].value, false); 64 | } 65 | return null; 66 | } 67 | 68 | /** 69 | * 70 | * @param key 71 | * @returns {boolean} 72 | */ 73 | delete(key) { 74 | if (this._storage[key]) { 75 | if (this._storage[key].timeoutId) { 76 | clearTimeout(this._storage[key].timeoutId); 77 | } 78 | delete this._storage[key]; 79 | return true; 80 | } 81 | return false; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /lib/component/MemoryStorage/Record.js: -------------------------------------------------------------------------------- 1 | module.exports = class Record { 2 | /** 3 | * 4 | * @param value int 5 | * @param expiresAt Date|int 6 | * @param timeoutId 7 | */ 8 | constructor(value, expiresAt, timeoutId = null) { 9 | this.value = value; 10 | this.expiresAt = expiresAt; 11 | this.timeoutId = timeoutId; 12 | } 13 | 14 | get value() { 15 | return this._value; 16 | } 17 | 18 | set value(value) { 19 | this._value = parseInt(value); 20 | } 21 | 22 | get expiresAt() { 23 | return this._expiresAt; 24 | } 25 | 26 | set expiresAt(value) { 27 | if (!(value instanceof Date) && Number.isInteger(value)) { 28 | value = new Date(value); 29 | } 30 | this._expiresAt = value; 31 | } 32 | 33 | get timeoutId() { 34 | return this._timeoutId; 35 | } 36 | 37 | set timeoutId(value) { 38 | this._timeoutId = value; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /lib/component/MemoryStorage/index.js: -------------------------------------------------------------------------------- 1 | const MemoryStorage = require('./MemoryStorage'); 2 | 3 | module.exports = MemoryStorage; 4 | -------------------------------------------------------------------------------- /lib/component/RateLimiterEtcdTransactionFailedError.js: -------------------------------------------------------------------------------- 1 | module.exports = class RateLimiterEtcdTransactionFailedError extends Error { 2 | constructor(message) { 3 | super(); 4 | if (Error.captureStackTrace) { 5 | Error.captureStackTrace(this, this.constructor); 6 | } 7 | this.name = 'RateLimiterEtcdTransactionFailedError'; 8 | this.message = message; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/component/RateLimiterQueueError.js: -------------------------------------------------------------------------------- 1 | module.exports = class RateLimiterQueueError extends Error { 2 | constructor(message, extra) { 3 | super(); 4 | if (Error.captureStackTrace) { 5 | Error.captureStackTrace(this, this.constructor); 6 | } 7 | this.name = 'CustomError'; 8 | this.message = message; 9 | if (extra) { 10 | this.extra = extra; 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/component/RateLimiterSetupError.js: -------------------------------------------------------------------------------- 1 | module.exports = class RateLimiterSetupError extends Error { 2 | constructor(message) { 3 | super(); 4 | if (Error.captureStackTrace) { 5 | Error.captureStackTrace(this, this.constructor); 6 | } 7 | this.name = 'RateLimiterSetupError'; 8 | this.message = message; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /lib/component/index.d.ts: -------------------------------------------------------------------------------- 1 | export class RateLimiterQueueError extends Error { 2 | 3 | constructor(message?: string, extra?: string); 4 | 5 | readonly name: string; 6 | readonly message: string; 7 | readonly extra: string; 8 | 9 | } 10 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | const LIMITER_TYPES = { 2 | MEMORY: 'memory', 3 | CLUSTER: 'cluster', 4 | MEMCACHE: 'memcache', 5 | MONGO: 'mongo', 6 | REDIS: 'redis', 7 | MYSQL: 'mysql', 8 | POSTGRES: 'postgres', 9 | DYNAMO: 'dynamo', 10 | PRISMA: 'prisma', 11 | SQLITE: 'sqlite', 12 | VALKEY: 'valkey', 13 | VALKEY_GLIDE: 'valkey-glide', 14 | }; 15 | 16 | const ERR_UNKNOWN_LIMITER_TYPE_MESSAGE = 'Unknown limiter type. Use one of LIMITER_TYPES constants.'; 17 | 18 | module.exports = { 19 | LIMITER_TYPES, 20 | ERR_UNKNOWN_LIMITER_TYPE_MESSAGE, 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rate-limiter-flexible", 3 | "version": "7.1.1", 4 | "description": "Node.js rate limiter by key and protection from DDoS and Brute-Force attacks in process Memory, Redis, MongoDb, Memcached, MySQL, PostgreSQL, Cluster or PM", 5 | "main": "index.js", 6 | "scripts": { 7 | "dc:up": "docker-compose -f docker-compose.yml up -d", 8 | "dc:down": "docker-compose -f docker-compose.yml down", 9 | "valkey-cluster:up": "docker-compose -f docker-compose.valkey-cluster.yml up -d", 10 | "valkey-cluster:down": "docker-compose -f docker-compose.valkey-cluster.yml down -v", 11 | "test:valkey-cluster": "VALKEY_CLUSTER_PORT=7001 mocha test/RateLimiterValkeyGlide.test.js -- -g 'RateLimiterValkeyGlide with cluster client'", 12 | "prisma:postgres": "prisma generate --schema=./test/RateLimiterPrisma/Postgres/schema.prisma && prisma db push --schema=./test/RateLimiterPrisma/Postgres/schema.prisma", 13 | "test": "npm run prisma:postgres && nyc --reporter=html --reporter=text mocha", 14 | "debug-test": "mocha --inspect-brk lib/**/**.test.js", 15 | "coveralls": "cat ./coverage/lcov.info | coveralls", 16 | "eslint": "eslint --quiet lib/**/**.js test/**/**.js", 17 | "eslint-fix": "eslint --fix lib/**/**.js test/**/**.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/animir/node-rate-limiter-flexible.git" 22 | }, 23 | "keywords": [ 24 | "ratelimter", 25 | "authorization", 26 | "security", 27 | "rate", 28 | "limit", 29 | "bruteforce", 30 | "throttle", 31 | "redis", 32 | "mongodb", 33 | "dynamodb", 34 | "mysql", 35 | "postgres", 36 | "prisma", 37 | "koa", 38 | "express", 39 | "hapi", 40 | "valkey", 41 | "valkey-glide", 42 | "GLIDE", 43 | "cluster", 44 | "memcached" 45 | ], 46 | "author": "animir <animirr@gmail.com>", 47 | "license": "ISC", 48 | "bugs": { 49 | "url": "https://github.com/animir/node-rate-limiter-flexible/issues" 50 | }, 51 | "homepage": "https://github.com/animir/node-rate-limiter-flexible#readme", 52 | "types": "./lib/index.d.ts", 53 | "devDependencies": { 54 | "@aws-sdk/client-dynamodb": "^3.431.0", 55 | "@prisma/client": "^5.8.0", 56 | "better-sqlite3": "^11.9.0", 57 | "chai": "^4.1.2", 58 | "coveralls": "^3.0.1", 59 | "etcd3": "^1.1.2", 60 | "eslint": "^4.19.1", 61 | "eslint-config-airbnb-base": "^12.1.0", 62 | "eslint-plugin-import": "^2.7.0", 63 | "eslint-plugin-node": "^6.0.1", 64 | "eslint-plugin-security": "^1.4.0", 65 | "ioredis": "^5.3.2", 66 | "iovalkey": "^0.3.1", 67 | "istanbul": "^1.1.0-alpha.1", 68 | "knex": "^3.1.0", 69 | "memcached-mock": "^0.1.0", 70 | "mocha": "^10.2.0", 71 | "nyc": "^15.1.0", 72 | "prisma": "^5.8.0", 73 | "redis": "^4.6.8", 74 | "redis-mock": "^0.48.0", 75 | "sinon": "^17.0.1", 76 | "sqlite3": "^5.1.7", 77 | "@valkey/valkey-glide": "^1.3.1" 78 | }, 79 | "browser": { 80 | "cluster": false, 81 | "crypto": false 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/BurstyRateLimiter.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | const { describe, it } = require('mocha'); 3 | const { expect } = require('chai'); 4 | const RateLimiterMemory = require('../lib/RateLimiterMemory'); 5 | const BurstyRateLimiter = require('../lib/BurstyRateLimiter'); 6 | const RateLimiterRedis = require('../lib/RateLimiterRedis'); 7 | const Redis = require("ioredis"); 8 | 9 | describe('BurstyRateLimiter', () => { 10 | it('consume 1 point from limiter', (done) => { 11 | const testKey = 'consume1'; 12 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 13 | const blMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 14 | const bursty = new BurstyRateLimiter(rlMemory, blMemory); 15 | bursty.consume(testKey) 16 | .then((res) => { 17 | expect(res.consumedPoints).to.equal(1); 18 | expect(res.remainingPoints).to.equal(0); 19 | expect(res.msBeforeNext <= 1000).to.equal(true); 20 | expect(res.isFirstInDuration).to.equal(true); 21 | done(); 22 | }) 23 | .catch((err) => { 24 | done(err); 25 | }); 26 | }); 27 | 28 | it('consume 1 point from bursty limiter, if all consumed on limiter', (done) => { 29 | const testKey = 'consume1frombursty'; 30 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 31 | const blMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 32 | const bursty = new BurstyRateLimiter(rlMemory, blMemory); 33 | bursty.consume(testKey) 34 | .then(() => { 35 | bursty.consume(testKey) 36 | .then((res) => { 37 | expect(res.consumedPoints).to.equal(2); 38 | expect(res.remainingPoints).to.equal(0); 39 | expect(res.msBeforeNext <= 1000).to.equal(true); 40 | expect(res.isFirstInDuration).to.equal(false); 41 | done(); 42 | }) 43 | .catch((err) => { 44 | done(err); 45 | }); 46 | }) 47 | .catch((err) => { 48 | done(err); 49 | }); 50 | }); 51 | 52 | it('consume 1 point from limiter and 1 from bursty, and then 1 point reject with data from limiter', (done) => { 53 | const testKey = 'consume1frombursty'; 54 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 55 | const blMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 56 | const bursty = new BurstyRateLimiter(rlMemory, blMemory); 57 | bursty.consume(testKey) 58 | .then(() => { 59 | bursty.consume(testKey) 60 | .then(() => { 61 | bursty.consume(testKey) 62 | .then(() => { 63 | done(new Error('must not')); 64 | }) 65 | .catch((rej) => { 66 | expect(rej.consumedPoints).to.equal(3); 67 | expect(rej.remainingPoints).to.equal(0); 68 | expect(rej.msBeforeNext <= 1000).to.equal(true); 69 | expect(rej.isFirstInDuration).to.equal(false); 70 | done(); 71 | }); 72 | }) 73 | .catch((err) => { 74 | done(err); 75 | }); 76 | }) 77 | .catch((err) => { 78 | done(err); 79 | }); 80 | }); 81 | 82 | it('do not consume from burst limiter, if rate limiter consume rejected with error', async() => { 83 | const testKey = 'consume-rejected-with-error'; 84 | const redisMockClient = new Redis(); 85 | const redisClientClosed = new Redis(); 86 | await redisClientClosed.disconnect(); 87 | const rlRedisClosed = new RateLimiterRedis({ 88 | storeClient: redisClientClosed, 89 | }); 90 | const blRedis = new RateLimiterRedis({ 91 | storeClient: redisMockClient, 92 | keyPrefix: 'bursty', 93 | points: 1, 94 | duration: 1, 95 | }); 96 | const bursty = new BurstyRateLimiter(rlRedisClosed, blRedis); 97 | 98 | let testFailed = false 99 | try { 100 | await bursty.consume(testKey) 101 | testFailed = true; 102 | } catch(err) { 103 | expect(err instanceof Error).to.equal(true); 104 | try { 105 | const rlRes = await rlRedis.get(testKey) 106 | expect(rlRes).to.equal(null); 107 | } catch (err2) { 108 | testFailed = true; 109 | } 110 | } 111 | await redisMockClient.disconnect(); 112 | if (testFailed) { 113 | return new Error('must not'); 114 | } 115 | }); 116 | 117 | it('reject with burst limiter error if it happens', async() => { 118 | const testKey = 'consume-rejected-with-error'; 119 | const redisMockClient = new Redis(); 120 | const redisClientClosed = new Redis(); 121 | await redisClientClosed.disconnect(); 122 | const rlRedis = new RateLimiterRedis({ 123 | storeClient: redisMockClient, 124 | points: 1, 125 | duration: 1, 126 | }); 127 | const blRedisClosed = new RateLimiterRedis({ 128 | storeClient: redisClientClosed, 129 | keyPrefix: 'bursty', 130 | }); 131 | const bursty = new BurstyRateLimiter(rlRedis, blRedisClosed); 132 | await bursty.consume(testKey); 133 | let testFailed = false 134 | try { 135 | await bursty.consume(testKey) 136 | testFailed = true; 137 | } catch(err) { 138 | expect(err instanceof Error).to.equal(true); 139 | const rlRes = await rlRedis.get(testKey) 140 | expect(rlRes.consumedPoints).to.equal(2); 141 | expect(rlRes.remainingPoints).to.equal(0); 142 | expect(rlRes.msBeforeNext <= 1000).to.equal(true); 143 | } 144 | await redisMockClient.disconnect(); 145 | if (testFailed) { 146 | throw new Error('must not'); 147 | } 148 | }); 149 | 150 | it('consume and get return the combined RateLimiterRes of both limiters with correct msBeforeNext', (done) => { 151 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 152 | const rlBurstMemory = new RateLimiterMemory({ points: 20, duration: 1 }); 153 | 154 | const bl = new BurstyRateLimiter(rlMemory, rlBurstMemory); 155 | 156 | bl.consume('keyGet', 1) 157 | .then((firstConsumeRes) => { 158 | expect(firstConsumeRes.isFirstInDuration).to.equal(true); 159 | bl.consume('keyGet', 1) 160 | .then((res) => { 161 | expect(res.consumedPoints).to.equal(2); 162 | expect(res.remainingPoints).to.equal(0); 163 | expect(res.msBeforeNext <= 1000).to.equal(true); 164 | expect(res.isFirstInDuration).to.equal(false); 165 | 166 | bl.get('keyGet') 167 | .then((rlRes) => { 168 | expect(rlRes.consumedPoints).to.equal(2); 169 | expect(rlRes.remainingPoints).to.equal(0); 170 | expect(rlRes.msBeforeNext <= 1000).to.equal(true); 171 | done(); 172 | }) 173 | .catch(err => done(err)); 174 | }) 175 | .catch((err) => { 176 | done(err); 177 | }); 178 | }); 179 | }); 180 | 181 | it('returns points from limiter', (done) => { 182 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 183 | const rlBurstMemory = new RateLimiterMemory({ points: 20, duration: 1 }); 184 | 185 | const brl = new BurstyRateLimiter(rlMemory, rlBurstMemory); 186 | expect(brl.points).to.equal(1); 187 | done(); 188 | }); 189 | 190 | it('returns null if key does not exist', (done) => { 191 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 192 | const rlBurstMemory = new RateLimiterMemory({ points: 20, duration: 1 }); 193 | 194 | const brl = new BurstyRateLimiter(rlMemory, rlBurstMemory); 195 | brl.get('test-null') 196 | .then((res) => { 197 | expect(res).to.equal(null); 198 | done(); 199 | }); 200 | }); 201 | 202 | it('returns msBeforeNext=0 if key is not set on bursty limiter', (done) => { 203 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 10 }); 204 | const rlBurstMemory = new RateLimiterMemory({ points: 20, duration: 1 }); 205 | 206 | const testKey = 'test-burst-null' 207 | const brl = new BurstyRateLimiter(rlMemory, rlBurstMemory); 208 | rlMemory.consume(testKey) 209 | .then(async () => { 210 | brl.get(testKey) 211 | .then((res) => { 212 | expect(res.msBeforeNext).to.equal(0); 213 | done(); 214 | }) 215 | .catch((err) => { 216 | done(err); 217 | }) 218 | }) 219 | .catch((err) => { 220 | done(err); 221 | }); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/RateLimiterAbstract.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const RateLimiterAbstract = require('../lib/RateLimiterAbstract'); 4 | 5 | describe('RateLimiterAbstract', function () { 6 | this.timeout(5000); 7 | 8 | it('do not prefix key, if keyPrefix is empty string', () => { 9 | const testKey = 'test1'; 10 | const rateLimiter = new RateLimiterAbstract({ keyPrefix: '' }); 11 | expect(rateLimiter.getKey(testKey)).to.equal(testKey); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/RateLimiterCluster.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable no-unused-expressions */ 3 | /* eslint-disable security/detect-object-injection */ 4 | const cluster = require('cluster'); 5 | const sinon = require('sinon'); 6 | const { describe, it, after } = require('mocha'); 7 | const { expect } = require('chai'); 8 | const { RateLimiterClusterMaster, RateLimiterCluster } = require('../lib/RateLimiterCluster'); 9 | 10 | const masterEvents = []; 11 | const workerEvents = []; 12 | 13 | const worker = { 14 | send: (data) => { 15 | workerEvents.forEach((cb) => { 16 | cb(data); 17 | }); 18 | }, 19 | }; 20 | 21 | global.process.on = (eventName, cb) => { 22 | if (eventName === 'message') { 23 | workerEvents.push(cb); 24 | } 25 | }; 26 | global.process.send = (data) => { 27 | masterEvents.forEach((cb) => { 28 | cb(worker, data); 29 | }); 30 | }; 31 | 32 | describe('RateLimiterCluster', function RateLimiterClusterTest() { 33 | let rateLimiterClusterMaster; 34 | let clusterStubOn; 35 | this.timeout(5000); 36 | 37 | before(() => { 38 | clusterStubOn = sinon.stub(cluster, 'on').callsFake((eventName, cb) => { 39 | masterEvents.push(cb); 40 | }); 41 | rateLimiterClusterMaster = new RateLimiterClusterMaster(); 42 | }); 43 | 44 | after(() => { 45 | clusterStubOn.restore(); 46 | }); 47 | 48 | it('master must be singleton', () => { 49 | const rateLimiterClusterMaster2 = new RateLimiterClusterMaster(); 50 | expect(rateLimiterClusterMaster2 === rateLimiterClusterMaster).to.equal(true); 51 | }); 52 | 53 | it('consume 1 point', (done) => { 54 | const key = 'consume1'; 55 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix: key }); 56 | rateLimiterCluster.consume(key) 57 | .then((res) => { 58 | expect(res.remainingPoints).to.equal(1); 59 | done(); 60 | }) 61 | .catch((rej) => { 62 | done(rej); 63 | }); 64 | }); 65 | 66 | it('reject on consuming more than maximum points', (done) => { 67 | const key = 'reject'; 68 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix: key }); 69 | rateLimiterCluster.consume(key, 3) 70 | .then(() => { 71 | 72 | }) 73 | .catch((rejRes) => { 74 | expect(rejRes.remainingPoints).to.equal(0); 75 | done(); 76 | }); 77 | }); 78 | // 79 | it('execute evenly over duration', (done) => { 80 | const key = 'evenly'; 81 | const rateLimiterCluster = new RateLimiterCluster({ 82 | points: 2, duration: 5, execEvenly: true, keyPrefix: key, 83 | }); 84 | rateLimiterCluster.consume(key) 85 | .then(() => { 86 | const timeFirstConsume = Date.now(); 87 | rateLimiterCluster.consume(key) 88 | .then(() => { 89 | /* Second consume should be delayed more than 2 seconds 90 | Explanation: 91 | 1) consume at 0ms, remaining duration = 4444ms 92 | 2) delayed consume for (4444 / (0 + 2)) ~= 2222ms, where 2 is a fixed value 93 | , because it mustn't delay in the beginning and in the end of duration 94 | 3) consume after 2222ms by timeout 95 | */ 96 | expect((Date.now() - timeFirstConsume) > 2000).to.equal(true); 97 | done(); 98 | }) 99 | .catch((err) => { 100 | done(err); 101 | }); 102 | }) 103 | .catch((err) => { 104 | done(err); 105 | }); 106 | }); 107 | 108 | it('use keyPrefix from options', (done) => { 109 | const key = 'use keyPrefix from options'; 110 | 111 | const keyPrefix = 'test'; 112 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix }); 113 | rateLimiterCluster.consume(key) 114 | .then(() => { 115 | expect(typeof rateLimiterClusterMaster._rateLimiters[keyPrefix]._memoryStorage._storage[`${keyPrefix}:${key}`] 116 | !== 'undefined').to.equal(true); 117 | done(); 118 | }) 119 | .catch((rejRes) => { 120 | done(rejRes); 121 | }); 122 | }); 123 | 124 | it('create 2 rate limiters depending on keyPrefix', (done) => { 125 | const keyPrefixes = ['create1', 'create2']; 126 | const rateLimiterClusterprocess1 = new RateLimiterCluster({ keyPrefix: keyPrefixes[0] }); 127 | const rateLimiterClusterprocess2 = new RateLimiterCluster({ keyPrefix: keyPrefixes[1] }); 128 | rateLimiterClusterprocess1.consume('key1') 129 | .then(() => { 130 | rateLimiterClusterprocess2.consume('key2') 131 | .then(() => { 132 | const createdKeyLimiters = Object.keys(rateLimiterClusterMaster._rateLimiters); 133 | expect(createdKeyLimiters.indexOf(keyPrefixes[0]) !== -1 && createdKeyLimiters.indexOf(keyPrefixes[0]) !== -1).to.equal(true); 134 | done(); 135 | }); 136 | }); 137 | }); 138 | 139 | it('penalty', (done) => { 140 | const key = 'penalty'; 141 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix: key }); 142 | rateLimiterCluster.penalty(key) 143 | .then((res) => { 144 | expect(res.remainingPoints).to.equal(1); 145 | done(); 146 | }); 147 | }); 148 | 149 | it('reward', (done) => { 150 | const key = 'reward'; 151 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix: key }); 152 | rateLimiterCluster.consume(key) 153 | .then(() => { 154 | rateLimiterCluster.reward(key) 155 | .then((res) => { 156 | expect(res.remainingPoints).to.equal(2); 157 | done(); 158 | }); 159 | }); 160 | }); 161 | 162 | it('block', (done) => { 163 | const key = 'block'; 164 | const rateLimiterCluster = new RateLimiterCluster({ points: 1, duration: 1, keyPrefix: key }); 165 | rateLimiterCluster.block(key, 2) 166 | .then((res) => { 167 | expect(res.msBeforeNext > 1000 && res.msBeforeNext <= 2000).to.equal(true); 168 | done(); 169 | }); 170 | }); 171 | 172 | it('get', (done) => { 173 | const key = 'get'; 174 | const rateLimiterCluster = new RateLimiterCluster({ points: 1, duration: 1, keyPrefix: key }); 175 | rateLimiterCluster.consume(key) 176 | .then(() => { 177 | rateLimiterCluster.get(key) 178 | .then((res) => { 179 | expect(res.consumedPoints).to.equal(1); 180 | done(); 181 | }); 182 | }); 183 | }); 184 | 185 | it('get null', (done) => { 186 | const key = 'getnull'; 187 | const rateLimiterCluster = new RateLimiterCluster({ points: 1, duration: 1, keyPrefix: key }); 188 | rateLimiterCluster.get(key) 189 | .then((res) => { 190 | expect(res).to.equal(null); 191 | done(); 192 | }); 193 | }); 194 | 195 | it('delete', (done) => { 196 | const key = 'deletetrue'; 197 | const rateLimiterCluster = new RateLimiterCluster({ points: 1, duration: 10, keyPrefix: key }); 198 | rateLimiterCluster.consume(key) 199 | .then(() => { 200 | rateLimiterCluster.delete(key) 201 | .then((res) => { 202 | expect(res).to.equal(true); 203 | done(); 204 | }); 205 | }); 206 | }); 207 | 208 | it('consume applies options.customDuration to set expire', (done) => { 209 | const key = 'consume.customDuration'; 210 | const rateLimiterCluster = new RateLimiterCluster({ points: 2, duration: 5, keyPrefix: key }); 211 | rateLimiterCluster.consume(key, 1, { customDuration: 1 }) 212 | .then((res) => { 213 | expect(res.msBeforeNext <= 1000).to.be.true; 214 | done(); 215 | }) 216 | .catch((rej) => { 217 | done(rej); 218 | }); 219 | }); 220 | }); 221 | 222 | -------------------------------------------------------------------------------- /test/RateLimiterDynamo.test.js: -------------------------------------------------------------------------------- 1 | const {DynamoDB} = require('@aws-sdk/client-dynamodb') 2 | const { expect } = require('chai'); 3 | const { describe, it } = require('mocha'); 4 | const RateLimiterDynamo = require('../lib/RateLimiterDynamo'); 5 | const sinon = require('sinon'); 6 | 7 | describe('RateLimiterDynamo with fixed window', function RateLimiterDynamoTest() { 8 | this.timeout(5000); 9 | 10 | const dynamoClient = new DynamoDB({ 11 | region: 'eu-central-1', 12 | credentials: { 13 | accessKeyId: 'fake', 14 | secretAccessKey: 'fake' 15 | }, 16 | endpoint: 'http://127.0.0.1:8000' 17 | }); 18 | 19 | it('DynamoDb client connection', (done) => { 20 | expect(dynamoClient).to.not.equal(null); 21 | dynamoClient.listTables() 22 | .then((data) => { 23 | done(); 24 | }) 25 | .catch((err) => { 26 | done(err); 27 | }); 28 | }); 29 | 30 | it('get item from DynamoDB', (done) => { 31 | 32 | const testKey = 'test'; 33 | const rateLimiter = new RateLimiterDynamo({ 34 | storeClient: dynamoClient 35 | }, 36 | () => { 37 | rateLimiter.set(testKey, 999, 10000) 38 | .then((data) => { 39 | rateLimiter.get(testKey) 40 | .then((response) => { 41 | expect(response).to.not.equal(null); 42 | done(); 43 | }) 44 | .catch((err) => { 45 | done(err); 46 | }); 47 | }) 48 | .catch((err) => { 49 | done(err); 50 | }) 51 | } 52 | ); 53 | }); 54 | 55 | it('get NOT existing item from DynamoDB', (done) => { 56 | 57 | const testKey = 'not_existing'; 58 | const rateLimiter = new RateLimiterDynamo({ 59 | storeClient: dynamoClient 60 | }, 61 | () => { 62 | rateLimiter.get(testKey) 63 | .then((response) => { 64 | expect(response).to.equal(null); 65 | done(); 66 | }) 67 | .catch((err) => { 68 | done(err); 69 | }); 70 | } 71 | ); 72 | }); 73 | 74 | it('delete item from DynamoDB', (done) => { 75 | 76 | const testKey = 'delete_test'; 77 | const rateLimiter = new RateLimiterDynamo({ 78 | storeClient: dynamoClient 79 | }, 80 | () => { 81 | rateLimiter.set(testKey, 999, 10000) 82 | .then((data) => { 83 | rateLimiter.delete(testKey) 84 | .then((response) => { 85 | expect(response).to.equal(true); 86 | done(); 87 | }) 88 | .catch((err) => { 89 | done(err); 90 | }); 91 | }) 92 | .catch((err) => { 93 | done(err); 94 | }) 95 | } 96 | ); 97 | }); 98 | 99 | it('delete NOT existing item from DynamoDB return false', (done) => { 100 | 101 | const testKey = 'delete_test_2'; 102 | const rateLimiter = new RateLimiterDynamo({ 103 | storeClient: dynamoClient 104 | }, 105 | () => { 106 | rateLimiter.delete(testKey) 107 | .then((response) => { 108 | expect(response).to.equal(false); 109 | done(); 110 | }) 111 | .catch((err) => { 112 | done(err); 113 | }); 114 | } 115 | ); 116 | }); 117 | 118 | it('consume 1 point', (done) => { 119 | const testKey = 'consume1'; 120 | 121 | const rateLimiter = new RateLimiterDynamo({ 122 | storeClient: dynamoClient, 123 | points: 2, 124 | duration: 10 125 | }, 126 | () => { 127 | rateLimiter.consume(testKey) 128 | .then((result) => { 129 | expect(result.consumedPoints).to.equal(1); 130 | rateLimiter.delete(testKey); 131 | done(); 132 | }) 133 | .catch((err) => { 134 | done(err); 135 | }); 136 | 137 | }); 138 | 139 | }); 140 | 141 | it('rejected when consume more than maximum points', (done) => { 142 | const testKey = 'consumerej'; 143 | 144 | const rateLimiter = new RateLimiterDynamo({ 145 | storeClient: dynamoClient, 146 | points: 1, 147 | duration: 5 148 | }, 149 | () => { 150 | rateLimiter.consume(testKey, 2) 151 | .then((result) => { 152 | expect(result.consumedPoints).to.equal(2); 153 | done(Error('must not resolve')); 154 | }) 155 | .catch((err) => { 156 | expect(err.consumedPoints).to.equal(2); 157 | done(); 158 | }); 159 | 160 | }); 161 | }); 162 | 163 | it('blocks key for block duration when consumed more than points', (done) => { 164 | const testKey = 'block'; 165 | 166 | const rateLimiter = new RateLimiterDynamo({ 167 | storeClient: dynamoClient, 168 | points: 1, 169 | duration: 1, 170 | blockDuration: 2 171 | }, 172 | () => { 173 | rateLimiter.consume(testKey, 2) 174 | .then((result) => { 175 | expect(result.consumedPoints).to.equal(2); 176 | done(Error('must not resolve')); 177 | }) 178 | .catch((err) => { 179 | expect(err.msBeforeNext > 1000).to.equal(true); 180 | done(); 181 | }); 182 | 183 | }); 184 | 185 | }); 186 | 187 | it('return correct data with _getRateLimiterRes', () => { 188 | const testKey = 'test'; 189 | 190 | const rateLimiter = new RateLimiterDynamo({ 191 | storeClient: dynamoClient, 192 | points: 5, 193 | }, 194 | () => { 195 | 196 | const res = rateLimiter._getRateLimiterRes( 197 | 'test', 198 | 1, 199 | { key: 'test', points: 3, expire: (Date.now() + 1000) / 1000} 200 | ); 201 | 202 | expect(res.msBeforeNext <= 1000 && 203 | res.consumedPoints === 3 && 204 | res.isFirstInDuration === false && 205 | res.remainingPoints === 2 206 | ).to.equal(true); 207 | 208 | }); 209 | }); 210 | 211 | it('get points', (done) => { 212 | const testKey = 'get'; 213 | 214 | const rateLimiter = new RateLimiterDynamo({ 215 | storeClient: dynamoClient, 216 | points: 5, 217 | }, 218 | () => { 219 | 220 | rateLimiter.set(testKey, 999, 10000) 221 | .then((data) => { 222 | rateLimiter.get(testKey) 223 | .then((response) => { 224 | expect(response.consumedPoints).to.equal(999); 225 | rateLimiter.delete(testKey); 226 | done(); 227 | }) 228 | .catch((err) => { 229 | done(err); 230 | }); 231 | }) 232 | .catch((err) => { 233 | done(err); 234 | }); 235 | 236 | }); 237 | 238 | }); 239 | 240 | it('get points return NULL if key is not set', (done) => { 241 | const testKey = 'getnull'; 242 | 243 | const rateLimiter = new RateLimiterDynamo({ 244 | storeClient: dynamoClient, 245 | points: 5, 246 | }, 247 | () => { 248 | 249 | rateLimiter.get(testKey) 250 | .then((response) => { 251 | expect(response).to.equal(null); 252 | done(); 253 | }) 254 | .catch((err) => { 255 | done(err); 256 | }); 257 | }); 258 | 259 | }); 260 | 261 | it('delete returns false, if there is no key', (done) => { 262 | const testKey = 'getnull3'; 263 | 264 | const rateLimiter = new RateLimiterDynamo({ 265 | storeClient: dynamoClient, 266 | points: 5, 267 | }, 268 | () => { 269 | 270 | rateLimiter.delete(testKey) 271 | .then((response) => { 272 | expect(response).to.equal(false); 273 | done(); 274 | }) 275 | .catch((err) => { 276 | done(err); 277 | }); 278 | }); 279 | 280 | }); 281 | 282 | it('delete rejects on error', (done) => { 283 | const testKey = 'deleteerr'; 284 | 285 | const rateLimiter = new RateLimiterDynamo({ 286 | storeClient: dynamoClient, 287 | points: 5, 288 | }, 289 | () => { 290 | 291 | sinon.stub(dynamoClient, 'deleteItem').callsFake(() => { 292 | throw new Error('stub error'); 293 | }); 294 | 295 | rateLimiter.delete(testKey) 296 | .catch(() => { 297 | done(); 298 | }); 299 | 300 | dynamoClient.deleteItem.restore(); 301 | }); 302 | 303 | }); 304 | 305 | 306 | it('does not expire key if duration set to 0', (done) => { 307 | const testKey = 'neverexpire'; 308 | 309 | const rateLimiter = new RateLimiterDynamo({ 310 | storeClient: dynamoClient, 311 | points: 2, 312 | duration: 0 313 | }, 314 | () => { 315 | 316 | rateLimiter.set(testKey, 2, 0) 317 | .then(() => { 318 | rateLimiter.consume(testKey, 1) 319 | .then(() => { 320 | rateLimiter.get(testKey) 321 | .then((res) => { 322 | expect(res.consumedPoints).to.equal(1); 323 | expect(res.msBeforeNext).to.equal(-1); 324 | done(); 325 | }) 326 | .catch((err) => { 327 | done(err); 328 | }) 329 | }) 330 | .catch((err) => { 331 | done(err); 332 | }) 333 | }) 334 | .catch((err) => { 335 | done(err); 336 | }); 337 | 338 | }); 339 | 340 | }); 341 | 342 | }); 343 | -------------------------------------------------------------------------------- /test/RateLimiterPrisma/Postgres/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = "postgres://root:secret@127.0.0.1:5432" 8 | } 9 | 10 | model RateLimiterFlexible { 11 | key String @id 12 | points Int 13 | expire DateTime? 14 | } 15 | -------------------------------------------------------------------------------- /test/RateLimiterQueue.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const RateLimiterMemory = require('../lib/RateLimiterMemory'); 4 | const BurstyLimiter = require('../lib/BurstyRateLimiter'); 5 | const RateLimiterQueue = require('../lib/RateLimiterQueue'); 6 | const RateLimiterQueueError = require('../lib/component/RateLimiterQueueError'); 7 | 8 | describe('RateLimiterQueue with FIFO queue', function RateLimiterQueueTest() { 9 | this.timeout(5000); 10 | 11 | it('remove 1 token works and 1 remaining', (done) => { 12 | const rlMemory = new RateLimiterMemory({ points: 2, duration: 1 }); 13 | const rlQueue = new RateLimiterQueue(rlMemory); 14 | rlQueue.removeTokens(1) 15 | .then((remainingTokens) => { 16 | expect(remainingTokens).to.equal(1); 17 | done(); 18 | }); 19 | }); 20 | 21 | it('remove 2 tokens from bursty limiter and returns correct remainingTokens 0', (done) => { 22 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 23 | const blMemory = new RateLimiterMemory({ points: 1, duration: 3 }); 24 | const burstyLimiter = new BurstyLimiter(rlMemory, blMemory); 25 | const rlQueue = new RateLimiterQueue(burstyLimiter); 26 | const startTime = Date.now(); 27 | rlQueue.removeTokens(1) 28 | .then((remainingTokens1) => { 29 | expect(remainingTokens1).to.equal(0); 30 | rlQueue.removeTokens(1) 31 | .then((remainingTokens2) => { 32 | expect(remainingTokens2).to.equal(0); 33 | expect(Date.now() - startTime < 1000).to.equal(true); 34 | done(); 35 | }); 36 | }); 37 | }); 38 | 39 | it('remove 2 tokens from bursty limiter and wait 1 more', (done) => { 40 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 41 | const blMemory = new RateLimiterMemory({ points: 1, duration: 3 }); 42 | const burstyLimiter = new BurstyLimiter(rlMemory, blMemory); 43 | const rlQueue = new RateLimiterQueue(burstyLimiter); 44 | const startTime = Date.now(); 45 | rlQueue.removeTokens(1) 46 | .then(() => { 47 | rlQueue.removeTokens(1) 48 | .then(() => { 49 | rlQueue.removeTokens(1) 50 | .then((remainingTokens) => { 51 | expect(remainingTokens).to.equal(0); 52 | expect(Date.now() - startTime >= 999).to.equal(true); 53 | done(); 54 | }); 55 | }); 56 | }); 57 | }); 58 | 59 | it('remove all tokens works and 0 remaining', (done) => { 60 | const rlMemory = new RateLimiterMemory({ points: 2, duration: 1 }); 61 | const rlQueue = new RateLimiterQueue(rlMemory); 62 | rlQueue.removeTokens(2) 63 | .then((remainingTokens) => { 64 | expect(remainingTokens).to.equal(0); 65 | done(); 66 | }); 67 | }); 68 | 69 | it('return error if try to remove more tokens than allowed', (done) => { 70 | const rlMemory = new RateLimiterMemory({ points: 2, duration: 1 }); 71 | const rlQueue = new RateLimiterQueue(rlMemory); 72 | rlQueue.removeTokens(3) 73 | .then(() => { 74 | }) 75 | .catch((err) => { 76 | expect(err instanceof RateLimiterQueueError).to.equal(true); 77 | done(); 78 | }); 79 | }); 80 | 81 | it('queues 1 request and fire it after 1 second', (done) => { 82 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 83 | const rlQueue = new RateLimiterQueue(rlMemory); 84 | const time = Date.now(); 85 | rlQueue.removeTokens(1).then(() => { 86 | rlQueue.removeTokens(1).then((remainingTokens) => { 87 | expect(remainingTokens).to.equal(0); 88 | expect(Date.now() - time >= 999).to.equal(true); 89 | done(); 90 | }); 91 | }); 92 | }); 93 | 94 | it('respects order of queued callbacks', (done) => { 95 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 96 | const rlQueue = new RateLimiterQueue(rlMemory); 97 | let index; 98 | rlQueue.removeTokens(1).then(() => { 99 | index = 0; 100 | }); 101 | rlQueue.removeTokens(1).then(() => { 102 | expect(index).to.equal(0); 103 | index = 1; 104 | }); 105 | rlQueue.removeTokens(1).then(() => { 106 | expect(index).to.equal(1); 107 | index = 2; 108 | }); 109 | rlQueue.removeTokens(1).then(() => { 110 | expect(index).to.equal(2); 111 | done(); 112 | }); 113 | }); 114 | 115 | it('return error if queue length reaches maximum', (done) => { 116 | const rlMemory = new RateLimiterMemory({ points: 1, duration: 1 }); 117 | const rlQueue = new RateLimiterQueue(rlMemory, { maxQueueSize: 1 }); 118 | rlQueue.removeTokens(1).then(() => { 119 | }); 120 | rlQueue.removeTokens(1).then(() => { 121 | done(); 122 | }); 123 | rlQueue.removeTokens(1) 124 | .then(() => { 125 | done(new Error('must not allow to queue')); 126 | }) 127 | .catch((err) => { 128 | expect(err instanceof RateLimiterQueueError).to.equal(true); 129 | }); 130 | }); 131 | 132 | it('getTokensRemaining works', (done) => { 133 | const rlMemory = new RateLimiterMemory({ points: 2, duration: 1 }); 134 | const rlQueue = new RateLimiterQueue(rlMemory); 135 | rlQueue.removeTokens(1) 136 | .then(() => { 137 | rlQueue.getTokensRemaining() 138 | .then((tokensRemaining) => { 139 | expect(tokensRemaining).to.equal(1); 140 | done(); 141 | }); 142 | }); 143 | }); 144 | 145 | it('getTokensRemaining returns maximum if internal limiter by key does not exist', (done) => { 146 | const rlMemory = new RateLimiterMemory({ points: 23, duration: 1 }); 147 | const rlQueue = new RateLimiterQueue(rlMemory); 148 | rlQueue.getTokensRemaining('test') 149 | .then((tokensRemaining) => { 150 | expect(tokensRemaining).to.equal(23); 151 | done(); 152 | }); 153 | }); 154 | 155 | it('creates internal instance by key and removes tokens from it', (done) => { 156 | const rlMemory = new RateLimiterMemory({ points: 2, duration: 1 }); 157 | const rlQueue = new RateLimiterQueue(rlMemory); 158 | rlQueue.removeTokens(1, 'customkey') 159 | .then((remainingTokens) => { 160 | expect(remainingTokens).to.equal(1); 161 | rlQueue.getTokensRemaining() 162 | .then((defaultTokensRemaining) => { 163 | expect(defaultTokensRemaining).to.equal(2); 164 | done(); 165 | }); 166 | }); 167 | }); 168 | 169 | it('getTokensRemaining returns maximum if internal limiter does not have data', (done) => { 170 | const rlMemory = new RateLimiterMemory({ points: 23, duration: 1 }); 171 | const rlQueue = new RateLimiterQueue(rlMemory); 172 | rlQueue.removeTokens(1, 'nodata') 173 | .then(() => { 174 | setTimeout(() => { 175 | rlQueue.getTokensRemaining('nodata') 176 | .then((tokensRemaining) => { 177 | expect(tokensRemaining).to.equal(23); 178 | done(); 179 | }); 180 | }, 1001) 181 | }) 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /test/RateLimiterRes.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it, beforeEach } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const RateLimiterRes = require('../lib/RateLimiterRes'); 4 | 5 | describe('RateLimiterRes response object', () => { 6 | let rateLimiterRes; 7 | beforeEach(() => { 8 | rateLimiterRes = new RateLimiterRes(); 9 | }); 10 | 11 | it('setup defaults on construct', () => { 12 | expect(rateLimiterRes.msBeforeNext === 0 && rateLimiterRes.remainingPoints === 0) 13 | .to.be.equal(true); 14 | }); 15 | 16 | it('msBeforeNext set and get', () => { 17 | rateLimiterRes.msBeforeNext = 123; 18 | expect(rateLimiterRes.msBeforeNext).to.equal(123); 19 | }); 20 | 21 | it('points set and get', () => { 22 | rateLimiterRes.remainingPoints = 4; 23 | expect(rateLimiterRes.remainingPoints).to.equal(4); 24 | }); 25 | 26 | it('consumed points set and get', () => { 27 | rateLimiterRes.consumedPoints = 5; 28 | expect(rateLimiterRes.consumedPoints).to.equal(5); 29 | }); 30 | 31 | it('isFirstInDuration set and get with cast', () => { 32 | rateLimiterRes.isFirstInDuration = 1; 33 | expect(rateLimiterRes.isFirstInDuration).to.equal(true); 34 | }); 35 | 36 | it('returns object on toJSON call', () => { 37 | rateLimiterRes.msBeforeNext = 12; 38 | rateLimiterRes.remainingPoints = 3; 39 | rateLimiterRes.consumedPoints = 2; 40 | rateLimiterRes.isFirstInDuration = true; 41 | 42 | expect(rateLimiterRes.toJSON()).to.deep.equal({ 43 | remainingPoints: 3, 44 | msBeforeNext: 12, 45 | consumedPoints: 2, 46 | isFirstInDuration: true, 47 | }); 48 | }); 49 | 50 | it('returns JSON string on toString call', () => { 51 | rateLimiterRes.msBeforeNext = 2; 52 | rateLimiterRes.remainingPoints = 0; 53 | rateLimiterRes.consumedPoints = 5; 54 | rateLimiterRes.isFirstInDuration = false; 55 | 56 | expect(rateLimiterRes.toString()).to.equal('{"remainingPoints":0,"msBeforeNext":2,"consumedPoints":5,"isFirstInDuration":false}'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/RateLimiterSQLite.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it, beforeEach, afterEach } = require("mocha"); 2 | const { expect } = require("chai"); 3 | const sqlite3 = require("sqlite3"); 4 | const betterSQLite3 = require("better-sqlite3"); 5 | const { RateLimiterSQLite } = require("../index"); 6 | const knex = require("knex"); 7 | 8 | function testRateLimiterSQLite(library, createDb, clientName = null) { 9 | describe(`RateLimiterSQLite with ${clientName || library}`, () => { 10 | let db; 11 | let rateLimiter; 12 | 13 | beforeEach((done) => { 14 | db = createDb(); 15 | rateLimiter = new RateLimiterSQLite({ 16 | storeClient: db, 17 | storeType: library, 18 | tableName: "rate_limiter_test", 19 | points: 5, 20 | duration: 5, 21 | }); 22 | // Wait for table creation 23 | setTimeout(done, 100); 24 | }); 25 | 26 | afterEach((done) => { 27 | if (library === "sqlite3") { 28 | db.close(() => done()); 29 | return; 30 | } 31 | 32 | if (library === "knex") { 33 | db.destroy().then(() => done()); 34 | return; 35 | } 36 | db.close(); 37 | done(); 38 | }); 39 | 40 | describe("basic functionality", () => { 41 | it("should consume points", async () => { 42 | const res = await rateLimiter.consume("test"); 43 | expect(res.consumedPoints).to.equal(1); 44 | expect(res.remainingPoints).to.equal(4); 45 | }); 46 | 47 | it("should reject when too many points consumed", async () => { 48 | try { 49 | await rateLimiter.consume("test", 6); 50 | expect.fail("should have thrown"); 51 | } catch (err) { 52 | expect(err.remainingPoints).to.equal(0); 53 | expect(err.consumedPoints).to.equal(6); 54 | } 55 | }); 56 | 57 | it("should respect points and duration", async () => { 58 | const res1 = await rateLimiter.consume("test"); 59 | const res2 = await rateLimiter.consume("test"); 60 | expect(res1.consumedPoints).to.equal(1); 61 | expect(res2.consumedPoints).to.equal(2); 62 | expect(res2.remainingPoints).to.equal(3); 63 | }); 64 | }); 65 | 66 | describe("block functionality", () => { 67 | it("should block key for specified duration", async () => { 68 | await rateLimiter.block("test", 1); 69 | try { 70 | await rateLimiter.consume("test"); 71 | expect.fail("should have thrown"); 72 | } catch (err) { 73 | expect(err.msBeforeNext).to.be.at.least(900); 74 | } 75 | }); 76 | }); 77 | 78 | describe("get and delete operations", () => { 79 | it("should get points consumed", async () => { 80 | await rateLimiter.consume("test", 2); 81 | const res = await rateLimiter.get("test"); 82 | expect(res.consumedPoints).to.equal(2); 83 | }); 84 | 85 | it("should return null when getting non-existent key", async () => { 86 | const res = await rateLimiter.get("nonexistent"); 87 | expect(res).to.be.null; 88 | }); 89 | 90 | it("should delete key", async () => { 91 | await rateLimiter.consume("test"); 92 | const deleted = await rateLimiter.delete("test"); 93 | expect(deleted).to.be.true; 94 | const res = await rateLimiter.get("test"); 95 | expect(res).to.be.null; 96 | }); 97 | 98 | it("should return false when deleting non-existent key", async () => { 99 | const deleted = await rateLimiter.delete("nonexistent"); 100 | expect(deleted).to.be.false; 101 | }); 102 | }); 103 | 104 | describe("expiration", () => { 105 | it("should expire points after duration", (done) => { 106 | const shortLimiter = new RateLimiterSQLite({ 107 | storeClient: db, 108 | storeType: library, 109 | tableName: "rate_limiter_test_short", 110 | points: 5, 111 | duration: 1, // 1 second duration 112 | }); 113 | 114 | setTimeout(() => { 115 | shortLimiter 116 | .consume("test") 117 | .then(async () => { 118 | await new Promise((resolve) => setTimeout(resolve, 1100)); 119 | const res = await shortLimiter.get("test"); 120 | expect(res).to.be.null; 121 | done(); 122 | }) 123 | .catch(done); 124 | }, 100); // Wait for table creation 125 | }); 126 | }); 127 | 128 | describe("error handling", () => { 129 | it("should handle database errors gracefully", async () => { 130 | // Close the database to simulate errors 131 | if (library === "sqlite3") { 132 | await new Promise((resolve) => db.close(resolve)); 133 | } else if (library === "knex") { 134 | await db.destroy(); 135 | } else { 136 | db.close(); 137 | } 138 | 139 | try { 140 | await rateLimiter.consume("test"); 141 | expect.fail("should have thrown"); 142 | } catch (err) { 143 | expect(err).to.be.an("error"); 144 | } 145 | }); 146 | 147 | it("should reject table when not valid", async () => { 148 | try { 149 | const invalidLimiter = new RateLimiterSQLite({ 150 | storeClient: db, 151 | storeType: library, 152 | tableName: "invalid table name with spaces", 153 | points: 5, 154 | duration: 5, 155 | }); 156 | expect.fail("should have thrown"); 157 | } catch (err) { 158 | expect(err.message).to.equal( 159 | "Table name must contain only letters and numbers" 160 | ); 161 | } 162 | }); 163 | 164 | it("should reject storeType when it's not supported", async () => { 165 | const validStoreTypes = ["sqlite3", "better-sqlite3", "knex"]; 166 | 167 | try { 168 | const unsupportedStoreType = new RateLimiterSQLite({ 169 | storeClient: db, 170 | storeType: "not_supported", 171 | tableName: "invalid table name with spaces", 172 | points: 5, 173 | duration: 5, 174 | }); 175 | expect.fail("should have thrown"); 176 | } catch (err) { 177 | expect(err.message).to.equal( 178 | `storeType must be one of: ${validStoreTypes.join(", ")}` 179 | ); 180 | } 181 | }); 182 | }); 183 | 184 | describe("concurrent operations", () => { 185 | it("should handle multiple concurrent requests", async () => { 186 | const promises = []; 187 | for (let i = 0; i < 3; i++) { 188 | promises.push(rateLimiter.consume("test")); 189 | } 190 | 191 | const results = await Promise.all(promises); 192 | 193 | expect(results).to.have.lengthOf(3); 194 | expect(results[2].consumedPoints).to.equal(3); 195 | expect(results[2].remainingPoints).to.equal(2); 196 | }); 197 | }); 198 | 199 | describe("cleanup", () => { 200 | it("should clear expired records", async () => { 201 | const cleanupLimiter = new RateLimiterSQLite({ 202 | storeClient: db, 203 | storeType: library, 204 | tableName: "rate_limiter_cleanup", 205 | points: 5, 206 | duration: 1, 207 | clearExpiredByTimeout: true, 208 | }); 209 | 210 | // wait for table creation 211 | await new Promise((resolve) => setTimeout(resolve, 500)); 212 | 213 | await cleanupLimiter.consume("test"); 214 | await new Promise((resolve) => setTimeout(resolve, 1100)); 215 | await cleanupLimiter.clearExpired(Date.now()); 216 | 217 | const res = await cleanupLimiter.get("test"); 218 | expect(res).to.be.null; 219 | }); 220 | }); 221 | }); 222 | } 223 | 224 | // Run tests with sqlite3 in-memory database 225 | testRateLimiterSQLite("sqlite3", () => new sqlite3.Database(":memory:")); 226 | 227 | // Run tests with better-sqlite3 in-memory-database 228 | testRateLimiterSQLite("better-sqlite3", () => new betterSQLite3(":memory:")); 229 | 230 | // Run test with knex using better-sqlite3 in-memory database 231 | testRateLimiterSQLite( 232 | "knex", 233 | () => 234 | knex({ 235 | client: "better-sqlite3", 236 | connection: { filename: ":memory:" }, 237 | useNullAsDefault: true, 238 | }), 239 | "knex(better-sqlite3)" 240 | ); 241 | 242 | // Run test with knex using sqlite3 in-memory database 243 | testRateLimiterSQLite( 244 | "knex", 245 | () => 246 | knex({ 247 | client: "sqlite3", 248 | connection: { filename: ":memory:" }, 249 | useNullAsDefault: true, 250 | }), 251 | "knex(sqlite3)" 252 | ); 253 | -------------------------------------------------------------------------------- /test/RateLimiterStoreAbstract.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable security/detect-object-injection */ 2 | const { describe, it } = require('mocha'); 3 | const { expect } = require('chai'); 4 | const RateLimiterStoreAbstract = require('../lib/RateLimiterStoreAbstract'); 5 | const RateLimiterRes = require('../lib/RateLimiterRes'); 6 | 7 | class RateLimiterStoreMemory extends RateLimiterStoreAbstract { 8 | constructor(opts) { 9 | super(opts); 10 | this._inMemoryDataAsStorage = {}; 11 | } 12 | 13 | _getRateLimiterRes(rlKey, changedPoints, storeResult) { 14 | const res = new RateLimiterRes(); 15 | res.consumedPoints = storeResult.points; 16 | res.isFirstInDuration = res.consumedPoints === changedPoints; 17 | res.remainingPoints = Math.max(this.points - res.consumedPoints, 0); 18 | res.msBeforeNext = storeResult.msBeforeNext; 19 | return res; 20 | } 21 | 22 | _get(rlKey) { 23 | const result = this._inMemoryDataAsStorage[rlKey]; 24 | 25 | return Promise.resolve(typeof result === 'undefined' ? null : result); 26 | } 27 | 28 | _delete(rlKey) { 29 | const value = this._inMemoryDataAsStorage[rlKey]; 30 | 31 | if (typeof value === 'undefined') { 32 | return Promise.resolve(false); 33 | } 34 | 35 | delete this._inMemoryDataAsStorage[rlKey]; 36 | return Promise.resolve(true); 37 | } 38 | 39 | _upsert(rlKey, points, msDuration) { 40 | const now = Date.now(); 41 | const result = { 42 | points, 43 | msBeforeNext: msDuration, 44 | }; 45 | 46 | if (typeof this._inMemoryDataAsStorage[rlKey] === 'undefined') { 47 | this._inMemoryDataAsStorage[rlKey] = { 48 | points, 49 | expired: now + msDuration, 50 | }; 51 | } else { 52 | const value = this._inMemoryDataAsStorage[rlKey]; 53 | if (value.expired > now) { 54 | value.points += points; 55 | result.points = value.points; 56 | result.msBeforeNext = value.expired - now; 57 | } else { 58 | value.points = points; 59 | value.expired = now + msDuration; 60 | } 61 | } 62 | 63 | return Promise.resolve(result); 64 | } 65 | } 66 | 67 | describe('RateLimiterStoreAbstract with fixed window', () => { 68 | it('delete all in memory blocked keys', (done) => { 69 | const rateLimiter = new RateLimiterStoreMemory({ 70 | points: 1, 71 | duration: 1, 72 | // avoid fire block method 73 | blockDuration: 0, 74 | inMemoryBlockOnConsumed: 1, 75 | inMemoryBlockDuration: 1, 76 | keyPrefix: '', 77 | }); 78 | 79 | // should start blocking 80 | Promise.allSettled([ 81 | rateLimiter.consume('key1', 2), 82 | rateLimiter.consume('key2', 2), 83 | ]) 84 | .then(() => { 85 | expect(rateLimiter._inMemoryBlockedKeys._keys.key1).not.eq(undefined); 86 | expect(rateLimiter._inMemoryBlockedKeys._keys.key2).not.eq(undefined); 87 | 88 | rateLimiter.deleteInMemoryBlockedAll(); 89 | expect(rateLimiter._inMemoryBlockedKeys._keys.key1).eq(undefined); 90 | expect(rateLimiter._inMemoryBlockedKeys._keys.key2).eq(undefined); 91 | 92 | done(); 93 | }) 94 | .catch((err) => { 95 | done(err); 96 | }); 97 | }); 98 | 99 | it('delete specific key should also deleting in-memory data', (done) => { 100 | const rateLimiter = new RateLimiterStoreMemory({ 101 | points: 1, 102 | duration: 1, 103 | // avoid fire block method 104 | blockDuration: 0, 105 | inMemoryBlockOnConsumed: 1, 106 | inMemoryBlockDuration: 1, 107 | keyPrefix: '', 108 | }); 109 | 110 | // should start blocking 111 | rateLimiter.consume('key', 2).catch(() => { 112 | expect(rateLimiter._inMemoryBlockedKeys._keys.key).not.eq(undefined); 113 | 114 | rateLimiter.delete('key').then((isExist) => { 115 | expect(rateLimiter._inMemoryBlockedKeys._keys.key).eq(undefined); 116 | expect(isExist).eq(true); 117 | 118 | done(); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/RateLimiterUnion.test.js: -------------------------------------------------------------------------------- 1 | // eslint-disable no-unused-expressions 2 | const { describe, it, beforeEach } = require('mocha'); 3 | const { expect } = require('chai'); 4 | const RateLimiterUnion = require('../lib/RateLimiterUnion'); 5 | const RateLimiterMemory = require('../lib/RateLimiterMemory'); 6 | 7 | describe('RateLimiterUnion with fixed window', () => { 8 | const keyPrefix1 = 'limit1'; 9 | const keyPrefix2 = 'limit2'; 10 | let rateLimiter; 11 | 12 | beforeEach(() => { 13 | const limiter1 = new RateLimiterMemory({ 14 | keyPrefix: keyPrefix1, 15 | points: 1, 16 | duration: 1, 17 | }); 18 | const limiter2 = new RateLimiterMemory({ 19 | keyPrefix: keyPrefix2, 20 | points: 2, 21 | duration: 5, 22 | }); 23 | rateLimiter = new RateLimiterUnion(limiter1, limiter2); 24 | }); 25 | 26 | it('does not allow to create union with limiters number less than 2', () => { 27 | try { 28 | new RateLimiterUnion(new RateLimiterMemory({ // eslint-disable-line no-new 29 | keyPrefix: keyPrefix1, 30 | points: 1, 31 | duration: 1, 32 | })); 33 | } catch (err) { 34 | expect(err instanceof Error).to.equal(true); 35 | } 36 | }); 37 | 38 | it('all limiters have to be instance of RateLimiterAbstract', () => { 39 | try { 40 | new RateLimiterUnion(new RateLimiterMemory({ // eslint-disable-line no-new 41 | keyPrefix: keyPrefix1, 42 | points: 1, 43 | duration: 1, 44 | }), {}); 45 | } catch (err) { 46 | expect(err instanceof Error).to.equal(true); 47 | } 48 | }); 49 | 50 | it('consume from all limiters', (done) => { 51 | rateLimiter.consume('test') 52 | .then((res) => { 53 | expect(res[keyPrefix1].remainingPoints === 0 && res[keyPrefix2].remainingPoints === 1).to.equal(true); 54 | done(); 55 | }) 56 | .catch(() => { 57 | done(Error('must not reject')); 58 | }); 59 | }); 60 | 61 | it('reject consume one "limit1", which does not have enough points', (done) => { 62 | rateLimiter.consume('test', 2) 63 | .then(() => { 64 | done(Error('must not resolve')); 65 | }) 66 | .catch((rej) => { 67 | expect(rej[keyPrefix1].remainingPoints === 0).to.equal(true); 68 | done(); 69 | }); 70 | }); 71 | 72 | it('reject both do not have enough points', (done) => { 73 | rateLimiter.consume('test', 3) 74 | .then(() => { 75 | done(Error('must not resolve')); 76 | }) 77 | .catch((rej) => { 78 | expect(rej[keyPrefix1].remainingPoints === 0 && rej[keyPrefix2].remainingPoints === 0).to.equal(true); 79 | done(); 80 | }); 81 | }); 82 | }); 83 | 84 | -------------------------------------------------------------------------------- /test/RedisOptions.js: -------------------------------------------------------------------------------- 1 | // This object is used for setting the options for the redis client, 2 | // so we can connect to the redis server in Docker, using ipv4 and not 3 | // ipv6, which the client defaults to useing 4 | module.exports = { 5 | socket: { 6 | host: '127.0.0.1', 7 | port: 6379, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /test/component/BlockedKeys/BlockedKeys.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it, beforeEach } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const BlockedKeys = require('../../../lib/component/BlockedKeys/BlockedKeys'); 4 | 5 | describe('BlockedKeys', () => { 6 | let blockedKeys; 7 | beforeEach(() => { 8 | blockedKeys = new BlockedKeys(); 9 | }); 10 | 11 | it('add blocked key', () => { 12 | blockedKeys.add('key', 5); 13 | blockedKeys.collectExpired(); 14 | expect(blockedKeys.msBeforeExpire('key') > 0).to.equal(true); 15 | }); 16 | 17 | it('expire blocked key', (done) => { 18 | blockedKeys.add('key', 1); 19 | setTimeout(() => { 20 | expect(blockedKeys.msBeforeExpire('key')).to.equal(0); 21 | done(); 22 | }, 1001); 23 | }); 24 | 25 | it('check not blocked key', () => { 26 | blockedKeys.add('key', 1); 27 | expect(blockedKeys.msBeforeExpire('key1')).to.equal(0); 28 | }); 29 | 30 | it('do not collect expired on add', (done) => { 31 | blockedKeys.add('key', 1); 32 | blockedKeys.add('key1', 1); 33 | setTimeout(() => { 34 | blockedKeys.add('key2', 1); 35 | expect(Object.keys(blockedKeys._keys).length).to.equal(3); 36 | done(); 37 | }, 1001); 38 | }); 39 | 40 | it('collect expired on add if there more than 999 blocked keys', (done) => { 41 | for (let i = 0; i < 1000; i++) { 42 | blockedKeys.add(`key${i}`, 1); 43 | } 44 | 45 | setTimeout(() => { 46 | blockedKeys.add('key1', 1); 47 | expect(Object.keys(blockedKeys._keys).length === 1 && blockedKeys._addedKeysAmount === 1) 48 | .to.equal(true); 49 | done(); 50 | }, 1001); 51 | }); 52 | 53 | it('do not collect expired when key is not blocked', (done) => { 54 | blockedKeys.add('key', 1); 55 | setTimeout(() => { 56 | blockedKeys.msBeforeExpire('key'); 57 | expect(Object.keys(blockedKeys._keys).length === 1 && blockedKeys._addedKeysAmount === 1) 58 | .to.equal(true); 59 | done(); 60 | }, 1001); 61 | }); 62 | 63 | it('collect expired when key is blocked', (done) => { 64 | blockedKeys.add('key', 1); 65 | blockedKeys.add('blocked', 2); 66 | setTimeout(() => { 67 | blockedKeys.msBeforeExpire('blocked'); 68 | expect(Object.keys(blockedKeys._keys).length).to.equal(1); 69 | done(); 70 | }, 1001); 71 | }); 72 | 73 | it('duplicated keys do not brake collectExpired and msBeforeExpire', (done) => { 74 | blockedKeys.add('key', 1); 75 | blockedKeys.add('key', 2); 76 | setTimeout(() => { 77 | blockedKeys.add('key', 3); 78 | expect(blockedKeys.msBeforeExpire('key') > 2000).to.equal(true); 79 | done(); 80 | }, 1001); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/component/MemoryStorage/MemoryStorage.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it, beforeEach } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const MemoryStorage = require('../../../lib/component/MemoryStorage/MemoryStorage'); 4 | 5 | describe('MemoryStorage', function MemoryStorageTest() { 6 | const testKey = 'test'; 7 | const val = 34; 8 | let storage; 9 | 10 | this.timeout(5000); 11 | 12 | beforeEach(() => { 13 | storage = new MemoryStorage(); 14 | }); 15 | 16 | it('should set and get', (done) => { 17 | storage.set(testKey, val, 5); 18 | expect(storage.get(testKey).consumedPoints).to.equal(val); 19 | done(); 20 | }); 21 | 22 | it('should delete record on expire', (done) => { 23 | storage.set(testKey, val, 1); 24 | setTimeout(() => { 25 | expect(storage.get(testKey)).to.equal(null); 26 | done(); 27 | }, 2000); 28 | }); 29 | 30 | it('should incrby', (done) => { 31 | storage.set(testKey, val, 5); 32 | storage.incrby(testKey, 2); 33 | expect(storage.get(testKey).consumedPoints).to.equal(val + 2); 34 | done(); 35 | }); 36 | 37 | it('incrby should create record if it is not set', (done) => { 38 | storage.incrby(testKey, val, 5); 39 | expect(storage.get(testKey).consumedPoints).to.equal(val); 40 | done(); 41 | }); 42 | 43 | it('incrby should create record if expiresAt is not set', (done) => { 44 | storage.set(testKey, val) 45 | expect(storage.get(testKey).expiresAt).to.equal(undefined); 46 | storage.incrby(testKey, val, 5); 47 | expect(storage.get(testKey).expiresAt !== null).to.equal(true); 48 | done(); 49 | }); 50 | 51 | it('should delete record and return true, if it was there', () => { 52 | storage.set(testKey, val, 10); 53 | expect(storage.delete(testKey)).to.equal(true); 54 | expect(storage.get(testKey)).to.equal(null); 55 | }); 56 | 57 | it('return false, if there is no record to delete', () => { 58 | expect(storage.delete(testKey)).to.equal(false); 59 | }); 60 | 61 | it('should not fail in the absence of Timeout::unref', (done) => { 62 | // Node (where we most likely be running tests) provides `Timeout.prototype.unref`, however 63 | // MemoryStorage should run in environments where `Timeout.prototype.unref` is not provided 64 | // (e.g. browsers). For this test we remove `unref` from `Timeout.prototype` only for the 65 | // duration of this test, to verify that MemoryStorage.prototype.set won't throw. 66 | const handle = setTimeout(() => {}, 0); 67 | const isHandleObject = typeof handle === 'object' && !!handle.constructor; 68 | let timeoutUnref; 69 | if (isHandleObject) { 70 | timeoutUnref = handle.constructor.prototype.unref; 71 | delete handle.constructor.prototype.unref; 72 | } 73 | expect(() => new MemoryStorage().set('key', 0, 0.001)).to.not.throw(); 74 | setTimeout(done, 250); 75 | if (isHandleObject) { 76 | handle.constructor.prototype.unref = timeoutUnref; 77 | } 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/component/MemoryStorage/Record.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it, beforeEach } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const Record = require('../../../lib/component/MemoryStorage/Record'); 4 | 5 | describe('MemoryStorage Record', () => { 6 | let record; 7 | beforeEach(() => { 8 | record = new Record(); 9 | }); 10 | 11 | it('value set with cast to int and get', () => { 12 | record.value = '123'; 13 | expect(record.value).to.equal(123); 14 | }); 15 | 16 | it('expiresAt set unix time and get Date', () => { 17 | const now = Date.now(); 18 | record.expiresAt = now; 19 | expect(record.expiresAt.getTime()).to.equal(now); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/component/RateLimiterQueueError.test.js: -------------------------------------------------------------------------------- 1 | const { describe, it } = require('mocha'); 2 | const { expect } = require('chai'); 3 | const RateLimiterQueueError = require('../../lib/component/RateLimiterQueueError'); 4 | 5 | describe('RateLimiterQueueError', () => { 6 | it('supports extra argument in constructor', (done) => { 7 | const err = new RateLimiterQueueError('test', 'extra'); 8 | expect(err.extra).to.equal('extra'); 9 | done(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/scripts/cluster-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo "Setting up cluster environment..." 5 | 6 | if [ -z "$SKIP_SYSCTL" ]; then 7 | echo "Attempting system settings..." 8 | sysctl vm.overcommit_memory=1 2>/dev/null || true 9 | else 10 | echo "Skipping system settings (handled by Docker)" 11 | fi 12 | 13 | echo "Cleaning up data directories..." 14 | rm -rf /data/* 15 | 16 | # Initialize nodes 17 | init_nodes() { 18 | for port in $(seq 7001 7003); do 19 | mkdir -p /data/${port} 20 | cat > /data/${port}/valkey.conf <<EOF 21 | port ${port} 22 | cluster-enabled yes 23 | cluster-config-file /data/${port}/nodes.conf 24 | cluster-node-timeout 30000 25 | appendonly yes 26 | dir /data/${port} 27 | bind 0.0.0.0 28 | protected-mode no 29 | cluster-announce-ip 127.0.0.1 30 | daemonize yes 31 | EOF 32 | valkey-server /data/${port}/valkey.conf 33 | 34 | until valkey-cli -p ${port} ping 2>/dev/null; do 35 | echo "Waiting for node ${port} to start..." 36 | sleep 1 37 | done 38 | done 39 | } 40 | 41 | echo "Initializing nodes..." 42 | init_nodes 43 | 44 | echo "Waiting for nodes to stabilize..." 45 | sleep 5 46 | 47 | echo "Initializing cluster..." 48 | yes "yes" | valkey-cli --cluster create \ 49 | 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \ 50 | --cluster-replicas 0 51 | 52 | # Wait for cluster to stabilize 53 | echo "Waiting for cluster to stabilize..." 54 | for i in {1..30}; do 55 | if valkey-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then 56 | echo "Cluster is stable" 57 | break 58 | fi 59 | echo "Waiting for cluster to stabilize (attempt $i)..." 60 | sleep 2 61 | done 62 | 63 | echo "Cluster initialization complete" 64 | 65 | # Keep container running and monitor cluster 66 | while true; do 67 | echo "Cluster Status at $(date):" 68 | valkey-cli -p 7001 cluster info || echo "Failed to get cluster info" 69 | sleep 60 70 | done 71 | --------------------------------------------------------------------------------