From 53b01c993636f12f20c1a4acc70ea55c5f3e580d Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Wed, 18 May 2022 13:45:04 -0400 Subject: [PATCH 01/12] eslint/prettier config. Name change --- .eslintrc.json | 7 +++-- README.md | 2 +- package-lock.json | 64 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 ++--- tsconfig.eslint.json | 4 --- tsconfig.json | 2 +- 6 files changed, 75 insertions(+), 11 deletions(-) delete mode 100644 tsconfig.eslint.json diff --git a/.eslintrc.json b/.eslintrc.json index 6f2a6b8..63dd958 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,9 +15,9 @@ "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", - "project": "./tsconfig.eslint.json" + "project": "./tsconfig.json" }, - "plugins": ["import"], + "plugins": ["import", "prettier"], "rules": { "indent": [ "warn", @@ -25,6 +25,9 @@ { "SwitchCase": 2 } + ], + "prettier/prettier": [ + "error" ] }, "ignorePatterns": ["jest.config.js"] diff --git a/README.md b/README.md index 1e01bd4..0f313c9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# graph-beaver +# GraphQL-Gate A GraphQL rate limiting library using query complexity analysis. diff --git a/package-lock.json b/package-lock.json index 5fed735..2d33199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.0.0", "husky": "^8.0.1", "jest": "^28.1.0", "lint-staged": "^12.4.1", @@ -3877,6 +3878,27 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, + "node_modules/eslint-plugin-prettier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz", + "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0" + }, + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "eslint": ">=7.28.0", + "prettier": ">=2.0.0" + }, + "peerDependenciesMeta": { + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -4049,6 +4071,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -6299,6 +6327,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "28.1.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.0.tgz", @@ -10287,6 +10327,15 @@ } } }, + "eslint-plugin-prettier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz", + "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -10409,6 +10458,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, "fast-glob": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", @@ -12063,6 +12118,15 @@ "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==", "dev": true }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, "pretty-format": { "version": "28.1.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.0.tgz", diff --git a/package.json b/package.json index 610cf8b..e4b739c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "graphql-complexity-limit", + "name": "graphql-gate", "version": "1.0.0", "description": "A GraphQL rate limiting library using query complexity analysis.", "main": "index.js", "scripts": { "test": "jest --passWithNoTests", - "lint": "eslint src", - "lint:fix": "eslint --fix src", + "lint": "eslint src test", + "lint:fix": "eslint --fix src test", "prettier": "prettier --write .", "prepare": "husky install" }, @@ -34,6 +34,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.0.0", "husky": "^8.0.1", "jest": "^28.1.0", "lint-staged": "^12.4.1", diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index e2fd46b..0000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts"] -} diff --git a/tsconfig.json b/tsconfig.json index 0e63588..bb82328 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "typeRoots": ["./@types", "node_modules/@types"], "types": ["node", "jest"] }, - "include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts", "test/**/*.js"], "exclude": ["node_modules", "**/*.spec.ts"] } From c3367cfc9563e8bc0198f5490b35a5e184639ab6 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Wed, 18 May 2022 13:48:30 -0400 Subject: [PATCH 02/12] tsconfig tweak --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index bb82328..5c3f48d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ "strict": true, "removeComments": false, "preserveConstEnums": true, - "outFile": "../../built/local/tsc.js", "sourceMap": true, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], From 3836d66a85526261a36f25df9eafa795d4f84dbe Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 13:58:15 -0400 Subject: [PATCH 03/12] Added redis-mock for testing. Updated RateLimiter and TokenBucket specs. Added new tests --- .eslintrc.json | 8 +- package-lock.json | 211 +++++++++++++++++++++++++- package.json | 5 + src/@types/rateLimit.d.ts | 10 +- src/rateLimiters/tokenBucket.ts | 26 +++- test/rateLimiters/tokenBucket.test.ts | 54 +++++-- 6 files changed, 281 insertions(+), 33 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 63dd958..9acdafc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,13 +19,7 @@ }, "plugins": ["import", "prettier"], "rules": { - "indent": [ - "warn", - 4, - { - "SwitchCase": 2 - } - ], + "prettier/prettier": [ "error" ] diff --git a/package-lock.json b/package-lock.json index 2d33199..4cd57ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,22 @@ { - "name": "graphql-complexity-limit", + "name": "graphql-gate", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "graphql-complexity-limit", + "name": "graphql-gate", "version": "1.0.0", "license": "ISC", + "dependencies": { + "redis": "^4.1.0" + }, "devDependencies": { "@babel/core": "^7.17.12", "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", "@types/jest": "^27.5.1", + "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", "@typescript-eslint/parser": "^5.24.0", "babel-jest": "^28.1.0", @@ -26,6 +30,7 @@ "jest": "^28.1.0", "lint-staged": "^12.4.1", "prettier": "2.6.2", + "redis-mock": "^0.56.3", "ts-jest": "^28.0.2", "typescript": "^4.6.4" } @@ -2343,6 +2348,59 @@ "node": ">= 8" } }, + "node_modules/@redis/bloom": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.0.2.tgz", + "integrity": "sha512-EBw7Ag1hPgFzdznK2PBblc1kdlj5B5Cw3XwI9/oG7tSn85/HKy3X9xHy/8tm/eNXJYHLXHJL/pkwBpFMVVefkw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.1.0.tgz", + "integrity": "sha512-xO9JDIgzsZYDl3EvFhl6LC52DP3q3GCMUer8zHgKV6qSYsq1zB+pZs9+T80VgcRogrlRYhi4ZlfX6A+bHiBAgA==", + "dependencies": { + "cluster-key-slot": "1.1.0", + "generic-pool": "3.8.2", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.0.1.tgz", + "integrity": "sha512-oDE4myMCJOCVKYMygEMWuriBgqlS5FqdWerikMoJxzmmTUErnTRRgmIDa2VcgytACZMFqpAOWDzops4DOlnkfQ==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.3.tgz", + "integrity": "sha512-4X0Qv0BzD9Zlb0edkUoau5c1bInWSICqXAGrpwEltkncUwcxJIGEcVryZhLgb0p/3PkKaLIWkjhHRtLe9yiA7Q==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.0.6.tgz", + "integrity": "sha512-pP+ZQRis5P21SD6fjyCeLcQdps+LuTzp2wdUbzxEmNhleighDDTD5ck8+cYof+WLec4csZX7ks+BuoMw0RaZrA==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.3.tgz", + "integrity": "sha512-OFp0q4SGrTH0Mruf6oFsHGea58u8vS/iI5+NpYdicaM+7BgqBZH8FFvNZ8rYYLrUO/QRqMq72NpXmxLVNcdmjA==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.23.5.tgz", @@ -2555,6 +2613,24 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/redis-mock": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/redis-mock/-/redis-mock-0.17.1.tgz", + "integrity": "sha512-mdt2Kd56fHloc8SnQnXZBrxd/E6jYpj8zXADyI8oZE7quK+P4iwO9PvGGuZdVI+G8DkbQEv0UMrjQlBVJyDS0A==", + "dev": true, + "dependencies": { + "@types/redis": "^2.8.0" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -3350,6 +3426,14 @@ "node": ">=8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4249,6 +4333,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz", + "integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6414,6 +6506,28 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/redis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.1.0.tgz", + "integrity": "sha512-5hvJ8wbzpCCiuN1ges6tx2SAh2XXCY0ayresBmu40/SGusWHFW86TAlIPpbimMX2DFHOX7RN34G2XlPA1Z43zg==", + "dependencies": { + "@redis/bloom": "1.0.2", + "@redis/client": "1.1.0", + "@redis/graph": "1.0.1", + "@redis/json": "1.0.3", + "@redis/search": "1.0.6", + "@redis/time-series": "1.0.3" + } + }, + "node_modules/redis-mock": { + "version": "0.56.3", + "resolved": "https://registry.npmjs.org/redis-mock/-/redis-mock-0.56.3.tgz", + "integrity": "sha512-ynaJhqk0Qf3Qajnwvy4aOjS4Mdf9IBkELWtjd+NYhpiqu4QCNq6Vf3Q7c++XRPGiKiwRj9HWr0crcwy7EiPjYQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -7419,8 +7533,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "1.10.2", @@ -9164,6 +9277,46 @@ "fastq": "^1.6.0" } }, + "@redis/bloom": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.0.2.tgz", + "integrity": "sha512-EBw7Ag1hPgFzdznK2PBblc1kdlj5B5Cw3XwI9/oG7tSn85/HKy3X9xHy/8tm/eNXJYHLXHJL/pkwBpFMVVefkw==", + "requires": {} + }, + "@redis/client": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.1.0.tgz", + "integrity": "sha512-xO9JDIgzsZYDl3EvFhl6LC52DP3q3GCMUer8zHgKV6qSYsq1zB+pZs9+T80VgcRogrlRYhi4ZlfX6A+bHiBAgA==", + "requires": { + "cluster-key-slot": "1.1.0", + "generic-pool": "3.8.2", + "yallist": "4.0.0" + } + }, + "@redis/graph": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.0.1.tgz", + "integrity": "sha512-oDE4myMCJOCVKYMygEMWuriBgqlS5FqdWerikMoJxzmmTUErnTRRgmIDa2VcgytACZMFqpAOWDzops4DOlnkfQ==", + "requires": {} + }, + "@redis/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.3.tgz", + "integrity": "sha512-4X0Qv0BzD9Zlb0edkUoau5c1bInWSICqXAGrpwEltkncUwcxJIGEcVryZhLgb0p/3PkKaLIWkjhHRtLe9yiA7Q==", + "requires": {} + }, + "@redis/search": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.0.6.tgz", + "integrity": "sha512-pP+ZQRis5P21SD6fjyCeLcQdps+LuTzp2wdUbzxEmNhleighDDTD5ck8+cYof+WLec4csZX7ks+BuoMw0RaZrA==", + "requires": {} + }, + "@redis/time-series": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.3.tgz", + "integrity": "sha512-OFp0q4SGrTH0Mruf6oFsHGea58u8vS/iI5+NpYdicaM+7BgqBZH8FFvNZ8rYYLrUO/QRqMq72NpXmxLVNcdmjA==", + "requires": {} + }, "@sinclair/typebox": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.23.5.tgz", @@ -9357,6 +9510,24 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/redis-mock": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/redis-mock/-/redis-mock-0.17.1.tgz", + "integrity": "sha512-mdt2Kd56fHloc8SnQnXZBrxd/E6jYpj8zXADyI8oZE7quK+P4iwO9PvGGuZdVI+G8DkbQEv0UMrjQlBVJyDS0A==", + "dev": true, + "requires": { + "@types/redis": "^2.8.0" + } + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -9907,6 +10078,11 @@ } } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10604,6 +10780,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "generic-pool": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz", + "integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12175,6 +12356,25 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "redis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.1.0.tgz", + "integrity": "sha512-5hvJ8wbzpCCiuN1ges6tx2SAh2XXCY0ayresBmu40/SGusWHFW86TAlIPpbimMX2DFHOX7RN34G2XlPA1Z43zg==", + "requires": { + "@redis/bloom": "1.0.2", + "@redis/client": "1.1.0", + "@redis/graph": "1.0.1", + "@redis/json": "1.0.3", + "@redis/search": "1.0.6", + "@redis/time-series": "1.0.3" + } + }, + "redis-mock": { + "version": "0.56.3", + "resolved": "https://registry.npmjs.org/redis-mock/-/redis-mock-0.56.3.tgz", + "integrity": "sha512-ynaJhqk0Qf3Qajnwvy4aOjS4Mdf9IBkELWtjd+NYhpiqu4QCNq6Vf3Q7c++XRPGiKiwRj9HWr0crcwy7EiPjYQ==", + "dev": true + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12904,8 +13104,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "1.10.2", diff --git a/package.json b/package.json index e4b739c..f54b10d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", "@types/jest": "^27.5.1", + "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", "@typescript-eslint/parser": "^5.24.0", "babel-jest": "^28.1.0", @@ -39,11 +40,15 @@ "jest": "^28.1.0", "lint-staged": "^12.4.1", "prettier": "2.6.2", + "redis-mock": "^0.56.3", "ts-jest": "^28.0.2", "typescript": "^4.6.4" }, "lint-staged": { "*.{js, ts}": "eslint --cache --fix", "*.{js,ts,css,md}": "prettier --write --ignore-unknown" + }, + "dependencies": { + "redis": "^4.1.0" } } diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index a64c5af..bb0595e 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -6,9 +6,9 @@ interface RateLimiter { * @returns true if the request is allowed */ processRequest: (uuid: string, tokens?: number) => boolean; - /** - * Connects the RateLimiter instance to a db to cache current token usage for connected users - * @param uri database connection string - */ - connect: (uri: string) => void; +} + +interface RedisToken { + tokens: number; + timestamp: number; } diff --git a/src/rateLimiters/tokenBucket.ts b/src/rateLimiters/tokenBucket.ts index 8ea4903..cd9cca9 100644 --- a/src/rateLimiters/tokenBucket.ts +++ b/src/rateLimiters/tokenBucket.ts @@ -1,33 +1,47 @@ +import { RedisClientType } from 'redis'; + /** - * + * The TokenBucket instance of a RateLimiter limits requests based on a unique user ID. + * Whenever a user makes a request the following steps are performed: + * 1. Refill the bucket based on time elapsed since the previous request + * 2. Update the timestamp of the last request. + * 3. Allow the request and remove the requested amount of tokens from the bucket if the user has enough. + * 4. Otherwise, disallow the request and do not update the token total. */ class TokenBucket implements RateLimiter { capacity: number; refillRate: number; + client: RedisClientType; + /** * Create a new instance of a TokenBucket rate limiter that can be connected to any database store * @param capacity max token bucket capacity * @param refillRate rate at which the token bucket is refilled + * @param client redis client where rate limiter will cache information */ - constructor(capacity: number, refillRate: number) { + constructor(capacity: number, refillRate: number, client: RedisClientType) { this.capacity = capacity; this.refillRate = refillRate; + this.client = client; } - processRequest(uuid: string, tokens: number): boolean { + processRequest(uuid: string, tokens = 1): boolean { throw Error(`TokenBucket.processRequest not implemented, ${this}`); } - connect(uri: string) { + /** + * @returns current size of the token bucket in redis store or CAPACITY if user is not present + */ + getSize(uuid: string): number { throw Error(`TokenBucket.connect not implemented, ${this}`); } /** - * @returns current size of the token bucket. + * Resets the rate limiter to the intial state by clearing the redis store. */ - getSize(uuid: string): number { + reset(): void { throw Error(`TokenBucket.connect not implemented, ${this}`); } } diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index 0b36855..dc30317 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -1,12 +1,15 @@ +import redis from 'redis-mock'; +import { RedisClientType } from 'redis'; import TokenBucket from '../../src/rateLimiters/tokenBucket'; const CAPACITY = 10; const REFILL_RATE = 1; // 1 token per second let limiter: TokenBucket; -let user1; -let user2; -let user3; +let client: RedisClientType; +let user1: string; +let user2: string; +let user3: string; xdescribe('Test token bucket functionality', () => { beforeAll(() => { @@ -15,14 +18,18 @@ xdescribe('Test token bucket functionality', () => { user3 = '3'; }); - beforeEach(() => { + beforeEach(async () => { // Initialize a new token bucket before each test // create a mock user // intialze the token bucket algorithm - limiter = new TokenBucket(CAPACITY, REFILL_RATE); + client = redis.createClient(); + await client.connect(); + limiter = new TokenBucket(CAPACITY, REFILL_RATE, client); }); test('allows a user to consume up to their current allotment of tokens', () => { + // "free requests" + expect(limiter.processRequest(user1, 0)).toBe(true); // Test 1 token requested expect(limiter.processRequest(user1, 1)).toBe(true); // Test < CAPACITY tokens requested @@ -36,13 +43,22 @@ xdescribe('Test token bucket functionality', () => { }, 1000); }); - test("blocks requets exceeding the user's current allotment of tokens", () => { + test("blocks requets exceeding the user's current allotment of tokens", async () => { // Test > capacity tokens reqeusted expect(limiter.processRequest(user1, CAPACITY + 1)).toBe(false); - // allowed to take full amount - expect(limiter.processRequest(user1, CAPACITY)).toBe(true); + + // Empty user 1's bucket + // FIXME: What server time should we use? In what format will it be stored. + const currentServerTime = await client.time(); + const timestamp = currentServerTime.microseconds; + const value: RedisToken = { tokens: 0, timestamp }; + await client.set(user1, JSON.stringify(value)); + // bucket is empty. Shouldn't be allowed to take 1 token expect(limiter.processRequest(user1, 1)).toBe(false); + + // Should still be allowed to process "free" requests + expect(limiter.processRequest(user1, 0)).toBe(true); }); test('token bucket never exceeds maximum capacity', () => { @@ -64,7 +80,7 @@ xdescribe('Test token bucket functionality', () => { // check if bucket refills completely and doesn't spill over. setTimeout(() => { expect(limiter.getSize(user1)).toBe(CAPACITY); - }, (withdraw / REFILL_RATE) * 5000); + }, Math.ceil(withdraw / REFILL_RATE) * 1000); }); test('users have their own buckets', () => { @@ -78,4 +94,24 @@ xdescribe('Test token bucket functionality', () => { expect(limiter.getSize(user2)).toBe(CAPACITY - 1); expect(limiter.getSize(user3)).toBe(CAPACITY); }); + + test('bucket does not allow negative capacity or refill rate <= 0', () => { + expect(new TokenBucket(-10, 1, client)).toThrowError(); + expect(new TokenBucket(10, -1, client)).toThrowError(); + expect(new TokenBucket(10, 0, client)).toThrowError(); + }); + + test('bucket allows custom refill rates', async () => { + const doubleRefillClient: RedisClientType = redis.createClient(); + await doubleRefillClient.connect(); + limiter = new TokenBucket(CAPACITY, 2, doubleRefillClient); + + const timestamp = await doubleRefillClient.time().then((time) => time.microseconds); + const value: RedisToken = { tokens: 0, timestamp }; + await client.set(user1, JSON.stringify(value)); + + setInterval(() => { + expect(limiter.processRequest(user1, 2)).toBeTruthy(); + }, 1000); + }); }); From 62b89b6c54665a1b6a100a34119d20bf80dcfee9 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 18:06:00 -0400 Subject: [PATCH 04/12] added reset tests for rate limiter updated timestamps to use UNIX format --- test/rateLimiters/tokenBucket.test.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index dc30317..be73ba2 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -48,9 +48,7 @@ xdescribe('Test token bucket functionality', () => { expect(limiter.processRequest(user1, CAPACITY + 1)).toBe(false); // Empty user 1's bucket - // FIXME: What server time should we use? In what format will it be stored. - const currentServerTime = await client.time(); - const timestamp = currentServerTime.microseconds; + const timestamp = await client.time().then((time) => time.valueOf()); const value: RedisToken = { tokens: 0, timestamp }; await client.set(user1, JSON.stringify(value)); @@ -106,7 +104,8 @@ xdescribe('Test token bucket functionality', () => { await doubleRefillClient.connect(); limiter = new TokenBucket(CAPACITY, 2, doubleRefillClient); - const timestamp = await doubleRefillClient.time().then((time) => time.microseconds); + const timestamp = await doubleRefillClient.time().then((time) => time.valueOf()); + const value: RedisToken = { tokens: 0, timestamp }; await client.set(user1, JSON.stringify(value)); @@ -114,4 +113,22 @@ xdescribe('Test token bucket functionality', () => { expect(limiter.processRequest(user1, 2)).toBeTruthy(); }, 1000); }); + + test('All buckets should be able to be reset', async () => { + // add data to redis + const time = new Date(); + const value = JSON.stringify({ tokens: 0, timestamp: time.valueOf() }); + + await client.set(user1, value); + await client.set(user2, value); + await client.set(user3, value); + limiter.reset(); + + const resetUser1 = await client.get(user1); + const resetUser2 = await client.get(user2); + const resetUser3 = await client.get(user3); + expect(resetUser1).toBeNull(); + expect(resetUser2).toBeNull(); + expect(resetUser3).toBeNull(); + }); }); From c12b9e6366834b954d2b8a5df669cd8ef738485a Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 18:21:09 -0400 Subject: [PATCH 05/12] corrected jest config --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index ba37eb5..81ae109 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { - roots: ['./src'], + roots: ['./test'], preset: 'ts-jest', testEnvironment: 'node', moduleFileExtensions: ['js', 'ts'], From ee63bb751dbfa67f5a04c09afaabc4910cba2431 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 22:13:59 -0400 Subject: [PATCH 06/12] added timestamp to processRequest for RateLimiters and overahauled TokenBucket test suite to accomodate --- src/@types/rateLimit.d.ts | 16 +- src/rateLimiters/tokenBucket.ts | 17 +- test/rateLimiters/tokenBucket.test.ts | 321 ++++++++++++++++++-------- 3 files changed, 243 insertions(+), 111 deletions(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index bb0595e..e8d6866 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -2,13 +2,23 @@ interface RateLimiter { /** * Checks if a request is allowed under the given conditions and withdraws the specified number of tokens * @param uuid Unique identifier for the user associated with the request + * @param timestamp UNIX format timestamp of when request was received * @param tokens Number of tokens being used in this request. Optional - * @returns true if the request is allowed + * @returns a RateLimiterResponse indicating with a sucess and tokens property indicating the number of tokens remaining */ - processRequest: (uuid: string, tokens?: number) => boolean; + processRequest: ( + uuid: string, + timestamp: number, + tokens?: number | undefined + ) => RateLimiterResponse; } -interface RedisToken { +interface RateLimiterResponse { + success: boolean; + tokens?: number; +} + +interface RedisBucket { tokens: number; timestamp: number; } diff --git a/src/rateLimiters/tokenBucket.ts b/src/rateLimiters/tokenBucket.ts index cd9cca9..6632ff9 100644 --- a/src/rateLimiters/tokenBucket.ts +++ b/src/rateLimiters/tokenBucket.ts @@ -25,24 +25,23 @@ class TokenBucket implements RateLimiter { this.capacity = capacity; this.refillRate = refillRate; this.client = client; + if (refillRate <= 0 || capacity <= 0) + throw Error('TokenBucket refillRate and capacity must be positive'); } - processRequest(uuid: string, tokens = 1): boolean { + processRequest( + uuid: string, + timestamp: number, + tokens: number | undefined + ): RateLimiterResponse { throw Error(`TokenBucket.processRequest not implemented, ${this}`); } - /** - * @returns current size of the token bucket in redis store or CAPACITY if user is not present - */ - getSize(uuid: string): number { - throw Error(`TokenBucket.connect not implemented, ${this}`); - } - /** * Resets the rate limiter to the intial state by clearing the redis store. */ reset(): void { - throw Error(`TokenBucket.connect not implemented, ${this}`); + throw Error(`TokenBucket.reset not implemented, ${this}`); } } diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index be73ba2..e4bb6dd 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -3,132 +3,255 @@ import { RedisClientType } from 'redis'; import TokenBucket from '../../src/rateLimiters/tokenBucket'; const CAPACITY = 10; +// FIXME: Changing the refill rate affects test outcomes. const REFILL_RATE = 1; // 1 token per second let limiter: TokenBucket; let client: RedisClientType; -let user1: string; -let user2: string; -let user3: string; - -xdescribe('Test token bucket functionality', () => { - beforeAll(() => { - user1 = '1'; - user2 = '2'; - user3 = '3'; - }); +let timestamp: number; +const user1 = '1'; +const user2 = '2'; +const user3 = '3'; +const user4 = '4'; + +async function getBucketFromClient( + redisClient: RedisClientType, + uuid: string +): Promise { + return redisClient.get(uuid).then((res) => JSON.parse(res || '{}')); +} +async function setTokenCountInClient( + redisClient: RedisClientType, + uuid: string, + tokens: number, + time: number +) { + const value: RedisBucket = { tokens, timestamp: time }; + await redisClient.set(uuid, JSON.stringify(value)); +} + +describe('Test TokenBucket Rate Limiter', () => { beforeEach(async () => { // Initialize a new token bucket before each test // create a mock user // intialze the token bucket algorithm client = redis.createClient(); - await client.connect(); limiter = new TokenBucket(CAPACITY, REFILL_RATE, client); + timestamp = new Date().valueOf(); }); - test('allows a user to consume up to their current allotment of tokens', () => { - // "free requests" - expect(limiter.processRequest(user1, 0)).toBe(true); - // Test 1 token requested - expect(limiter.processRequest(user1, 1)).toBe(true); - // Test < CAPACITY tokens requested - expect(limiter.processRequest(user2, CAPACITY - 1)).toBe(true); - // <= CAPACITY tokens requested - expect(limiter.processRequest(user3, CAPACITY)).toBe(true); - - setTimeout(() => { - // make sure user doesn't get extra tokens - expect(limiter.processRequest(user1, CAPACITY + 1)).toBe(false); - }, 1000); - }); + describe('TokenBucket returns correct number of tokens and updates redis store as expected', () => { + test('after an ALLOWED request', async () => { + // Bucket intially full + const withdraw5 = 5; + expect(limiter.processRequest(user1, timestamp, withdraw5).tokens).toBe( + CAPACITY - withdraw5 + ); + const tokenCountFull = await getBucketFromClient(client, user1); + expect(tokenCountFull).toBe(CAPACITY - withdraw5); - test("blocks requets exceeding the user's current allotment of tokens", async () => { - // Test > capacity tokens reqeusted - expect(limiter.processRequest(user1, CAPACITY + 1)).toBe(false); + // Bucket partially full but enough time has elapsed to fill the bucket since the last request and + // has leftover tokens after reqeust + const initial = 6; + const partialWithdraw = 1; + await setTokenCountInClient(client, user2, initial, timestamp); + expect( + limiter.processRequest( + user2, + timestamp + 1000 * (CAPACITY - initial), + initial + partialWithdraw + ) + ).toBe(CAPACITY - (initial + partialWithdraw)); + const tokenCountPartial = await getBucketFromClient(client, user2); + expect(tokenCountPartial).toBe(CAPACITY - (initial + partialWithdraw)); - // Empty user 1's bucket - const timestamp = await client.time().then((time) => time.valueOf()); - const value: RedisToken = { tokens: 0, timestamp }; - await client.set(user1, JSON.stringify(value)); + // Bucket partially full and no leftover tokens after reqeust + const initial2 = 6; + await setTokenCountInClient(client, user2, initial, timestamp); + expect(limiter.processRequest(user2, timestamp, initial2)).toBe(0); + const tokenCountPartialToEmpty = await getBucketFromClient(client, user2); + expect(tokenCountPartialToEmpty).toBe(0); - // bucket is empty. Shouldn't be allowed to take 1 token - expect(limiter.processRequest(user1, 1)).toBe(false); + // Bucket initially empty but enough time elapsed to paritally fill bucket since last request + await setTokenCountInClient(client, user4, 0, timestamp); + expect(limiter.processRequest(user4, timestamp + 6000, 4)).toBe(2); + const count = await getBucketFromClient(client, user4); + expect(count).toBe(2); + }); - // Should still be allowed to process "free" requests - expect(limiter.processRequest(user1, 0)).toBe(true); - }); + test('after a BLOCKED request', async () => { + let redisData: RedisBucket; + // Initial request greater than capacity + expect(limiter.processRequest(user1, timestamp, CAPACITY + 1)).toBe(CAPACITY); - test('token bucket never exceeds maximum capacity', () => { - // initial capacity should be max - expect(limiter.getSize(user1)).toBe(CAPACITY); - // make sure bucket doesn't exceed max size without any requests. - setTimeout(() => { - expect(limiter.getSize(user1)).toBe(CAPACITY); - }, 1000); - - // make sure bucket refills if user takes tokens. - const withdraw = 5; - limiter.processRequest(user1, withdraw); - expect(limiter.getSize(user1)).toBe(CAPACITY - withdraw); - setTimeout(() => { - expect(limiter.getSize(user1)).toBe(CAPACITY - withdraw + REFILL_RATE); - }, 1000); - - // check if bucket refills completely and doesn't spill over. - setTimeout(() => { - expect(limiter.getSize(user1)).toBe(CAPACITY); - }, Math.ceil(withdraw / REFILL_RATE) * 1000); - }); + redisData = await getBucketFromClient(client, user1); + expect(redisData.tokens).toBe(CAPACITY); - test('users have their own buckets', () => { - limiter.processRequest(user1, CAPACITY); - expect(limiter.getSize(user1)).toBe(0); - expect(limiter.getSize(user2)).toBe(CAPACITY); - expect(limiter.getSize(user3)).toBe(CAPACITY); + // Bucket is partially full and time has elapsed but not enough to allow the current request + const fillLevel = 5; + const timeDelta = 3; + const requestedTokens = 9; + await setTokenCountInClient(client, user2, fillLevel, timestamp); - limiter.processRequest(user2, 1); - expect(limiter.getSize(user1)).toBe(0); - expect(limiter.getSize(user2)).toBe(CAPACITY - 1); - expect(limiter.getSize(user3)).toBe(CAPACITY); - }); + expect( + limiter.processRequest(user1, timestamp + timeDelta * 1000, requestedTokens) + ).toBe(fillLevel + timeDelta * REFILL_RATE); - test('bucket does not allow negative capacity or refill rate <= 0', () => { - expect(new TokenBucket(-10, 1, client)).toThrowError(); - expect(new TokenBucket(10, -1, client)).toThrowError(); - expect(new TokenBucket(10, 0, client)).toThrowError(); + redisData = await getBucketFromClient(client, user2); + expect(redisData.tokens).toBe(fillLevel + timeDelta * REFILL_RATE); + }); }); - test('bucket allows custom refill rates', async () => { - const doubleRefillClient: RedisClientType = redis.createClient(); - await doubleRefillClient.connect(); - limiter = new TokenBucket(CAPACITY, 2, doubleRefillClient); + describe('Token Bucket functions as expected', () => { + test('allows a user to consume up to their current allotment of tokens', () => { + // "free requests" + expect(limiter.processRequest(user1, timestamp, 0).success).toBe(true); + // Test 1 token requested + expect(limiter.processRequest(user1, timestamp, 1).success).toBe(true); + // Test < CAPACITY tokens requested + expect(limiter.processRequest(user2, timestamp, CAPACITY - 1).success).toBe(true); + // <= CAPACITY tokens requested + expect(limiter.processRequest(user3, timestamp, CAPACITY).success).toBe(true); + }); + + test("blocks requests exceeding the user's current allotment of tokens", async () => { + // Test > capacity tokens reqeusted + expect(limiter.processRequest(user1, timestamp, CAPACITY + 1).success).toBe(false); + + // Empty user 1's bucket + const value: RedisBucket = { tokens: 0, timestamp }; + await client.set(user1, JSON.stringify(value)); + + // bucket is empty. Shouldn't be allowed to take 1 token + expect(limiter.processRequest(user1, timestamp, 1).success).toBe(false); + + // Should still be allowed to process "free" requests + expect(limiter.processRequest(user1, timestamp, 0).success).toBe(true); + }); + + test('token bucket never exceeds maximum capacity', async () => { + // make sure bucket doesn't exceed max size without any requests. + // Fill the user's bucket then request additional tokens after an interval + const value: RedisBucket = { tokens: CAPACITY, timestamp }; + await client.set(user1, JSON.stringify(value)); + expect(limiter.processRequest(user1, timestamp + 1000, CAPACITY + 1).success).toBe( + false + ); + expect(limiter.processRequest(user1, timestamp + 10000, CAPACITY + 1).success).toBe( + false + ); + expect(limiter.processRequest(user1, timestamp + 100000, CAPACITY + 1).success).toBe( + false + ); + }); + + test('token bucket refills at specified rate', async () => { + // make sure bucket refills if user takes tokens. + const withdraw = 5; + let timeDelta = 3; + limiter.processRequest(user1, timestamp, withdraw); + expect( + limiter.processRequest( + user1, + timestamp + timeDelta * 1000, + withdraw + REFILL_RATE * timeDelta + ).tokens + ).toBe(CAPACITY - withdraw + REFILL_RATE * timeDelta); + + // check if bucket refills completely and doesn't spill over. + timeDelta = 2 * CAPACITY; + expect( + limiter.processRequest(user1, timestamp + timeDelta * 1000, CAPACITY + 1).tokens + ).toBe(CAPACITY); + }); - const timestamp = await doubleRefillClient.time().then((time) => time.valueOf()); + test('bucket allows custom refill rates', async () => { + const doubleRefillClient: RedisClientType = redis.createClient(); + limiter = new TokenBucket(CAPACITY, 2, doubleRefillClient); - const value: RedisToken = { tokens: 0, timestamp }; - await client.set(user1, JSON.stringify(value)); + await setTokenCountInClient(doubleRefillClient, user1, 0, timestamp); - setInterval(() => { - expect(limiter.processRequest(user1, 2)).toBeTruthy(); - }, 1000); + const timeDelta = 5; + expect(limiter.processRequest(user1, timestamp * 1000 + timeDelta, 0)).toBe( + timeDelta * REFILL_RATE + ); + }); + + test('users have their own buckets', async () => { + const requested = 6; + const user3Tokens = 8; + // Add tokens for user 3 so we have both a user that exists in the store (3) and one that doesn't (2) + await setTokenCountInClient(client, user3, user3Tokens, timestamp); + + // issue a request for user 1; + limiter.processRequest(user1, timestamp, requested); + + // Check that each user has the expected amount of tokens. + expect((await getBucketFromClient(client, user1)).tokens).toBe(CAPACITY - requested); + expect((await getBucketFromClient(client, user2)).tokens).toBe(CAPACITY); + expect((await getBucketFromClient(client, user3)).tokens).toBe(user3Tokens); + + limiter.processRequest(user2, timestamp, 1); + expect((await getBucketFromClient(client, user1)).tokens).toBe(CAPACITY - requested); + expect((await getBucketFromClient(client, user2)).tokens).toBe(CAPACITY - 1); + expect((await getBucketFromClient(client, user3)).tokens).toBe(user3Tokens); + }); + + test('bucket does not allow capacity or refill rate <= 0', () => { + expect(new TokenBucket(-10, 1, client)).toThrowError(); + expect(new TokenBucket(0, 1, client)).toThrowError(); + expect(new TokenBucket(10, -1, client)).toThrowError(); + expect(new TokenBucket(10, 0, client)).toThrowError(); + }); + + test('All buckets should be able to be reset', async () => { + const tokens = 5; + await setTokenCountInClient(client, user1, tokens, timestamp); + await setTokenCountInClient(client, user2, tokens, timestamp); + await setTokenCountInClient(client, user3, tokens, timestamp); + + limiter.reset(); + + expect(limiter.processRequest(user1, timestamp, CAPACITY)).toBe(true); + expect(limiter.processRequest(user2, timestamp, CAPACITY - 1)).toBe(true); + expect(limiter.processRequest(user3, timestamp, CAPACITY + 1)).toBe(false); + }); }); - test('All buckets should be able to be reset', async () => { - // add data to redis - const time = new Date(); - const value = JSON.stringify({ tokens: 0, timestamp: time.valueOf() }); - - await client.set(user1, value); - await client.set(user2, value); - await client.set(user3, value); - limiter.reset(); - - const resetUser1 = await client.get(user1); - const resetUser2 = await client.get(user2); - const resetUser3 = await client.get(user3); - expect(resetUser1).toBeNull(); - expect(resetUser2).toBeNull(); - expect(resetUser3).toBeNull(); + describe('Token Bucket correctly updates redis store', () => { + test('timestamp correctly updated in redis', async () => { + let redisData: RedisBucket; + + // blocked request + limiter.processRequest(user1, timestamp, CAPACITY + 1); + redisData = await getBucketFromClient(client, user2); + expect(redisData.timestamp).toBe(timestamp); + + timestamp += 1000; + // allowed request + limiter.processRequest(user1, timestamp, CAPACITY); + redisData = await getBucketFromClient(client, user2); + expect(redisData.timestamp).toBe(timestamp); + }); + + test('All buckets should be able to be reset', async () => { + // add data to redis + const time = new Date(); + const value = JSON.stringify({ tokens: 0, timestamp: time.valueOf() }); + + await client.set(user1, value); + await client.set(user2, value); + await client.set(user3, value); + + limiter.reset(); + + const resetUser1 = await client.get(user1); + const resetUser2 = await client.get(user2); + const resetUser3 = await client.get(user3); + expect(resetUser1).toBe(''); + expect(resetUser2).toBe(''); + expect(resetUser3).toBe(''); + }); }); }); From 572987aab46a8ec7f35136a8dbe92a6a99fb1b5c Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 22:27:07 -0400 Subject: [PATCH 07/12] Fixed test for negative tokenbucket refillRate and capacity --- test/rateLimiters/tokenBucket.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index e4bb6dd..808eee2 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -3,7 +3,7 @@ import { RedisClientType } from 'redis'; import TokenBucket from '../../src/rateLimiters/tokenBucket'; const CAPACITY = 10; -// FIXME: Changing the refill rate affects test outcomes. +// FIXME: Changing the refill rate effects test outcomes. const REFILL_RATE = 1; // 1 token per second let limiter: TokenBucket; @@ -199,10 +199,18 @@ describe('Test TokenBucket Rate Limiter', () => { }); test('bucket does not allow capacity or refill rate <= 0', () => { - expect(new TokenBucket(-10, 1, client)).toThrowError(); - expect(new TokenBucket(0, 1, client)).toThrowError(); - expect(new TokenBucket(10, -1, client)).toThrowError(); - expect(new TokenBucket(10, 0, client)).toThrowError(); + expect(() => new TokenBucket(0, 1, client)).toThrow( + 'TokenBucket refillRate and capacity must be positive' + ); + expect(() => new TokenBucket(-10, 1, client)).toThrow( + 'TokenBucket refillRate and capacity must be positive' + ); + expect(() => new TokenBucket(10, -1, client)).toThrow( + 'TokenBucket refillRate and capacity must be positive' + ); + expect(() => new TokenBucket(10, 0, client)).toThrow( + 'TokenBucket refillRate and capacity must be positive' + ); }); test('All buckets should be able to be reset', async () => { From f579f1a4cad6f54c7e37ef851b8d74d9963d1762 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Thu, 19 May 2022 22:35:36 -0400 Subject: [PATCH 08/12] disabled TokenBucket tests for travis build --- src/@types/rateLimit.d.ts | 1 + test/rateLimiters/tokenBucket.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index e8d6866..d168d6c 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -6,6 +6,7 @@ interface RateLimiter { * @param tokens Number of tokens being used in this request. Optional * @returns a RateLimiterResponse indicating with a sucess and tokens property indicating the number of tokens remaining */ + // FIXME: Should this be async processRequest: ( uuid: string, timestamp: number, diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index 808eee2..8568cce 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -31,7 +31,7 @@ async function setTokenCountInClient( await redisClient.set(uuid, JSON.stringify(value)); } -describe('Test TokenBucket Rate Limiter', () => { +xdescribe('Test TokenBucket Rate Limiter', () => { beforeEach(async () => { // Initialize a new token bucket before each test // create a mock user From e88e0c17a13ad6a89f2d9b38efe8e471e8ee1243 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Fri, 20 May 2022 11:48:40 -0400 Subject: [PATCH 09/12] Made RateLimiter.processRequest async and updated TokenBucket tests accordingly --- src/@types/rateLimit.d.ts | 3 +- src/rateLimiters/tokenBucket.ts | 6 +- test/rateLimiters/tokenBucket.test.ts | 102 +++++++++++++++----------- 3 files changed, 63 insertions(+), 48 deletions(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index d168d6c..7a915eb 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -6,12 +6,11 @@ interface RateLimiter { * @param tokens Number of tokens being used in this request. Optional * @returns a RateLimiterResponse indicating with a sucess and tokens property indicating the number of tokens remaining */ - // FIXME: Should this be async processRequest: ( uuid: string, timestamp: number, tokens?: number | undefined - ) => RateLimiterResponse; + ) => Promise; } interface RateLimiterResponse { diff --git a/src/rateLimiters/tokenBucket.ts b/src/rateLimiters/tokenBucket.ts index 6632ff9..378c974 100644 --- a/src/rateLimiters/tokenBucket.ts +++ b/src/rateLimiters/tokenBucket.ts @@ -29,11 +29,11 @@ class TokenBucket implements RateLimiter { throw Error('TokenBucket refillRate and capacity must be positive'); } - processRequest( + async processRequest( uuid: string, timestamp: number, - tokens: number | undefined - ): RateLimiterResponse { + tokens = 1 + ): Promise { throw Error(`TokenBucket.processRequest not implemented, ${this}`); } diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index 8568cce..dd67f6f 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -45,7 +45,7 @@ xdescribe('Test TokenBucket Rate Limiter', () => { test('after an ALLOWED request', async () => { // Bucket intially full const withdraw5 = 5; - expect(limiter.processRequest(user1, timestamp, withdraw5).tokens).toBe( + expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe( CAPACITY - withdraw5 ); const tokenCountFull = await getBucketFromClient(client, user1); @@ -57,11 +57,13 @@ xdescribe('Test TokenBucket Rate Limiter', () => { const partialWithdraw = 1; await setTokenCountInClient(client, user2, initial, timestamp); expect( - limiter.processRequest( - user2, - timestamp + 1000 * (CAPACITY - initial), - initial + partialWithdraw - ) + ( + await limiter.processRequest( + user2, + timestamp + 1000 * (CAPACITY - initial), + initial + partialWithdraw + ) + ).tokens ).toBe(CAPACITY - (initial + partialWithdraw)); const tokenCountPartial = await getBucketFromClient(client, user2); expect(tokenCountPartial).toBe(CAPACITY - (initial + partialWithdraw)); @@ -69,13 +71,13 @@ xdescribe('Test TokenBucket Rate Limiter', () => { // Bucket partially full and no leftover tokens after reqeust const initial2 = 6; await setTokenCountInClient(client, user2, initial, timestamp); - expect(limiter.processRequest(user2, timestamp, initial2)).toBe(0); + expect((await limiter.processRequest(user2, timestamp, initial2)).tokens).toBe(0); const tokenCountPartialToEmpty = await getBucketFromClient(client, user2); expect(tokenCountPartialToEmpty).toBe(0); // Bucket initially empty but enough time elapsed to paritally fill bucket since last request await setTokenCountInClient(client, user4, 0, timestamp); - expect(limiter.processRequest(user4, timestamp + 6000, 4)).toBe(2); + expect((await limiter.processRequest(user4, timestamp + 6000, 4)).tokens).toBe(2); const count = await getBucketFromClient(client, user4); expect(count).toBe(2); }); @@ -83,7 +85,9 @@ xdescribe('Test TokenBucket Rate Limiter', () => { test('after a BLOCKED request', async () => { let redisData: RedisBucket; // Initial request greater than capacity - expect(limiter.processRequest(user1, timestamp, CAPACITY + 1)).toBe(CAPACITY); + expect((await limiter.processRequest(user1, timestamp, CAPACITY + 1)).tokens).toBe( + CAPACITY + ); redisData = await getBucketFromClient(client, user1); expect(redisData.tokens).toBe(CAPACITY); @@ -95,7 +99,8 @@ xdescribe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user2, fillLevel, timestamp); expect( - limiter.processRequest(user1, timestamp + timeDelta * 1000, requestedTokens) + (await limiter.processRequest(user1, timestamp + timeDelta * 1000, requestedTokens)) + .tokens ).toBe(fillLevel + timeDelta * REFILL_RATE); redisData = await getBucketFromClient(client, user2); @@ -104,30 +109,34 @@ xdescribe('Test TokenBucket Rate Limiter', () => { }); describe('Token Bucket functions as expected', () => { - test('allows a user to consume up to their current allotment of tokens', () => { + test('allows a user to consume up to their current allotment of tokens', async () => { // "free requests" - expect(limiter.processRequest(user1, timestamp, 0).success).toBe(true); + expect((await limiter.processRequest(user1, timestamp, 0)).success).toBe(true); // Test 1 token requested - expect(limiter.processRequest(user1, timestamp, 1).success).toBe(true); + expect((await limiter.processRequest(user1, timestamp, 1)).success).toBe(true); // Test < CAPACITY tokens requested - expect(limiter.processRequest(user2, timestamp, CAPACITY - 1).success).toBe(true); + expect((await limiter.processRequest(user2, timestamp, CAPACITY - 1)).success).toBe( + true + ); // <= CAPACITY tokens requested - expect(limiter.processRequest(user3, timestamp, CAPACITY).success).toBe(true); + expect((await limiter.processRequest(user3, timestamp, CAPACITY)).success).toBe(true); }); test("blocks requests exceeding the user's current allotment of tokens", async () => { // Test > capacity tokens reqeusted - expect(limiter.processRequest(user1, timestamp, CAPACITY + 1).success).toBe(false); + expect((await limiter.processRequest(user1, timestamp, CAPACITY + 1)).success).toBe( + false + ); // Empty user 1's bucket const value: RedisBucket = { tokens: 0, timestamp }; await client.set(user1, JSON.stringify(value)); // bucket is empty. Shouldn't be allowed to take 1 token - expect(limiter.processRequest(user1, timestamp, 1).success).toBe(false); + expect((await limiter.processRequest(user1, timestamp, 1)).success).toBe(false); // Should still be allowed to process "free" requests - expect(limiter.processRequest(user1, timestamp, 0).success).toBe(true); + expect((await limiter.processRequest(user1, timestamp, 0)).success).toBe(true); }); test('token bucket never exceeds maximum capacity', async () => { @@ -135,34 +144,37 @@ xdescribe('Test TokenBucket Rate Limiter', () => { // Fill the user's bucket then request additional tokens after an interval const value: RedisBucket = { tokens: CAPACITY, timestamp }; await client.set(user1, JSON.stringify(value)); - expect(limiter.processRequest(user1, timestamp + 1000, CAPACITY + 1).success).toBe( - false - ); - expect(limiter.processRequest(user1, timestamp + 10000, CAPACITY + 1).success).toBe( - false - ); - expect(limiter.processRequest(user1, timestamp + 100000, CAPACITY + 1).success).toBe( - false - ); + expect( + (await limiter.processRequest(user1, timestamp + 1000, CAPACITY + 1)).success + ).toBe(false); + expect( + (await limiter.processRequest(user1, timestamp + 10000, CAPACITY + 1)).success + ).toBe(false); + expect( + (await limiter.processRequest(user1, timestamp + 100000, CAPACITY + 1)).success + ).toBe(false); }); test('token bucket refills at specified rate', async () => { // make sure bucket refills if user takes tokens. const withdraw = 5; let timeDelta = 3; - limiter.processRequest(user1, timestamp, withdraw); + await limiter.processRequest(user1, timestamp, withdraw); expect( - limiter.processRequest( - user1, - timestamp + timeDelta * 1000, - withdraw + REFILL_RATE * timeDelta + ( + await limiter.processRequest( + user1, + timestamp + timeDelta * 1000, + withdraw + REFILL_RATE * timeDelta + ) ).tokens ).toBe(CAPACITY - withdraw + REFILL_RATE * timeDelta); // check if bucket refills completely and doesn't spill over. timeDelta = 2 * CAPACITY; expect( - limiter.processRequest(user1, timestamp + timeDelta * 1000, CAPACITY + 1).tokens + (await limiter.processRequest(user1, timestamp + timeDelta * 1000, CAPACITY + 1)) + .tokens ).toBe(CAPACITY); }); @@ -173,9 +185,9 @@ xdescribe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(doubleRefillClient, user1, 0, timestamp); const timeDelta = 5; - expect(limiter.processRequest(user1, timestamp * 1000 + timeDelta, 0)).toBe( - timeDelta * REFILL_RATE - ); + expect( + (await limiter.processRequest(user1, timestamp * 1000 + timeDelta, 0)).tokens + ).toBe(timeDelta * REFILL_RATE); }); test('users have their own buckets', async () => { @@ -185,14 +197,14 @@ xdescribe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user3, user3Tokens, timestamp); // issue a request for user 1; - limiter.processRequest(user1, timestamp, requested); + await limiter.processRequest(user1, timestamp, requested); // Check that each user has the expected amount of tokens. expect((await getBucketFromClient(client, user1)).tokens).toBe(CAPACITY - requested); expect((await getBucketFromClient(client, user2)).tokens).toBe(CAPACITY); expect((await getBucketFromClient(client, user3)).tokens).toBe(user3Tokens); - limiter.processRequest(user2, timestamp, 1); + await limiter.processRequest(user2, timestamp, 1); expect((await getBucketFromClient(client, user1)).tokens).toBe(CAPACITY - requested); expect((await getBucketFromClient(client, user2)).tokens).toBe(CAPACITY - 1); expect((await getBucketFromClient(client, user3)).tokens).toBe(user3Tokens); @@ -221,9 +233,13 @@ xdescribe('Test TokenBucket Rate Limiter', () => { limiter.reset(); - expect(limiter.processRequest(user1, timestamp, CAPACITY)).toBe(true); - expect(limiter.processRequest(user2, timestamp, CAPACITY - 1)).toBe(true); - expect(limiter.processRequest(user3, timestamp, CAPACITY + 1)).toBe(false); + expect((await limiter.processRequest(user1, timestamp, CAPACITY)).success).toBe(true); + expect((await limiter.processRequest(user2, timestamp, CAPACITY - 1)).success).toBe( + true + ); + expect((await limiter.processRequest(user3, timestamp, CAPACITY + 1)).success).toBe( + false + ); }); }); @@ -232,13 +248,13 @@ xdescribe('Test TokenBucket Rate Limiter', () => { let redisData: RedisBucket; // blocked request - limiter.processRequest(user1, timestamp, CAPACITY + 1); + await limiter.processRequest(user1, timestamp, CAPACITY + 1); redisData = await getBucketFromClient(client, user2); expect(redisData.timestamp).toBe(timestamp); timestamp += 1000; // allowed request - limiter.processRequest(user1, timestamp, CAPACITY); + await limiter.processRequest(user1, timestamp, CAPACITY); redisData = await getBucketFromClient(client, user2); expect(redisData.timestamp).toBe(timestamp); }); From cd0f1c1c66f94e0a443b5a71df30bbf2f25acdb3 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 13:23:26 -0400 Subject: [PATCH 10/12] separated allowed/blocked token bucket tests for clarity --- test/rateLimiters/tokenBucket.test.ts | 130 +++++++++++++++----------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index dd67f6f..9959225 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -42,69 +42,85 @@ xdescribe('Test TokenBucket Rate Limiter', () => { }); describe('TokenBucket returns correct number of tokens and updates redis store as expected', () => { - test('after an ALLOWED request', async () => { - // Bucket intially full - const withdraw5 = 5; - expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe( - CAPACITY - withdraw5 - ); - const tokenCountFull = await getBucketFromClient(client, user1); - expect(tokenCountFull).toBe(CAPACITY - withdraw5); - - // Bucket partially full but enough time has elapsed to fill the bucket since the last request and - // has leftover tokens after reqeust - const initial = 6; - const partialWithdraw = 1; - await setTokenCountInClient(client, user2, initial, timestamp); - expect( - ( - await limiter.processRequest( - user2, - timestamp + 1000 * (CAPACITY - initial), - initial + partialWithdraw - ) - ).tokens - ).toBe(CAPACITY - (initial + partialWithdraw)); - const tokenCountPartial = await getBucketFromClient(client, user2); - expect(tokenCountPartial).toBe(CAPACITY - (initial + partialWithdraw)); + describe('after an ALLOWED request...', () => { + test('bucket is initially full', async () => { + // Bucket intially full + const withdraw5 = 5; + expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe( + CAPACITY - withdraw5 + ); + const tokenCountFull = await getBucketFromClient(client, user1); + expect(tokenCountFull).toBe(CAPACITY - withdraw5); + }); + + test('bucket is partially full and request has leftover tokens', async () => { + // Bucket partially full but enough time has elapsed to fill the bucket since the last request and + // has leftover tokens after reqeust + const initial = 6; + const partialWithdraw = 1; + await setTokenCountInClient(client, user2, initial, timestamp); + expect( + ( + await limiter.processRequest( + user2, + timestamp + 1000 * (CAPACITY - initial), + initial + partialWithdraw + ) + ).tokens + ).toBe(CAPACITY - (initial + partialWithdraw)); + const tokenCountPartial = await getBucketFromClient(client, user2); + expect(tokenCountPartial).toBe(CAPACITY - (initial + partialWithdraw)); + }); // Bucket partially full and no leftover tokens after reqeust - const initial2 = 6; - await setTokenCountInClient(client, user2, initial, timestamp); - expect((await limiter.processRequest(user2, timestamp, initial2)).tokens).toBe(0); - const tokenCountPartialToEmpty = await getBucketFromClient(client, user2); - expect(tokenCountPartialToEmpty).toBe(0); - - // Bucket initially empty but enough time elapsed to paritally fill bucket since last request - await setTokenCountInClient(client, user4, 0, timestamp); - expect((await limiter.processRequest(user4, timestamp + 6000, 4)).tokens).toBe(2); - const count = await getBucketFromClient(client, user4); - expect(count).toBe(2); + test('bucket is partially full and request has no leftover tokens', async () => { + const initial = 6; + await setTokenCountInClient(client, user2, initial, timestamp); + expect((await limiter.processRequest(user2, timestamp, initial)).tokens).toBe(0); + const tokenCountPartialToEmpty = await getBucketFromClient(client, user2); + expect(tokenCountPartialToEmpty).toBe(0); + + // Bucket initially empty but enough time elapsed to paritally fill bucket since last request + await setTokenCountInClient(client, user4, 0, timestamp); + expect((await limiter.processRequest(user4, timestamp + 6000, 4)).tokens).toBe(2); + const count = await getBucketFromClient(client, user4); + expect(count).toBe(2); + }); }); - test('after a BLOCKED request', async () => { + describe('after a BLOCKED request...', () => { let redisData: RedisBucket; - // Initial request greater than capacity - expect((await limiter.processRequest(user1, timestamp, CAPACITY + 1)).tokens).toBe( - CAPACITY - ); - redisData = await getBucketFromClient(client, user1); - expect(redisData.tokens).toBe(CAPACITY); - - // Bucket is partially full and time has elapsed but not enough to allow the current request - const fillLevel = 5; - const timeDelta = 3; - const requestedTokens = 9; - await setTokenCountInClient(client, user2, fillLevel, timestamp); - - expect( - (await limiter.processRequest(user1, timestamp + timeDelta * 1000, requestedTokens)) - .tokens - ).toBe(fillLevel + timeDelta * REFILL_RATE); - - redisData = await getBucketFromClient(client, user2); - expect(redisData.tokens).toBe(fillLevel + timeDelta * REFILL_RATE); + test('where intial request is greater than bucket capacity', async () => { + // Initial request greater than capacity + expect((await limiter.processRequest(user1, timestamp, CAPACITY + 1)).tokens).toBe( + CAPACITY + ); + + redisData = await getBucketFromClient(client, user1); + expect(redisData.tokens).toBe(CAPACITY); + }); + + test('Bucket is partially full but not enough time elapsed to complete the request', async () => { + // Bucket is partially full and time has elapsed but not enough to allow the current request + const fillLevel = 5; + const timeDelta = 3; + const requestedTokens = 9; + await setTokenCountInClient(client, user2, fillLevel, timestamp); + + expect( + ( + await limiter.processRequest( + user1, + timestamp + timeDelta * 1000, + requestedTokens + ) + ).tokens + ).toBe(fillLevel + timeDelta * REFILL_RATE); + + redisData = await getBucketFromClient(client, user2); + expect(redisData.tokens).toBe(fillLevel + timeDelta * REFILL_RATE); + }); }); }); From 5f69d87863908554ee560b71f708399142040300 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 13:34:33 -0400 Subject: [PATCH 11/12] futher separate token bucket tests. --- test/rateLimiters/tokenBucket.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index 9959225..34aa391 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -79,8 +79,10 @@ xdescribe('Test TokenBucket Rate Limiter', () => { expect((await limiter.processRequest(user2, timestamp, initial)).tokens).toBe(0); const tokenCountPartialToEmpty = await getBucketFromClient(client, user2); expect(tokenCountPartialToEmpty).toBe(0); + }); - // Bucket initially empty but enough time elapsed to paritally fill bucket since last request + // Bucket initially empty but enough time elapsed to paritally fill bucket since last request + test('bucket is initially empty but enough time has elapsed to partially fill the bucket', async () => { await setTokenCountInClient(client, user4, 0, timestamp); expect((await limiter.processRequest(user4, timestamp + 6000, 4)).tokens).toBe(2); const count = await getBucketFromClient(client, user4); From f6241df84218f113b72e598736b2c2eb8559f20b Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 15:13:01 -0400 Subject: [PATCH 12/12] typescript tweak --- src/@types/rateLimit.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 7a915eb..34de442 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -9,7 +9,7 @@ interface RateLimiter { processRequest: ( uuid: string, timestamp: number, - tokens?: number | undefined + tokens?: number ) => Promise; }