@stimulcross/rate-limiter 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +21 -0
- package/.github/workflows/node.yml +87 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.megaignore +8 -0
- package/.prettierignore +3 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/commitlint.config.js +8 -0
- package/eslint.config.js +65 -0
- package/lint-staged.config.js +4 -0
- package/package.json +89 -0
- package/prettier.config.cjs +1 -0
- package/src/core/cancellable.ts +4 -0
- package/src/core/clock.ts +9 -0
- package/src/core/decision.ts +27 -0
- package/src/core/rate-limit-policy.ts +15 -0
- package/src/core/rate-limiter-status.ts +14 -0
- package/src/core/rate-limiter.ts +37 -0
- package/src/core/state-storage.ts +51 -0
- package/src/enums/rate-limit-error-code.ts +29 -0
- package/src/errors/custom.error.ts +14 -0
- package/src/errors/invalid-cost.error.ts +33 -0
- package/src/errors/rate-limit.error.ts +91 -0
- package/src/errors/rate-limiter-destroyed.error.ts +8 -0
- package/src/index.ts +11 -0
- package/src/interfaces/rate-limiter-options.ts +84 -0
- package/src/interfaces/rate-limiter-queue-options.ts +45 -0
- package/src/interfaces/rate-limiter-run-options.ts +58 -0
- package/src/limiters/abstract-rate-limiter.ts +206 -0
- package/src/limiters/composite.policy.ts +102 -0
- package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
- package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
- package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
- package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
- package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
- package/src/limiters/fixed-window/index.ts +4 -0
- package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
- package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
- package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
- package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
- package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
- package/src/limiters/generic-cell/index.ts +4 -0
- package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
- package/src/limiters/http-response-based/http-limit.info.ts +41 -0
- package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
- package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
- package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
- package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
- package/src/limiters/http-response-based/index.ts +6 -0
- package/src/limiters/leaky-bucket/index.ts +4 -0
- package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
- package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
- package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
- package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
- package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
- package/src/limiters/sliding-window-counter/index.ts +7 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
- package/src/limiters/sliding-window-log/index.ts +4 -0
- package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
- package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
- package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
- package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
- package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
- package/src/limiters/token-bucket/index.ts +4 -0
- package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
- package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
- package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
- package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
- package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
- package/src/runtime/default-clock.ts +8 -0
- package/src/runtime/execution-tickets.ts +34 -0
- package/src/runtime/in-memory-state-store.ts +135 -0
- package/src/runtime/rate-limiter.executor.ts +286 -0
- package/src/runtime/semaphore.ts +31 -0
- package/src/runtime/task.ts +141 -0
- package/src/types/limit-behavior.ts +8 -0
- package/src/utils/generate-random-string.ts +16 -0
- package/src/utils/promise-with-resolvers.ts +23 -0
- package/src/utils/sanitize-error.ts +4 -0
- package/src/utils/sanitize-priority.ts +22 -0
- package/src/utils/validate-cost.ts +16 -0
- package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
- package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
- package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
- package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
- package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
- package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
- package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
- package/tests/tsconfig.json +4 -0
- package/tests/unit/policies/composite.policy.spec.ts +244 -0
- package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
- package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
- package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
- package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
- package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
- package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
- package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
- package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
- package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
- package/tests/unit/runtime/semaphore.spec.ts +98 -0
- package/tests/unit/runtime/task.spec.ts +182 -0
- package/tests/unit/utils/generate-random-string.spec.ts +51 -0
- package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
- package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
- package/tests/unit/utils/validate-cost.spec.ts +48 -0
- package/tsconfig.json +14 -0
- package/vitest.config.js +22 -0
package/.editorconfig
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
root = true
|
|
2
|
+
|
|
3
|
+
[*]
|
|
4
|
+
end_of_line = lf
|
|
5
|
+
insert_final_newline = true
|
|
6
|
+
charset = utf-8
|
|
7
|
+
indent_style = tab
|
|
8
|
+
tab_width = 4
|
|
9
|
+
max_line_length = 120
|
|
10
|
+
|
|
11
|
+
[*.json]
|
|
12
|
+
indent_style = tab
|
|
13
|
+
indent_size = 4
|
|
14
|
+
|
|
15
|
+
[package.json]
|
|
16
|
+
indent_style = space
|
|
17
|
+
indent_size = 2
|
|
18
|
+
|
|
19
|
+
[*.yml]
|
|
20
|
+
indent_style = space
|
|
21
|
+
indent_size = 2
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
name: Node.js CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
checks:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Install PNPM
|
|
17
|
+
uses: pnpm/action-setup@v4
|
|
18
|
+
with:
|
|
19
|
+
run_install: false
|
|
20
|
+
|
|
21
|
+
- name: Setup Node.js environment
|
|
22
|
+
uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 24
|
|
25
|
+
cache: pnpm
|
|
26
|
+
|
|
27
|
+
- name: Install deps
|
|
28
|
+
run: pnpm install --frozen-lockfile
|
|
29
|
+
|
|
30
|
+
- name: Check formatting
|
|
31
|
+
run: pnpm run format:check
|
|
32
|
+
|
|
33
|
+
- name: Check types
|
|
34
|
+
run: pnpm run check-types
|
|
35
|
+
|
|
36
|
+
- name: Lint
|
|
37
|
+
run: pnpm run lint
|
|
38
|
+
|
|
39
|
+
build:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- name: Checkout
|
|
43
|
+
uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- name: Install PNPM
|
|
46
|
+
uses: pnpm/action-setup@v4
|
|
47
|
+
with:
|
|
48
|
+
run_install: false
|
|
49
|
+
|
|
50
|
+
- name: Setup Node.js environment
|
|
51
|
+
uses: actions/setup-node@v4
|
|
52
|
+
with:
|
|
53
|
+
node-version: 24
|
|
54
|
+
cache: pnpm
|
|
55
|
+
|
|
56
|
+
- name: Install deps
|
|
57
|
+
run: pnpm install --frozen-lockfile
|
|
58
|
+
|
|
59
|
+
- name: Build
|
|
60
|
+
run: pnpm run build
|
|
61
|
+
|
|
62
|
+
test:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
strategy:
|
|
65
|
+
matrix:
|
|
66
|
+
node-version: [ 20, 22, 24 ]
|
|
67
|
+
|
|
68
|
+
steps:
|
|
69
|
+
- name: Checkout
|
|
70
|
+
uses: actions/checkout@v4
|
|
71
|
+
|
|
72
|
+
- name: Install PNPM
|
|
73
|
+
uses: pnpm/action-setup@v4
|
|
74
|
+
with:
|
|
75
|
+
run_install: false
|
|
76
|
+
|
|
77
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
78
|
+
uses: actions/setup-node@v4
|
|
79
|
+
with:
|
|
80
|
+
node-version: ${{ matrix.node-version }}
|
|
81
|
+
cache: pnpm
|
|
82
|
+
|
|
83
|
+
- name: Install deps
|
|
84
|
+
run: pnpm install --frozen-lockfile
|
|
85
|
+
|
|
86
|
+
- name: Run tests
|
|
87
|
+
run: pnpm run test
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx --no -- commitlint -e $1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx lint-staged
|
package/.megaignore
ADDED
package/.prettierignore
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stimul Cross
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Rate Limiters
|
|
2
|
+
|
|
3
|
+
A collection of rate limiters designed primarily for **outbound request throttling**.
|
|
4
|
+
|
|
5
|
+
They are suited for client-side usage to respect third-party limits or protect internal resources.
|
|
6
|
+
|
|
7
|
+
While it is technically possible to use these limiters for server-side traffic backed by a distributed store like Redis, it is **not recommended**. The algorithms evaluate state within the application process, so distributed usage requires multiple network operations per request introducing significant round-trip latency.
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolveFlatConfig } from '@leancodepl/resolve-eslint-flat-config';
|
|
2
|
+
import typescript from '@stimulcross/eslint-config-typescript';
|
|
3
|
+
import typescriptStyle from '@stimulcross/eslint-config-typescript/style';
|
|
4
|
+
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
5
|
+
import globals from 'globals';
|
|
6
|
+
|
|
7
|
+
export const globs = {
|
|
8
|
+
js: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
|
9
|
+
ts: ['**/*.ts', '**/*.cts', '**/*.mts'],
|
|
10
|
+
jsSpec: ['**/*.spec.js', '**/*.spec.cjs', '**/*.spec.mjs'],
|
|
11
|
+
tsSpec: ['**/*.spec.ts', '**/*.spec.cts', '**/*.spec.mts'],
|
|
12
|
+
lib: '**/dist',
|
|
13
|
+
nodeModules: '**/node_modules',
|
|
14
|
+
coverage: '**/coverage',
|
|
15
|
+
dts: '**/*.d.ts',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** @type {import("eslint").Linter.Config[]} */
|
|
19
|
+
export const config = resolveFlatConfig(
|
|
20
|
+
defineConfig(
|
|
21
|
+
globalIgnores([globs.lib, globs.nodeModules, globs.dts, globs.coverage]),
|
|
22
|
+
{
|
|
23
|
+
files: [...globs.js, ...globs.ts, ...globs.jsSpec, ...globs.tsSpec],
|
|
24
|
+
languageOptions: {
|
|
25
|
+
globals: {
|
|
26
|
+
...globals.node,
|
|
27
|
+
...globals.es2022,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
files: [...globs.js, ...globs.ts, ...globs.tsSpec],
|
|
33
|
+
extends: [typescript, typescriptStyle],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
files: [...globs.ts, ...globs.tsSpec],
|
|
37
|
+
rules: {
|
|
38
|
+
'id-length': 'off',
|
|
39
|
+
'no-await-in-loop': 'off',
|
|
40
|
+
'unicorn/no-new-array': 'off',
|
|
41
|
+
'unicorn/no-thenable': 'off',
|
|
42
|
+
'unicorn/no-useless-undefined': 'error',
|
|
43
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
44
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
45
|
+
'@typescript-eslint/no-unnecessary-condition': ['warn', { allowConstantLoopConditions: true }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
files: [...globs.jsSpec, ...globs.tsSpec],
|
|
50
|
+
rules: {
|
|
51
|
+
'id-length': 'off',
|
|
52
|
+
'max-nested-callbacks': 'off',
|
|
53
|
+
'unicorn/consistent-function-scoping': 'off',
|
|
54
|
+
'unicorn/no-array-push-push': 'off',
|
|
55
|
+
'@typescript-eslint/naming-convention': 'off',
|
|
56
|
+
'@typescript-eslint/no-empty-function': 'off',
|
|
57
|
+
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
58
|
+
'@typescript-eslint/no-unsafe-return': 'off',
|
|
59
|
+
'@typescript-eslint/unbound-method': 'off',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export default config;
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stimulcross/rate-limiter",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A collection of client-side rate limiters for Node.js and browsers.",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=20"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./lib/index.js",
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"default": "./lib/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./fixed-window": {
|
|
17
|
+
"types": "./lib/limiters/fixed-window/index.d.ts",
|
|
18
|
+
"default": "./lib/limiters/fixed-window/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./sliding-window-counter": {
|
|
21
|
+
"types": "./lib/limiters/sliding-window-counter/index.d.ts",
|
|
22
|
+
"default": "./lib/limiters/sliding-window-counter/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./sliding-window-log": {
|
|
25
|
+
"types": "./lib/limiters/sliding-window-log/index.d.ts",
|
|
26
|
+
"default": "./lib/limiters/sliding-window-log/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./token-bucket": {
|
|
29
|
+
"types": "./lib/limiters/token-bucket/index.d.ts",
|
|
30
|
+
"default": "./lib/limiters/token-bucket/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./leaky-bucket": {
|
|
33
|
+
"types": "./lib/limiters/leaky-bucket/index.d.ts",
|
|
34
|
+
"default": "./lib/limiters/leaky-bucket/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./generic-cell": {
|
|
37
|
+
"types": "./lib/limiters/generic-cell/index.d.ts",
|
|
38
|
+
"default": "./lib/limiters/generic-cell/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./http-response-based": {
|
|
41
|
+
"types": "./lib/limiters/http-response-based/index.d.ts",
|
|
42
|
+
"default": "./lib/limiters/http-response-based/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@stimulcross/ds-binary-heap": "^0.1.0",
|
|
47
|
+
"@stimulcross/ds-deque": "^0.1.0",
|
|
48
|
+
"@stimulcross/ds-policy-priority-queue": "^0.2.2",
|
|
49
|
+
"@stimulcross/logger": "^8.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@commitlint/cli": "^20.4.4",
|
|
53
|
+
"@leancodepl/resolve-eslint-flat-config": "^9.7.4",
|
|
54
|
+
"@stimulcross/commitlint-config": "^2.0.0",
|
|
55
|
+
"@stimulcross/eslint-config-node": "^2.0.0",
|
|
56
|
+
"@stimulcross/eslint-config-typescript": "^2.0.0",
|
|
57
|
+
"@stimulcross/prettier-config": "^2.0.0",
|
|
58
|
+
"@types/node": "^25.5.0",
|
|
59
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
60
|
+
"del-cli": "^7.0.0",
|
|
61
|
+
"eslint": "^9.39.4",
|
|
62
|
+
"globals": "^17.4.0",
|
|
63
|
+
"husky": "^9.1.7",
|
|
64
|
+
"lint-staged": "^16.4.0",
|
|
65
|
+
"prettier": "^3.8.1",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"vitest": "^4.1.0"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "tsc -p tsconfig.json",
|
|
74
|
+
"rebuild": "pnpm run clean && pnpm run build",
|
|
75
|
+
"clean": "del-cli lib *.tsbuildinfo",
|
|
76
|
+
"lint": "eslint src tests",
|
|
77
|
+
"lint:fix": "eslint src tests --fix",
|
|
78
|
+
"format:check": "prettier --config \"prettier.config.cjs\" --check src tests",
|
|
79
|
+
"format:fix": "prettier --config \"prettier.config.cjs\" --write src tests",
|
|
80
|
+
"check-types": "tsc -p tsconfig.json --noEmit",
|
|
81
|
+
"preversion": "pnpm run format:check && pnpm run lint && pnpm run test && pnpm run rebuild",
|
|
82
|
+
"postversion": "pnpm i",
|
|
83
|
+
"test": "vitest run",
|
|
84
|
+
"test:cov": "vitest run --coverage",
|
|
85
|
+
"test:watch": "vitest watch",
|
|
86
|
+
"test:verbose": "vitest --run --reporter verbose --logHeapUsage",
|
|
87
|
+
"test:watch:cov": "vitest watch --coverage"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('@stimulcross/prettier-config');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export type DecisionKind = 'allow' | 'deny' | 'delay';
|
|
3
|
+
|
|
4
|
+
/** @internal */
|
|
5
|
+
export interface DecisionBase {
|
|
6
|
+
kind: DecisionKind;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @internal */
|
|
10
|
+
export interface DecisionAllow extends DecisionBase {
|
|
11
|
+
kind: Extract<DecisionKind, 'allow'>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @internal */
|
|
15
|
+
export interface DecisionDeny extends DecisionBase {
|
|
16
|
+
kind: Extract<DecisionKind, 'deny'>;
|
|
17
|
+
retryAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @internal */
|
|
21
|
+
export interface DecisionDelay extends DecisionBase {
|
|
22
|
+
kind: Extract<DecisionKind, 'delay'>;
|
|
23
|
+
runAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @internal */
|
|
27
|
+
export type Decision = DecisionAllow | DecisionDeny | DecisionDelay;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Decision } from './decision.js';
|
|
2
|
+
|
|
3
|
+
/** @internal */
|
|
4
|
+
export interface RateLimitPolicyResult<S> {
|
|
5
|
+
decision: Decision;
|
|
6
|
+
nextState: S;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** @internal */
|
|
10
|
+
export interface RateLimitPolicy<TState extends object = object, TStatus extends object = object> {
|
|
11
|
+
getInitialState(now: number): TState;
|
|
12
|
+
getStatus(state: TState, now: number): TStatus;
|
|
13
|
+
evaluate(state: TState, now: number, cost: number, shouldReserve?: boolean): RateLimitPolicyResult<TState>;
|
|
14
|
+
revert(state: TState, cost: number, now: number): TState;
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status of the rate limiter.
|
|
3
|
+
*/
|
|
4
|
+
export interface RateLimiterStatus {
|
|
5
|
+
/**
|
|
6
|
+
* The timestamp (in milliseconds) when a rate limiter will allow a single request.
|
|
7
|
+
*/
|
|
8
|
+
readonly nextAvailableAt: number;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The timestamp (in milliseconds) when the rate limiter will reset.
|
|
12
|
+
*/
|
|
13
|
+
readonly resetAt: number;
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type RateLimiterRunOptions } from '../interfaces/rate-limiter-run-options.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rate limiter interface.
|
|
5
|
+
*
|
|
6
|
+
* @template TStatus The type of the rate limiter status returned by {@link getStatus} method.
|
|
7
|
+
*/
|
|
8
|
+
export interface RateLimiter<TStatus extends object = object> {
|
|
9
|
+
/**
|
|
10
|
+
* Runs the given task.
|
|
11
|
+
*
|
|
12
|
+
* @param task The task to run.
|
|
13
|
+
* @param options Options for running the task.
|
|
14
|
+
*/
|
|
15
|
+
run<T>(task: () => T | Promise<T>, options?: RateLimiterRunOptions): Promise<T>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clears the rate limiter state.
|
|
19
|
+
*
|
|
20
|
+
* @param key The optional key to clear the state for.
|
|
21
|
+
*/
|
|
22
|
+
clear(key?: string): Promise<void>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gets the rate limiter's status.
|
|
26
|
+
*
|
|
27
|
+
* @param key The optional key to get the status for.
|
|
28
|
+
*/
|
|
29
|
+
getStatus?(key?: string): Promise<TStatus>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Destroys the rate limiter.
|
|
33
|
+
*
|
|
34
|
+
* The limiter cannot be used after it has been destroyed. It should be used only for graceful shutdown.
|
|
35
|
+
*/
|
|
36
|
+
destroy?(): Promise<void>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State storage interface.
|
|
3
|
+
*/
|
|
4
|
+
export interface StateStorage<TState> {
|
|
5
|
+
/**
|
|
6
|
+
* Gets the state for the given key.
|
|
7
|
+
*
|
|
8
|
+
* @param key The key to get the state for.
|
|
9
|
+
*/
|
|
10
|
+
get(key: string): Promise<TState | null>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sets the state for the given key.
|
|
14
|
+
*
|
|
15
|
+
* @param key The key to set the state for.
|
|
16
|
+
* @param value The state to set.
|
|
17
|
+
* @param ttlMs Optional TTL in milliseconds.
|
|
18
|
+
*/
|
|
19
|
+
set(key: string, value: TState, ttlMs?: number): Promise<void>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deletes the state for the given key.
|
|
23
|
+
*
|
|
24
|
+
* @param key The key to delete the state for.
|
|
25
|
+
*/
|
|
26
|
+
delete(key: string): Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Clears all stored states.
|
|
30
|
+
*/
|
|
31
|
+
clear(): Promise<void>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Destroys the storage.
|
|
35
|
+
*/
|
|
36
|
+
destroy?(): Promise<void>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Acquires a lock for the given key.
|
|
40
|
+
*
|
|
41
|
+
* @param key The key to acquire the lock for.
|
|
42
|
+
*/
|
|
43
|
+
acquireLock?(key: string): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Releases the lock for the given key.
|
|
47
|
+
*
|
|
48
|
+
* @param key The key to release the lock for.
|
|
49
|
+
*/
|
|
50
|
+
releaseLock?(key: string): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter error codes.
|
|
3
|
+
*/
|
|
4
|
+
export enum RateLimitErrorCode {
|
|
5
|
+
/**
|
|
6
|
+
* Indicates that the limit has been reached.
|
|
7
|
+
*/
|
|
8
|
+
LimitExceeded = 'LIMIT_EXCEEDED',
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Indicates that the execution queue is full.
|
|
12
|
+
*/
|
|
13
|
+
QueueOverflow = 'QUEUE_OVERFLOW',
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Indicates that the task has expired.
|
|
17
|
+
*/
|
|
18
|
+
Expired = 'EXPIRED',
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Indicates that a task was cleared before it was executed.
|
|
22
|
+
*/
|
|
23
|
+
Destroyed = 'DESTROYED',
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Indicates that a task was canceled via abort controller before it was executed.
|
|
27
|
+
*/
|
|
28
|
+
Cancelled = 'CANCELLED',
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export abstract class CustomError extends Error {
|
|
3
|
+
protected constructor(message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
|
|
6
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
8
|
+
Error.captureStackTrace?.(this, new.target.constructor);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public get name(): string {
|
|
12
|
+
return this.constructor.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CustomError } from './custom.error.js';
|
|
2
|
+
|
|
3
|
+
export interface InvalidCostErrorPlainObject extends Error {
|
|
4
|
+
cost: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Error thrown when the cost is invalid.
|
|
9
|
+
*
|
|
10
|
+
* The cost must be a positive integer or zero.
|
|
11
|
+
*/
|
|
12
|
+
export class InvalidCostError extends CustomError {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
private readonly _cost: number,
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public get cost(): number {
|
|
21
|
+
return this._cost;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
25
|
+
public toJSON(): InvalidCostErrorPlainObject {
|
|
26
|
+
return {
|
|
27
|
+
name: this.name,
|
|
28
|
+
message: this.message,
|
|
29
|
+
cost: this._cost,
|
|
30
|
+
stack: this.stack,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
|
|
2
|
+
|
|
3
|
+
export interface RateLimitErrorPlainObject extends Error {
|
|
4
|
+
code: RateLimitErrorCode;
|
|
5
|
+
retryAt: number | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An error thrown when a rate limit is exceeded.
|
|
10
|
+
*
|
|
11
|
+
* This error has a {@link code} property that indicates the type of error.
|
|
12
|
+
*
|
|
13
|
+
* The `code` can be:
|
|
14
|
+
* - `LIMIT_EXCEEDED` - When the rate limit is exceeded.
|
|
15
|
+
* - `QUEUE_OVERFLOW` - When the queue is full (if the limiter has a queue and the capacity has been exceeded).
|
|
16
|
+
* - `EXPIRED` - When the task has expired (waited too long in the queue). This is related to the `maxWaitMs` option.
|
|
17
|
+
* **NOTE:** This is never thrown if the task is executing too long. Such scenarios should be handled by the
|
|
18
|
+
* task itself.
|
|
19
|
+
* - `DESTROYED` - When the task is destroyed due to the rate limiter's `clear()` or `destroy()` methods.
|
|
20
|
+
* - `CANCELLED` - When the task is cancelled using an abort signal.
|
|
21
|
+
*/
|
|
22
|
+
export class RateLimitError extends Error {
|
|
23
|
+
private readonly _code: RateLimitErrorCode;
|
|
24
|
+
private readonly _retryAt: number | null;
|
|
25
|
+
|
|
26
|
+
/** @internal */
|
|
27
|
+
constructor(code: RateLimitErrorCode, retryAt?: number, message?: string) {
|
|
28
|
+
if (!message) {
|
|
29
|
+
switch (code) {
|
|
30
|
+
case RateLimitErrorCode.LimitExceeded: {
|
|
31
|
+
message = `Rate limit exceeded.${retryAt ? ` Retry at ${new Date(retryAt).toISOString()}.` : ''}`;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case RateLimitErrorCode.QueueOverflow: {
|
|
36
|
+
message = 'Queue overflow.';
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case RateLimitErrorCode.Expired: {
|
|
41
|
+
message = 'Task expired.';
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case RateLimitErrorCode.Destroyed: {
|
|
46
|
+
message = 'Task destroyed.';
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case RateLimitErrorCode.Cancelled: {
|
|
51
|
+
message = 'Task cancelled.';
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No default
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
super(message);
|
|
60
|
+
|
|
61
|
+
this._code = code;
|
|
62
|
+
this._retryAt = retryAt ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The error code.
|
|
67
|
+
*/
|
|
68
|
+
public get code(): RateLimitErrorCode {
|
|
69
|
+
return this._code;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The timestamp (in milliseconds) when the task can be retried.
|
|
74
|
+
*
|
|
75
|
+
* Can be `null` if the retry time is not known.
|
|
76
|
+
*/
|
|
77
|
+
public get retryAt(): number | null {
|
|
78
|
+
return this._retryAt ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
82
|
+
public toJSON(): RateLimitErrorPlainObject {
|
|
83
|
+
return {
|
|
84
|
+
name: this.name,
|
|
85
|
+
message: this.message,
|
|
86
|
+
code: this._code,
|
|
87
|
+
retryAt: this._retryAt,
|
|
88
|
+
stack: this.stack,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|