├── .gitattributes ├── .npmrc ├── test ├── mocha.opts └── index.js ├── src ├── microtime.js └── index.js ├── .github ├── dependabot.yml └── workflows │ ├── pull_request.yml │ └── main.yml ├── .editorconfig ├── .gitignore ├── index.d.ts ├── LICENSE.md ├── package.json ├── README.md ├── benchmark └── index.js └── CHANGELOG.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | 2 | save-prefix=~ 3 | save=false 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --reporter spec 3 | --timeout 120000 4 | --slow 300 5 | --bail 6 | --recursive 7 | --exit 8 | -------------------------------------------------------------------------------- /src/microtime.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const time = Date.now() 4 | const start = process.hrtime.bigint() 5 | 6 | // Return high-precision timestamp in milliseconds 7 | module.exports.now = () => time + Number(process.hrtime.bigint() - start) / 1e6 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | schedule: 10 | # Check for updates to GitHub Actions every weekday 11 | interval: 'daily' 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 100 13 | indent_brace_style = 1TBS 14 | spaces_around_operators = true 15 | quote_type = auto 16 | 17 | [package.json] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # npm 3 | ############################ 4 | node_modules 5 | npm-debug.log 6 | .node_history 7 | yarn.lock 8 | package-lock.json 9 | 10 | ############################ 11 | # tmp, editor & OS files 12 | ############################ 13 | .tmp 14 | *.swo 15 | *.swp 16 | *.swn 17 | *.swm 18 | .DS_Store 19 | *# 20 | *~ 21 | .idea 22 | *sublime* 23 | nbproject 24 | dump.rdb 25 | 26 | ############################ 27 | # Tests 28 | ############################ 29 | testApp 30 | coverage 31 | .nyc_output 32 | 33 | ############################ 34 | # Other 35 | ############################ 36 | .envrc 37 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'async-ratelimiter' { 2 | import { Redis } from 'ioredis' 3 | 4 | namespace RateLimiter { 5 | interface ConstructorOptions { 6 | db: Redis 7 | max?: number 8 | duration?: number 9 | namespace?: string 10 | id?: string 11 | } 12 | 13 | interface GetOptions { 14 | id?: string 15 | max?: number 16 | duration?: number 17 | peek?: boolean 18 | } 19 | 20 | interface Status { 21 | total: number 22 | remaining: number 23 | reset: number 24 | } 25 | } 26 | 27 | 28 | class RateLimiter { 29 | constructor(options: RateLimiter.ConstructorOptions) 30 | get(options?: RateLimiter.GetOptions): Promise 31 | } 32 | 33 | export = RateLimiter 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull_request 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v6 12 | with: 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | - name: Start Redis 15 | uses: supercharge/redis-github-action@v2 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version: lts/* 20 | - name: Setup PNPM 21 | uses: pnpm/action-setup@v4 22 | with: 23 | version: latest 24 | run_install: true 25 | - name: Test 26 | run: pnpm test 27 | - name: Report 28 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 29 | - name: Coverage 30 | uses: coverallsapp/github-action@main 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2019 Microlink (microlink.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | contributors: 10 | if: "${{ github.event.head_commit.message != 'build: contributors' }}" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v6 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | - name: Start Redis 19 | uses: supercharge/redis-github-action@v2 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v6 22 | with: 23 | node-version: lts/* 24 | - name: Contributors 25 | run: | 26 | git config --global user.email ${{ secrets.GIT_EMAIL }} 27 | git config --global user.name ${{ secrets.GIT_USERNAME }} 28 | npm run contributors 29 | - name: Push changes 30 | run: | 31 | git push origin ${{ github.head_ref }} 32 | 33 | release: 34 | if: | 35 | !startsWith(github.event.head_commit.message, 'chore(release):') && 36 | !startsWith(github.event.head_commit.message, 'docs:') && 37 | !startsWith(github.event.head_commit.message, 'ci:') 38 | needs: [contributors] 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v6 43 | with: 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Start Redis 46 | uses: supercharge/redis-github-action@v2 47 | - name: Setup Node.js 48 | uses: actions/setup-node@v6 49 | with: 50 | node-version: lts/* 51 | - name: Setup PNPM 52 | uses: pnpm/action-setup@v4 53 | with: 54 | version: latest 55 | run_install: true 56 | - name: Test 57 | run: pnpm test 58 | - name: Report 59 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 60 | - name: Coverage 61 | uses: coverallsapp/github-action@main 62 | with: 63 | github-token: ${{ secrets.GITHUB_TOKEN }} 64 | - name: Release 65 | env: 66 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 67 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | run: | 69 | git config --global user.email ${{ secrets.GIT_EMAIL }} 70 | git config --global user.name ${{ secrets.GIT_USERNAME }} 71 | git pull origin master 72 | pnpm run release 73 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('assert') 4 | const microtime = require('./microtime') 5 | 6 | const ratelimiter = { 7 | numberOfKeys: 1, 8 | lua: ` 9 | local key = KEYS[1] 10 | local now = tonumber(ARGV[1]) 11 | local duration = tonumber(ARGV[2]) 12 | local max = tonumber(ARGV[3]) 13 | local peek = ARGV[4] == '1' 14 | local start = now - duration 15 | 16 | -- Check if the key exists 17 | local exists = redis.call('EXISTS', key) 18 | 19 | local count = 0 20 | local oldest = now 21 | 22 | if exists == 1 then 23 | -- Remove expired entries based on the current duration 24 | redis.call('ZREMRANGEBYSCORE', key, 0, start) 25 | 26 | -- Get count 27 | count = redis.call('ZCARD', key) 28 | 29 | -- Get oldest timestamp if we have entries 30 | if count > 0 then 31 | local oldest_result = redis.call('ZRANGE', key, 0, 0) 32 | oldest = tonumber(oldest_result[1]) 33 | end 34 | end 35 | 36 | -- Calculate remaining (before adding current request if not peeking) 37 | local remaining = max - count 38 | 39 | -- Early return if already at limit 40 | if remaining <= 0 then 41 | local resetMicro = oldest + duration 42 | return {0, math.floor(resetMicro / 1000), max} 43 | end 44 | 45 | -- If not peeking, add current request with current timestamp 46 | if not peek then 47 | redis.call('ZADD', key, now, now) 48 | end 49 | 50 | -- Calculate reset time and handle trimming if needed 51 | local resetMicro 52 | 53 | -- Only perform trim if we're at or over max and not peeking 54 | if not peek and count >= max then 55 | -- Get the entry at position -max for reset time calculation 56 | local oldest_in_range_result = redis.call('ZRANGE', key, -max, -max) 57 | local oldestInRange = oldest 58 | 59 | if #oldest_in_range_result > 0 then 60 | oldestInRange = tonumber(oldest_in_range_result[1]) 61 | end 62 | 63 | -- Trim the set 64 | redis.call('ZREMRANGEBYRANK', key, 0, -(max + 1)) 65 | 66 | -- Calculate reset time based on the entry at position -max 67 | resetMicro = oldestInRange + duration 68 | else 69 | -- We're under the limit or peeking, use the oldest entry for reset time 70 | resetMicro = oldest + duration 71 | end 72 | 73 | -- Set expiration using the provided duration (only if not peeking) 74 | if not peek then 75 | redis.call('PEXPIRE', key, duration) 76 | end 77 | 78 | return {remaining, math.floor(resetMicro / 1000), max} 79 | ` 80 | } 81 | 82 | class Limiter { 83 | constructor ({ id, db, max = 2500, duration = 3600000, namespace = 'limit' }) { 84 | assert(db, 'db required') 85 | this.db = db 86 | this.id = id 87 | this.max = max 88 | this.duration = duration 89 | this.namespace = namespace 90 | if (!this.db.ratelimiter) { 91 | this.db.defineCommand('ratelimiter', ratelimiter) 92 | } 93 | } 94 | 95 | async get ({ id = this.id, max = this.max, duration = this.duration, peek = false } = {}) { 96 | assert(id, 'id required') 97 | assert(max, 'max required') 98 | assert(duration, 'duration required') 99 | 100 | const result = await this.db.ratelimiter( 101 | `${this.namespace}:${id}`, 102 | microtime.now(), 103 | duration, 104 | max, 105 | peek ? '1' : '0' 106 | ) 107 | 108 | return { 109 | remaining: result[0], 110 | reset: Math.floor(result[1]), 111 | total: result[2] 112 | } 113 | } 114 | } 115 | 116 | module.exports = Limiter 117 | module.exports.defineCommand = { ratelimiter } 118 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-ratelimiter", 3 | "description": "Rate limit made simple, easy, async.", 4 | "homepage": "https://github.com/microlinkhq/async-ratelimiter", 5 | "version": "1.6.4", 6 | "main": "src/index.js", 7 | "author": { 8 | "email": "hello@microlink.io", 9 | "name": "microlink.io", 10 | "url": "https://microlink.io" 11 | }, 12 | "contributors": [ 13 | { 14 | "name": "Kiko Beats", 15 | "email": "josefrancisco.verdu@gmail.com" 16 | }, 17 | { 18 | "name": "3imed-jaberi", 19 | "email": "imed_jebari@hotmail.fr" 20 | }, 21 | { 22 | "name": "Marcus Poehls", 23 | "email": "marcus.poehls@gmail.com" 24 | }, 25 | { 26 | "name": "Ayan Yenbekbay", 27 | "email": "ayan.yenb@gmail.com" 28 | }, 29 | { 30 | "name": "Andrew", 31 | "email": "5755685+arnidan@users.noreply.github.com" 32 | }, 33 | { 34 | "name": "amanda", 35 | "email": "amandalucis@gmail.com" 36 | }, 37 | { 38 | "name": "Loren", 39 | "email": "loren138@users.noreply.github.com" 40 | }, 41 | { 42 | "name": "Nico Kaiser", 43 | "email": "nico@kaiser.me" 44 | }, 45 | { 46 | "name": "offirmo", 47 | "email": "offirmo.net@gmail.com" 48 | }, 49 | { 50 | "name": "kornel-kedzierski", 51 | "email": "pl.kornel@gmail.com" 52 | } 53 | ], 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/microlinkhq/async-ratelimiter.git" 57 | }, 58 | "bugs": { 59 | "url": "https://github.com/microlinkhq/async-ratelimiter/issues" 60 | }, 61 | "keywords": [ 62 | "async", 63 | "limit", 64 | "limiter", 65 | "promise", 66 | "rate", 67 | "ratelimit" 68 | ], 69 | "devDependencies": { 70 | "@commitlint/cli": "latest", 71 | "@commitlint/config-conventional": "latest", 72 | "@ksmithut/prettier-standard": "latest", 73 | "c8": "latest", 74 | "ci-publish": "latest", 75 | "finepack": "latest", 76 | "git-authors-cli": "latest", 77 | "github-generate-release": "latest", 78 | "ioredis": "latest", 79 | "mocha": "latest", 80 | "nano-staged": "latest", 81 | "should": "latest", 82 | "simple-git-hooks": "latest", 83 | "standard": "latest", 84 | "standard-version": "latest" 85 | }, 86 | "engines": { 87 | "node": ">= 8" 88 | }, 89 | "files": [ 90 | "index.d.ts", 91 | "src" 92 | ], 93 | "scripts": { 94 | "clean": "rm -rf node_modules", 95 | "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true", 96 | "lint": "standard", 97 | "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)", 98 | "pretest": "npm run lint", 99 | "release": "standard-version -a", 100 | "release:github": "github-generate-release", 101 | "release:tags": "git push --follow-tags origin HEAD:master", 102 | "test": "c8 mocha --exit" 103 | }, 104 | "license": "MIT", 105 | "commitlint": { 106 | "extends": [ 107 | "@commitlint/config-conventional" 108 | ], 109 | "rules": { 110 | "body-max-line-length": [ 111 | 0 112 | ] 113 | } 114 | }, 115 | "nano-staged": { 116 | "*.js": [ 117 | "prettier-standard", 118 | "standard --fix" 119 | ], 120 | "package.json": [ 121 | "finepack" 122 | ] 123 | }, 124 | "simple-git-hooks": { 125 | "commit-msg": "npx commitlint --edit", 126 | "pre-commit": "npx nano-staged" 127 | }, 128 | "standard": { 129 | "env": [ 130 | "mocha" 131 | ] 132 | }, 133 | "typings": "./index.d.ts" 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | microlink logo 3 | microlink logo 4 |
5 |
6 |
7 | 8 | ![Last version](https://img.shields.io/github/tag/microlinkhq/async-ratelimiter.svg?style=flat-square) 9 | [![Coverage Status](https://img.shields.io/coveralls/microlinkhq/async-ratelimiter.svg?style=flat-square)](https://coveralls.io/github/microlinkhq/async-ratelimiter) 10 | [![NPM Status](https://img.shields.io/npm/dm/async-ratelimiter.svg?style=flat-square)](https://www.npmjs.org/package/async-ratelimiter) 11 | 12 | > Rate limit made simple, easy, async. Based on [ratelimiter](https://github.com/tj/node-ratelimiter). 13 | 14 | ## Install 15 | 16 | ```bash 17 | $ npm install async-ratelimiter --save 18 | ``` 19 | 20 | ## Usage 21 | 22 | The most straightforward way to use the rate limiter: 23 | 24 | ```js 25 | 'use strict' 26 | 27 | const RateLimiter = require('async-ratelimiter') 28 | const { getClientIp } = require('request-ip') 29 | const Redis = require('ioredis') 30 | 31 | const rateLimiter = new RateLimiter({ 32 | db: new Redis() 33 | }) 34 | 35 | const apiQuota = async (req, res, next) => { 36 | const clientIp = getClientIp(req) 37 | const limit = await rateLimiter.get({ id: clientIp }) 38 | 39 | if (!res.writableEnded) { 40 | res.setHeader('X-Rate-Limit-Limit', limit.total) 41 | res.setHeader('X-Rate-Limit-Remaining', Math.max(0, limit.remaining - 1)) 42 | res.setHeader('X-Rate-Limit-Reset', limit.reset) 43 | } 44 | 45 | return !limit.remaining 46 | ? sendFail({ 47 | req, 48 | res, 49 | code: HTTPStatus.TOO_MANY_REQUESTS, 50 | message: MESSAGES.RATE_LIMIT_EXCEDEED() 51 | }) 52 | : next(req, res) 53 | } 54 | ``` 55 | For scenarios where you want to check the limit status before consuming a request, you should to pass `{ peek: true }`: 56 | 57 | ```js 58 | const apiQuota = async (req, res, next) => { 59 | const clientIp = getClientIp(req) 60 | 61 | // Check rate limit status without consuming a request 62 | const status = await rateLimiter.get({ id: clientIp, peek: true }) 63 | 64 | if (status.remaining === 0) { 65 | return sendFail({ 66 | req, 67 | res, 68 | code: HTTPStatus.TOO_MANY_REQUESTS, 69 | message: MESSAGES.RATE_LIMIT_EXCEDEED() 70 | }) 71 | } 72 | 73 | // Consume a request 74 | const limit = await rateLimiter.get({ id: clientIp }) 75 | 76 | if (!res.writableEnded) { 77 | res.setHeader('X-Rate-Limit-Limit', limit.total) 78 | res.setHeader('X-Rate-Limit-Remaining', limit.remaining) 79 | res.setHeader('X-Rate-Limit-Reset', limit.reset) 80 | } 81 | 82 | return next(req, res) 83 | } 84 | ``` 85 | 86 | 87 | 88 | ## API 89 | 90 | ### constructor(options) 91 | 92 | It creates an rate limiter instance. 93 | 94 | #### options 95 | 96 | ##### db 97 | 98 | _Required_
99 | Type: `object` 100 | 101 | The redis connection instance. 102 | 103 | ##### max 104 | 105 | Type: `number`
106 | Default: `2500` 107 | 108 | The maximum number of requests within `duration`. 109 | 110 | ##### duration 111 | 112 | Type: `number`
113 | Default: `3600000` 114 | 115 | How long keep records of requests in milliseconds. 116 | 117 | ##### namespace 118 | 119 | Type: `string`
120 | Default: `'limit'` 121 | 122 | The prefix used for compound the key. 123 | 124 | ##### id 125 | 126 | Type: `string` 127 | 128 | The identifier to limit against (typically a user id). 129 | 130 | You can pass this value using when you use `.get` method as well. 131 | 132 | ### .get(options) 133 | 134 | Given an `id`, returns a Promise with the status of the limit with the following structure: 135 | 136 | - `total`: `max` value. 137 | - `remaining`: number of calls left in current `duration` without decreasing current `get`. 138 | - `reset`: time since epoch in seconds that the rate limiting period will end (or already ended). 139 | 140 | #### options 141 | 142 | ##### id 143 | 144 | Type: `string` 145 | Default: `this.id` 146 | 147 | The identifier to limit against (typically a user id). 148 | 149 | ##### max 150 | 151 | Type: `number`
152 | Default: `this.max` 153 | 154 | The maximum number of requests within `duration`. If provided, it overrides the default `max` value. This is useful for custom limits that differ between IDs. 155 | 156 | ##### duration 157 | 158 | Type: `number`
159 | Default: `this.duration` 160 | 161 | How long keep records of requests in milliseconds. If provided, it overrides the default `duration` value. 162 | 163 | ##### peek 164 | 165 | Type: `boolean`
166 | Default: `false` 167 | 168 | When set to `true`, returns the current rate limit status **without consuming a request**. This is useful for checking the current rate limit status before deciding whether to proceed with an operation. 169 | 170 | ### defineCommand 171 | 172 | It provides the command definition so you can load it into any [ioredis](https://github.com/redis/ioredis) instance: 173 | 174 | ```js 175 | const Redis = require('ioredis') 176 | const redis = new Redis(uri, { 177 | scripts: { ...require('async-ratelimiter').defineCommand } 178 | }) 179 | ``` 180 | 181 | ## Related 182 | 183 | - [express-slow-down](https://github.com/nfriedly/express-slow-down) – Slow down repeated requests; use as an alternative (or addition) to express-rate-limit. 184 | 185 | ## License 186 | 187 | **async-ratelimiter** © [microlink.io](https://microlink.io), released under the [MIT](https://github.com/microlinkhq/async-ratelimiter/blob/master/LICENSE.md) License.
188 | Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/microlinkhq/async-ratelimiter/contributors). 189 | 190 | > [microlink.io](https://microlink.io) · GitHub [microlink.io](https://github.com/microlinkhq) · X [@microlinkhq](https://x.com/microlinkhq) 191 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const RateLimiter = require('..') 4 | const Redis = require('ioredis') 5 | const { performance } = require('perf_hooks') 6 | 7 | // Configuration 8 | const CONFIG = { 9 | // Benchmark settings 10 | iterations: 100000, 11 | concurrency: 100, 12 | warmup: 1000, 13 | // Rate limiter settings 14 | maxRequests: 100, 15 | duration: 60, // seconds 16 | // Distribution settings 17 | ipCount: 200, 18 | hotIpPercentage: 20, // percentage of requests that hit "hot" IPs 19 | hotIpCount: 10, 20 | // Redis settings 21 | redisOptions: { 22 | host: 'localhost', 23 | port: 6379 24 | } 25 | } 26 | 27 | // Generate test IPs 28 | function generateIps () { 29 | const ips = [] 30 | // Regular IPs 31 | for (let i = 0; i < CONFIG.ipCount; i++) { 32 | ips.push(`192.168.1.${i % 255}`) 33 | } 34 | // Hot IPs (will be rate limited) 35 | const hotIps = [] 36 | for (let i = 0; i < CONFIG.hotIpCount; i++) { 37 | hotIps.push(`10.0.0.${i % 255}`) 38 | } 39 | 40 | return { ips, hotIps } 41 | } 42 | 43 | // Select an IP based on our distribution 44 | function selectIp (ips, hotIps) { 45 | // Determine if this request should use a hot IP 46 | const useHotIp = Math.random() * 100 < CONFIG.hotIpPercentage 47 | 48 | if (useHotIp) { 49 | return hotIps[Math.floor(Math.random() * hotIps.length)] 50 | } else { 51 | return ips[Math.floor(Math.random() * ips.length)] 52 | } 53 | } 54 | 55 | // Run the benchmark 56 | async function runBenchmark () { 57 | console.log('=== Async RateLimiter Benchmark ===') 58 | console.log(`Iterations: ${CONFIG.iterations}`) 59 | console.log(`Concurrency: ${CONFIG.concurrency}`) 60 | console.log(`Rate limit: ${CONFIG.maxRequests} requests per ${CONFIG.duration} seconds`) 61 | console.log( 62 | `IP distribution: ${CONFIG.ipCount} IPs (${CONFIG.hotIpCount} hot IPs receiving ${CONFIG.hotIpPercentage}% of traffic)` 63 | ) 64 | console.log(`Redis: ${CONFIG.redisOptions.host}:${CONFIG.redisOptions.port}`) 65 | console.log('-----------------------------------') 66 | 67 | try { 68 | // Connect to Redis using ioredis 69 | const redis = new Redis(CONFIG.redisOptions) 70 | 71 | // Create rate limiter 72 | const limiter = new RateLimiter({ 73 | db: redis, 74 | max: CONFIG.maxRequests, 75 | duration: CONFIG.duration 76 | }) 77 | 78 | // Generate IPs 79 | const { ips, hotIps } = generateIps() 80 | 81 | // Warmup 82 | console.log(`Warming up with ${CONFIG.warmup} requests...`) 83 | for (let i = 0; i < CONFIG.warmup; i++) { 84 | const ip = selectIp(ips, hotIps) 85 | await limiter.get({ id: ip }) 86 | } 87 | 88 | // Reset Redis for accurate measurement 89 | console.log('Resetting Redis before benchmark...') 90 | await redis.flushdb() 91 | 92 | // Wait a moment for Redis to settle 93 | await new Promise(resolve => setTimeout(resolve, 1000)) 94 | 95 | // Run benchmark 96 | console.log(`Running ${CONFIG.iterations} iterations...`) 97 | 98 | const results = { 99 | totalTime: 0, 100 | successCount: 0, 101 | limitedCount: 0, 102 | latencies: [] 103 | } 104 | 105 | const start = performance.now() 106 | 107 | // Create batches for concurrency 108 | const batchSize = Math.min(CONFIG.concurrency, CONFIG.iterations) 109 | const batches = Math.ceil(CONFIG.iterations / batchSize) 110 | 111 | for (let b = 0; b < batches; b++) { 112 | const currentBatchSize = Math.min(batchSize, CONFIG.iterations - b * batchSize) 113 | const promises = [] 114 | 115 | for (let i = 0; i < currentBatchSize; i++) { 116 | const ip = selectIp(ips, hotIps) 117 | 118 | promises.push( 119 | (async () => { 120 | const requestStart = performance.now() 121 | const limit = await limiter.get({ id: ip }) 122 | const requestEnd = performance.now() 123 | 124 | results.latencies.push(requestEnd - requestStart) 125 | 126 | if (limit.remaining > 0) { 127 | results.successCount++ 128 | } else { 129 | results.limitedCount++ 130 | } 131 | })() 132 | ) 133 | } 134 | 135 | await Promise.all(promises) 136 | 137 | // Show progress 138 | if (batches > 10 && b % Math.floor(batches / 10) === 0) { 139 | const progress = Math.floor((b / batches) * 100) 140 | console.log(`Progress: ${progress}%`) 141 | } 142 | } 143 | 144 | const end = performance.now() 145 | results.totalTime = end - start 146 | 147 | // Calculate statistics 148 | results.totalRequests = results.successCount + results.limitedCount 149 | results.limitedPercentage = (results.limitedCount / results.totalRequests) * 100 150 | results.averageLatency = results.latencies.reduce((a, b) => a + b, 0) / results.latencies.length 151 | 152 | // Sort latencies for percentiles 153 | results.latencies.sort((a, b) => a - b) 154 | results.p50Latency = results.latencies[Math.floor(results.latencies.length * 0.5)] 155 | results.p95Latency = results.latencies[Math.floor(results.latencies.length * 0.95)] 156 | results.p99Latency = results.latencies[Math.floor(results.latencies.length * 0.99)] 157 | 158 | results.requestsPerSecond = (results.totalRequests / results.totalTime) * 1000 159 | 160 | // Print results 161 | console.log('\n=== Benchmark Results ===') 162 | console.log(`Total requests: ${results.totalRequests}`) 163 | console.log(`Successful requests: ${results.successCount}`) 164 | console.log( 165 | `Rate limited requests: ${results.limitedCount} (${results.limitedPercentage.toFixed(2)}%)` 166 | ) 167 | console.log(`Total time: ${results.totalTime.toFixed(2)}ms`) 168 | console.log(`Requests per second: ${results.requestsPerSecond.toFixed(2)}`) 169 | console.log('\nLatency:') 170 | console.log(` Average: ${results.averageLatency.toFixed(2)}ms`) 171 | console.log(` p50: ${results.p50Latency.toFixed(2)}ms`) 172 | console.log(` p95: ${results.p95Latency.toFixed(2)}ms`) 173 | console.log(` p99: ${results.p99Latency.toFixed(2)}ms`) 174 | 175 | // Clean up 176 | await redis.quit() 177 | } catch (error) { 178 | console.error('Benchmark error:', error) 179 | process.exit(1) 180 | } 181 | } 182 | 183 | // Run the benchmark 184 | runBenchmark().catch(err => { 185 | console.error('Unexpected error:', err) 186 | process.exit(1) 187 | }) 188 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### 1.6.4 (2025-11-21) 6 | 7 | ### 1.6.3 (2025-11-19) 8 | 9 | ### 1.6.2 (2025-11-15) 10 | 11 | ### 1.6.1 (2025-07-02) 12 | 13 | ## 1.6.0 (2025-07-02) 14 | 15 | 16 | ### Features 17 | 18 | * add `{ peek: true }` ([#54](https://github.com/microlinkhq/async-ratelimiter/issues/54)) ([362a65a](https://github.com/microlinkhq/async-ratelimiter/commit/362a65a80fcff5f15440f794a61de01feacb93c7)) 19 | 20 | ### 1.5.2 (2025-04-10) 21 | 22 | ### 1.5.1 (2025-04-10) 23 | 24 | ## 1.5.0 (2025-04-10) 25 | 26 | 27 | ### Features 28 | 29 | * add defineCommand ([#51](https://github.com/microlinkhq/async-ratelimiter/issues/51)) ([db75ce1](https://github.com/microlinkhq/async-ratelimiter/commit/db75ce140d665ebc216f515d13260c01ae728259)) 30 | 31 | ## 1.4.0 (2025-04-08) 32 | 33 | 34 | ### Features 35 | 36 | * implemented using lua ([#49](https://github.com/microlinkhq/async-ratelimiter/issues/49)) ([e28c9fc](https://github.com/microlinkhq/async-ratelimiter/commit/e28c9fc72ae232fb253b10053c476ffb7f6bb0cd)) 37 | 38 | ### 1.3.11 (2024-05-07) 39 | 40 | ### 1.3.10 (2024-04-09) 41 | 42 | ### 1.3.9 (2024-02-08) 43 | 44 | ### 1.3.8 (2024-01-12) 45 | 46 | ### 1.3.13 (2023-12-12) 47 | 48 | ### 1.3.12 (2023-10-23) 49 | 50 | ### 1.3.11 (2023-09-07) 51 | 52 | ### 1.3.10 (2023-09-05) 53 | 54 | ### 1.3.9 (2023-07-27) 55 | 56 | ### 1.3.8 (2023-05-13) 57 | 58 | ### 1.3.7 (2023-03-03) 59 | 60 | ### 1.3.6 (2023-02-14) 61 | 62 | ### 1.3.5 (2022-10-22) 63 | 64 | ### 1.3.4 (2022-09-30) 65 | 66 | ### 1.3.3 (2022-08-18) 67 | 68 | ### 1.3.2 (2022-08-18) 69 | 70 | ### 1.3.1 (2022-05-15) 71 | 72 | ## [1.3.0](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.8...v1.3.0) (2021-07-17) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * linter ([7f20976](https://github.com/microlinkhq/async-ratelimiter/commit/7f209764834790d0973913277f3d467b919044bb)) 78 | 79 | ### [1.2.8](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.7...v1.2.8) (2020-06-01) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * linter ([7619c7c](https://github.com/microlinkhq/async-ratelimiter/commit/7619c7c596d905b308ab3a52abdd1a1029323024)) 85 | 86 | ### [1.2.7](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.6...v1.2.7) (2019-07-11) 87 | 88 | 89 | 90 | ### [1.2.6](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.4...v1.2.6) (2019-07-10) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * typo ([becb50c](https://github.com/microlinkhq/async-ratelimiter/commit/becb50c)) 96 | 97 | 98 | ### Build System 99 | 100 | * update travis ([4f8a99d](https://github.com/microlinkhq/async-ratelimiter/commit/4f8a99d)) 101 | 102 | 103 | 104 | ### [1.2.5](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.4...v1.2.5) (2019-06-19) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * typo ([becb50c](https://github.com/microlinkhq/async-ratelimiter/commit/becb50c)) 110 | 111 | 112 | ### Build System 113 | 114 | * update travis ([4f8a99d](https://github.com/microlinkhq/async-ratelimiter/commit/4f8a99d)) 115 | 116 | 117 | 118 | ### [1.2.4](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.3...v1.2.4) (2019-06-12) 119 | 120 | 121 | ### Build System 122 | 123 | * meta tweaks ([1da925b](https://github.com/microlinkhq/async-ratelimiter/commit/1da925b)) 124 | 125 | 126 | 127 | ## [1.2.3](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.2...v1.2.3) (2019-04-21) 128 | 129 | 130 | ### Bug Fixes 131 | 132 | * specify parse int radix ([a8b29f0](https://github.com/microlinkhq/async-ratelimiter/commit/a8b29f0)) 133 | 134 | 135 | 136 | 137 | ## [1.2.2](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.1...v1.2.2) (2019-04-04) 138 | 139 | 140 | ### Bug Fixes 141 | 142 | * **typescript-declarations:** missing decrease param at GetOptions interface ([a22b9af](https://github.com/microlinkhq/async-ratelimiter/commit/a22b9af)) 143 | 144 | 145 | 146 | 147 | ## [1.2.1](https://github.com/microlinkhq/async-ratelimiter/compare/v1.2.0...v1.2.1) (2019-03-27) 148 | 149 | 150 | 151 | 152 | # [1.2.0](https://github.com/microlinkhq/async-ratelimiter/compare/v1.1.4...v1.2.0) (2019-03-27) 153 | 154 | 155 | 156 | 157 | ## [1.1.4](https://github.com/microlinkhq/async-ratelimiter/compare/v1.1.3...v1.1.4) (2019-03-27) 158 | 159 | 160 | 161 | 162 | ## 1.1.3 (2018-12-17) 163 | 164 | 165 | 166 | 167 | ## 1.1.2 (2018-07-23) 168 | 169 | 170 | 171 | 172 | ## 1.1.1 (2018-07-18) 173 | 174 | 175 | 176 | 177 | # 1.1.0 (2018-07-16) 178 | 179 | 180 | 181 | 182 | ## 1.0.2 (2018-06-18) 183 | 184 | 185 | 186 | 187 | ## 1.0.1 (2018-06-17) 188 | 189 | 190 | 191 | 192 | # 1.0.0 (2018-06-17) 193 | 194 | 195 | 196 | 197 | ## 1.1.2 (2018-07-23) 198 | 199 | * Closes #6 ([6614d30](https://github.com/microlinkhq/async-ratelimiter/commit/6614d30)), closes [#6](https://github.com/microlinkhq/async-ratelimiter/issues/6) 200 | * old data test and delete the old ones by score ([31beaed](https://github.com/microlinkhq/async-ratelimiter/commit/31beaed)) 201 | 202 | 203 | 204 | 205 | ## 1.1.1 (2018-07-18) 206 | 207 | * Remove unnecessary check ([f09bff9](https://github.com/microlinkhq/async-ratelimiter/commit/f09bff9)) 208 | 209 | 210 | 211 | 212 | # 1.1.0 (2018-07-16) 213 | 214 | * allow custom duration in .get ([f3edcf2](https://github.com/microlinkhq/async-ratelimiter/commit/f3edcf2)) 215 | * allow custom limits ([a94f501](https://github.com/microlinkhq/async-ratelimiter/commit/a94f501)) 216 | * keep responsibility of tests ([2fd9903](https://github.com/microlinkhq/async-ratelimiter/commit/2fd9903)) 217 | 218 | 219 | 220 | 221 | ## 1.0.2 (2018-06-18) 222 | 223 | * Remove badge, add contributors ([c252c7b](https://github.com/microlinkhq/async-ratelimiter/commit/c252c7b)) 224 | * Update README.md ([5b5d374](https://github.com/microlinkhq/async-ratelimiter/commit/5b5d374)) 225 | * Update README.md ([e2f1868](https://github.com/microlinkhq/async-ratelimiter/commit/e2f1868)) 226 | * Update README.md ([68130c2](https://github.com/microlinkhq/async-ratelimiter/commit/68130c2)) 227 | 228 | 229 | 230 | 231 | ## 1.0.1 (2018-06-17) 232 | 233 | * Add namespace doc ([ddfcd03](https://github.com/microlinkhq/async-ratelimiter/commit/ddfcd03)) 234 | * Avoid mutate state ([536d6a2](https://github.com/microlinkhq/async-ratelimiter/commit/536d6a2)) 235 | * Fix get id from param ([c4c18fe](https://github.com/microlinkhq/async-ratelimiter/commit/c4c18fe)) 236 | * Update precondition ([79d500d](https://github.com/microlinkhq/async-ratelimiter/commit/79d500d)) 237 | 238 | 239 | 240 | 241 | # 1.0.0 (2018-06-17) 242 | 243 | * First commit ([d9cafec](https://github.com/microlinkhq/async-ratelimiter/commit/d9cafec)) 244 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint handle-callback-err: "off" */ 2 | 3 | 'use strict' 4 | 5 | const should = require('should') 6 | 7 | const { setTimeout } = require('timers/promises') 8 | 9 | const RateLimiter = require('..') 10 | 11 | ;['ioredis'].forEach(function (redisModuleName) { 12 | const redisModule = require(redisModuleName) 13 | const db = require(redisModuleName).createClient() 14 | 15 | describe('Limiter with ' + redisModuleName, function () { 16 | beforeEach(async function () { 17 | const keys = await db.keys('limit:*') 18 | await Promise.all(keys.map(key => db.del(key))) 19 | }) 20 | 21 | describe('.total', function () { 22 | it('should represent the total limit per reset period', async function () { 23 | const limit = new RateLimiter({ 24 | max: 5, 25 | id: 'something', 26 | db 27 | }) 28 | const res = await limit.get() 29 | should(res.total).equal(5) 30 | }) 31 | }) 32 | 33 | describe('.remaining', function () { 34 | it('should represent the number of requests remaining in the reset period', async function () { 35 | const limit = new RateLimiter({ 36 | max: 5, 37 | duration: 100000, 38 | id: 'something', 39 | db 40 | }) 41 | 42 | let res 43 | res = await limit.get() 44 | should(res.remaining).equal(5) 45 | res = await limit.get() 46 | should(res.remaining).equal(4) 47 | res = await limit.get() 48 | should(res.remaining).equal(3) 49 | }) 50 | }) 51 | 52 | describe('.reset', function () { 53 | it('should represent the next reset time in UTC epoch seconds', async function () { 54 | const limit = new RateLimiter({ 55 | max: 5, 56 | duration: 60000, 57 | id: 'something', 58 | db 59 | }) 60 | const res = await limit.get() 61 | const left = res.reset - Date.now() / 1000 62 | should(left).be.below(60).and.be.greaterThan(0) 63 | }) 64 | }) 65 | 66 | describe('when the limit is exceeded', function () { 67 | it('should retain .remaining at 0', async function () { 68 | const limit = new RateLimiter({ 69 | max: 2, 70 | id: 'something', 71 | db 72 | }) 73 | 74 | let res 75 | res = await limit.get() 76 | should(res.remaining).equal(2) 77 | res = await limit.get() 78 | should(res.remaining).equal(1) 79 | res = await limit.get() 80 | should(res.remaining).equal(0) 81 | }) 82 | 83 | it('should return an increasing reset time after each call', async function () { 84 | const limit = new RateLimiter({ 85 | max: 2, 86 | id: 'something', 87 | db 88 | }) 89 | 90 | const { reset: originalReset } = await limit.get() 91 | await limit.get() 92 | await setTimeout(200) 93 | await limit.get() 94 | await setTimeout(200) 95 | await limit.get() 96 | await setTimeout(200) 97 | await limit.get() 98 | const { reset } = await limit.get() 99 | should(reset).be.greaterThanOrEqual(originalReset) 100 | }) 101 | }) 102 | 103 | describe('when the duration is exceeded', function () { 104 | it('should reset', async function () { 105 | this.timeout(5000) 106 | const limit = new RateLimiter({ 107 | duration: 2000, 108 | max: 2, 109 | id: 'something', 110 | db 111 | }) 112 | 113 | let res 114 | res = await limit.get() 115 | should(res.remaining).equal(2) 116 | res = await limit.get() 117 | should(res.remaining).equal(1) 118 | 119 | await setTimeout(3000) 120 | res = await limit.get() 121 | const left = res.reset - Date.now() / 1000 122 | should(left).be.below(2) 123 | should(res.remaining).equal(2) 124 | }) 125 | }) 126 | 127 | describe('when multiple successive calls are made', function () { 128 | describe('.peek', function () { 129 | it('should return the current status without incrementing the count', async function () { 130 | const limit = new RateLimiter({ 131 | max: 3, 132 | duration: 10000, 133 | id: 'peektest', 134 | db 135 | }) 136 | 137 | // Initial peek, should be full 138 | let res = await limit.get({ peek: true }) 139 | should(res.remaining).equal(3) 140 | should(res.total).equal(3) 141 | // After a get, remaining should decrease 142 | await limit.get() 143 | res = await limit.get({ peek: true }) 144 | should(res.remaining).equal(2) 145 | // After another get, remaining should decrease again 146 | await limit.get() 147 | res = await limit.get({ peek: true }) 148 | should(res.remaining).equal(1) 149 | // Peek should not decrement 150 | res = await limit.get({ peek: true }) 151 | should(res.remaining).equal(1) 152 | }) 153 | 154 | it('should support custom options', async function () { 155 | const limit = new RateLimiter({ 156 | max: 2, 157 | duration: 5000, 158 | id: 'peekcustom', 159 | db 160 | }) 161 | let res = await limit.get({ max: 5, peek: true }) 162 | should(res.remaining).equal(5) 163 | should(res.total).equal(5) 164 | res = await limit.get({ duration: 10000, peek: true }) 165 | should(res.total).equal(2) // max from constructor 166 | }) 167 | }) 168 | it('the next calls should not create again the limiter in Redis', async function () { 169 | const limit = new RateLimiter({ 170 | duration: 10000, 171 | max: 2, 172 | id: 'something', 173 | db 174 | }) 175 | 176 | let res 177 | res = await limit.get() 178 | should(res.remaining).equal(2) 179 | res = await limit.get() 180 | should(res.remaining).equal(1) 181 | }) 182 | it('updating the count should keep all TTLs in sync', async function () { 183 | const limit = new RateLimiter({ 184 | duration: 10000, 185 | max: 2, 186 | id: 'something', 187 | db 188 | }) 189 | await limit.get() // All good here. 190 | await limit.get() 191 | 192 | const res = await db 193 | .multi() 194 | .pttl(['limit:something:count']) 195 | .pttl(['limit:something:limit']) 196 | .pttl(['limit:something:reset']) 197 | .exec() 198 | 199 | const ttlCount = typeof res[0] === 'number' ? res[0] : res[0][1] 200 | const ttlLimit = typeof res[1] === 'number' ? res[1] : res[1][1] 201 | const ttlReset = typeof res[2] === 'number' ? res[2] : res[2][1] 202 | ttlLimit.should.equal(ttlCount) 203 | ttlReset.should.equal(ttlCount) 204 | }) 205 | }) 206 | 207 | describe('when multiple concurrent clients modify the limit', function () { 208 | const clientsCount = 7 209 | const max = 5 210 | let left = max 211 | const limits = [] 212 | 213 | for (let i = 0; i < clientsCount; ++i) { 214 | limits.push( 215 | new RateLimiter({ 216 | duration: 10000, 217 | max, 218 | id: 'something', 219 | db: redisModule.createClient() 220 | }) 221 | ) 222 | } 223 | 224 | it('should prevent race condition and properly set the expected value', async function () { 225 | const responses = [] 226 | 227 | function complete () { 228 | responses.push(arguments) 229 | 230 | if (responses.length === clientsCount) { 231 | // If there were any errors, report. 232 | const err = responses.some(function (res) { 233 | return res[0] 234 | }) 235 | 236 | if (err) { 237 | throw err 238 | } else { 239 | responses.sort(function (r1, r2) { 240 | return r1[1].remaining < r2[1].remaining 241 | }) 242 | responses.forEach(function (res) { 243 | should(res[1].remaining).equal(left < 0 ? 0 : left) 244 | left-- 245 | }) 246 | 247 | for (let i = max - 1; i < clientsCount; ++i) { 248 | should(responses[i][1].remaining).equal(0) 249 | } 250 | } 251 | } 252 | } 253 | 254 | // Warm up and prepare the data. 255 | const res = await limits[0].get() 256 | should(res.remaining).equal(left--) 257 | 258 | // Simulate multiple concurrent requests. 259 | limits.forEach(function (limit) { 260 | limit.get(complete) 261 | }) 262 | }) 263 | }) 264 | 265 | describe('when limiter is called in parallel by multiple clients', function () { 266 | let max = 6 267 | 268 | const limiter = new RateLimiter({ 269 | duration: 10000, 270 | max, 271 | id: 'asyncsomething', 272 | db: redisModule.createClient() 273 | }) 274 | 275 | it('should set the count properly without race conditions', async function () { 276 | const times = Array.from({ length: max }, (value, index) => index) 277 | const limits = await Promise.all(times.map(() => limiter.get())) 278 | limits.forEach(function (limit) { 279 | should(limit.remaining).equal(max--) 280 | }) 281 | }) 282 | }) 283 | 284 | describe('when get is called with max option', function () { 285 | it('should represent the custom total limit per reset period', async function () { 286 | const limit = new RateLimiter({ 287 | max: 5, 288 | id: 'something', 289 | db 290 | }) 291 | 292 | let res 293 | res = await limit.get({ max: 10 }) 294 | should(res.remaining).equal(10) 295 | res = await limit.get({ max: 10 }) 296 | should(res.remaining).equal(9) 297 | res = await limit.get({ max: 10 }) 298 | should(res.remaining).equal(8) 299 | }) 300 | 301 | it('should take the default limit as fallback', async function () { 302 | const limit = new RateLimiter({ 303 | max: 5, 304 | id: 'something', 305 | db 306 | }) 307 | 308 | let res 309 | res = await limit.get({ max: 10 }) 310 | should(res.remaining).equal(10) 311 | res = await limit.get({ max: 10 }) 312 | should(res.remaining).equal(9) 313 | res = await limit.get() 314 | should(res.remaining).equal(3) 315 | res = await limit.get() 316 | should(res.remaining).equal(2) 317 | }) 318 | }) 319 | 320 | describe('when get is called with duration', function () { 321 | it('should reset after custom duration', async function () { 322 | const limit = new RateLimiter({ 323 | db, 324 | max: 5, 325 | id: 'something' 326 | }) 327 | 328 | const res = await limit.get({ duration: 10000 }) 329 | should(res.remaining).equal(5) 330 | const left = res.reset - Date.now() / 1000 331 | should(left).be.below(10) 332 | }) 333 | 334 | it('should take the default duration as fallback', async function () { 335 | const limit = new RateLimiter({ 336 | db, 337 | max: 5, 338 | id: 'something', 339 | duration: 25000 340 | }) 341 | 342 | let res 343 | let left 344 | 345 | res = await limit.get({ duration: 10000 }) 346 | should(res.remaining).equal(5) 347 | left = res.reset - Date.now() / 1000 348 | should(left).be.below(10) 349 | 350 | res = await limit.get() 351 | should(res.remaining).equal(4) 352 | left = res.reset - Date.now() / 1000 353 | should(left).be.below(25).and.be.above(10) 354 | }) 355 | }) 356 | 357 | describe('when having old data earlier then the duration ', function () { 358 | it('the old request data eariler then the duration time should be ignore', async function () { 359 | this.timeout(20000) 360 | const limit = new RateLimiter({ 361 | db, 362 | max: 5, 363 | id: 'something', 364 | duration: 5000 365 | }) 366 | let res = await limit.get() 367 | should(res.remaining).equal(5) 368 | 369 | let times = 6 370 | do { 371 | res = await limit.get() 372 | ;(res.remaining > 0).should.be.true() 373 | await setTimeout(2000) 374 | times-- 375 | } while (times > 0) 376 | }) 377 | }) 378 | }) 379 | }) 380 | --------------------------------------------------------------------------------