@stimulcross/rate-limiter 0.0.1 → 0.0.2
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/README.md +20 -0
- package/lib/core/cancellable.js +1 -0
- package/lib/core/clock.js +1 -0
- package/lib/core/decision.js +1 -0
- package/lib/core/rate-limit-policy.js +1 -0
- package/lib/core/rate-limiter-status.js +1 -0
- package/lib/core/rate-limiter.js +1 -0
- package/lib/core/state-storage.js +1 -0
- package/lib/enums/rate-limit-error-code.js +26 -0
- package/lib/errors/custom.error.js +12 -0
- package/lib/errors/invalid-cost.error.js +25 -0
- package/lib/errors/rate-limit.error.js +74 -0
- package/{src/errors/rate-limiter-destroyed.error.ts → lib/errors/rate-limiter-destroyed.error.js} +3 -3
- package/lib/index.js +4 -0
- package/lib/interfaces/rate-limiter-options.js +1 -0
- package/lib/interfaces/rate-limiter-queue-options.js +1 -0
- package/lib/interfaces/rate-limiter-run-options.js +1 -0
- package/lib/limiters/abstract-rate-limiter.js +132 -0
- package/lib/limiters/composite.policy.js +72 -0
- package/lib/limiters/fixed-window/fixed-window.limiter.js +84 -0
- package/lib/limiters/fixed-window/fixed-window.options.js +1 -0
- package/lib/limiters/fixed-window/fixed-window.policy.js +120 -0
- package/lib/limiters/fixed-window/fixed-window.state.js +1 -0
- package/lib/limiters/fixed-window/fixed-window.status.js +1 -0
- package/lib/limiters/fixed-window/index.js +1 -0
- package/lib/limiters/generic-cell/generic-cell.limiter.js +73 -0
- package/lib/limiters/generic-cell/generic-cell.options.js +1 -0
- package/lib/limiters/generic-cell/generic-cell.policy.js +86 -0
- package/lib/limiters/generic-cell/generic-cell.state.js +1 -0
- package/lib/limiters/generic-cell/generic-cell.status.js +1 -0
- package/lib/limiters/generic-cell/index.js +1 -0
- package/lib/limiters/http-response-based/http-limit-info.extractor.js +1 -0
- package/lib/limiters/http-response-based/http-limit.info.js +1 -0
- package/lib/limiters/http-response-based/http-response-based-limiter.options.js +1 -0
- package/lib/limiters/http-response-based/http-response-based-limiter.state.js +1 -0
- package/lib/limiters/http-response-based/http-response-based-limiter.status.js +1 -0
- package/lib/limiters/http-response-based/http-response-based.limiter.js +379 -0
- package/lib/limiters/http-response-based/index.js +1 -0
- package/lib/limiters/leaky-bucket/index.js +1 -0
- package/lib/limiters/leaky-bucket/leaky-bucket.limiter.js +74 -0
- package/lib/limiters/leaky-bucket/leaky-bucket.options.js +1 -0
- package/lib/limiters/leaky-bucket/leaky-bucket.policy.js +100 -0
- package/lib/limiters/leaky-bucket/leaky-bucket.state.js +1 -0
- package/lib/limiters/leaky-bucket/leaky-bucket.status.js +1 -0
- package/lib/limiters/sliding-window-counter/index.js +1 -0
- package/lib/limiters/sliding-window-counter/sliding-window-counter.limiter.js +46 -0
- package/lib/limiters/sliding-window-counter/sliding-window-counter.options.js +1 -0
- package/lib/limiters/sliding-window-counter/sliding-window-counter.policy.js +127 -0
- package/lib/limiters/sliding-window-counter/sliding-window-counter.state.js +1 -0
- package/lib/limiters/sliding-window-counter/sliding-window-counter.status.js +1 -0
- package/lib/limiters/sliding-window-log/index.js +1 -0
- package/lib/limiters/sliding-window-log/sliding-window-log.limiter.js +43 -0
- package/lib/limiters/sliding-window-log/sliding-window-log.options.js +1 -0
- package/lib/limiters/sliding-window-log/sliding-window-log.policy.js +123 -0
- package/lib/limiters/sliding-window-log/sliding-window-log.state.js +1 -0
- package/lib/limiters/sliding-window-log/sliding-window-log.status.js +1 -0
- package/lib/limiters/token-bucket/index.js +1 -0
- package/lib/limiters/token-bucket/token-bucket.limiter.js +74 -0
- package/lib/limiters/token-bucket/token-bucket.options.js +1 -0
- package/lib/limiters/token-bucket/token-bucket.policy.js +115 -0
- package/lib/limiters/token-bucket/token-bucket.state.js +1 -0
- package/lib/limiters/token-bucket/token-bucket.status.js +1 -0
- package/lib/runtime/default-clock.js +6 -0
- package/lib/runtime/execution-tickets.js +26 -0
- package/lib/runtime/in-memory-state-store.js +96 -0
- package/lib/runtime/rate-limiter.executor.js +195 -0
- package/lib/runtime/semaphore.js +27 -0
- package/lib/runtime/task.js +100 -0
- package/lib/types/limit-behavior.js +1 -0
- package/lib/utils/generate-random-string.js +12 -0
- package/lib/utils/promise-with-resolvers.js +14 -0
- package/lib/utils/sanitize-error.js +4 -0
- package/lib/utils/sanitize-priority.js +17 -0
- package/lib/utils/validate-cost.js +13 -0
- package/package.json +12 -2
- package/.editorconfig +0 -21
- package/.github/workflows/node.yml +0 -87
- package/.husky/commit-msg +0 -1
- package/.husky/pre-commit +0 -1
- package/.megaignore +0 -8
- package/.prettierignore +0 -3
- package/commitlint.config.js +0 -8
- package/eslint.config.js +0 -65
- package/lint-staged.config.js +0 -4
- package/prettier.config.cjs +0 -1
- package/src/core/cancellable.ts +0 -4
- package/src/core/clock.ts +0 -9
- package/src/core/decision.ts +0 -27
- package/src/core/rate-limit-policy.ts +0 -15
- package/src/core/rate-limiter-status.ts +0 -14
- package/src/core/rate-limiter.ts +0 -37
- package/src/core/state-storage.ts +0 -51
- package/src/enums/rate-limit-error-code.ts +0 -29
- package/src/errors/custom.error.ts +0 -14
- package/src/errors/invalid-cost.error.ts +0 -33
- package/src/errors/rate-limit.error.ts +0 -91
- package/src/index.ts +0 -11
- package/src/interfaces/rate-limiter-options.ts +0 -84
- package/src/interfaces/rate-limiter-queue-options.ts +0 -45
- package/src/interfaces/rate-limiter-run-options.ts +0 -58
- package/src/limiters/abstract-rate-limiter.ts +0 -206
- package/src/limiters/composite.policy.ts +0 -102
- package/src/limiters/fixed-window/fixed-window.limiter.ts +0 -121
- package/src/limiters/fixed-window/fixed-window.options.ts +0 -29
- package/src/limiters/fixed-window/fixed-window.policy.ts +0 -159
- package/src/limiters/fixed-window/fixed-window.state.ts +0 -10
- package/src/limiters/fixed-window/fixed-window.status.ts +0 -46
- package/src/limiters/fixed-window/index.ts +0 -4
- package/src/limiters/generic-cell/generic-cell.limiter.ts +0 -108
- package/src/limiters/generic-cell/generic-cell.options.ts +0 -23
- package/src/limiters/generic-cell/generic-cell.policy.ts +0 -115
- package/src/limiters/generic-cell/generic-cell.state.ts +0 -8
- package/src/limiters/generic-cell/generic-cell.status.ts +0 -54
- package/src/limiters/generic-cell/index.ts +0 -4
- package/src/limiters/http-response-based/http-limit-info.extractor.ts +0 -20
- package/src/limiters/http-response-based/http-limit.info.ts +0 -41
- package/src/limiters/http-response-based/http-response-based-limiter.options.ts +0 -18
- package/src/limiters/http-response-based/http-response-based-limiter.state.ts +0 -13
- package/src/limiters/http-response-based/http-response-based-limiter.status.ts +0 -74
- package/src/limiters/http-response-based/http-response-based.limiter.ts +0 -512
- package/src/limiters/http-response-based/index.ts +0 -6
- package/src/limiters/leaky-bucket/index.ts +0 -4
- package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +0 -105
- package/src/limiters/leaky-bucket/leaky-bucket.options.ts +0 -23
- package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +0 -134
- package/src/limiters/leaky-bucket/leaky-bucket.state.ts +0 -9
- package/src/limiters/leaky-bucket/leaky-bucket.status.ts +0 -36
- package/src/limiters/sliding-window-counter/index.ts +0 -7
- package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +0 -76
- package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +0 -20
- package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +0 -167
- package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +0 -10
- package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +0 -53
- package/src/limiters/sliding-window-log/index.ts +0 -4
- package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +0 -65
- package/src/limiters/sliding-window-log/sliding-window-log.options.ts +0 -20
- package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +0 -166
- package/src/limiters/sliding-window-log/sliding-window-log.state.ts +0 -19
- package/src/limiters/sliding-window-log/sliding-window-log.status.ts +0 -44
- package/src/limiters/token-bucket/index.ts +0 -4
- package/src/limiters/token-bucket/token-bucket.limiter.ts +0 -110
- package/src/limiters/token-bucket/token-bucket.options.ts +0 -17
- package/src/limiters/token-bucket/token-bucket.policy.ts +0 -155
- package/src/limiters/token-bucket/token-bucket.state.ts +0 -10
- package/src/limiters/token-bucket/token-bucket.status.ts +0 -36
- package/src/runtime/default-clock.ts +0 -8
- package/src/runtime/execution-tickets.ts +0 -34
- package/src/runtime/in-memory-state-store.ts +0 -135
- package/src/runtime/rate-limiter.executor.ts +0 -286
- package/src/runtime/semaphore.ts +0 -31
- package/src/runtime/task.ts +0 -141
- package/src/types/limit-behavior.ts +0 -8
- package/src/utils/generate-random-string.ts +0 -16
- package/src/utils/promise-with-resolvers.ts +0 -23
- package/src/utils/sanitize-error.ts +0 -4
- package/src/utils/sanitize-priority.ts +0 -22
- package/src/utils/validate-cost.ts +0 -16
- package/tests/integration/limiters/fixed-window.limiter.spec.ts +0 -371
- package/tests/integration/limiters/generic-cell.limiter.spec.ts +0 -361
- package/tests/integration/limiters/http-response-based.limiter.spec.ts +0 -833
- package/tests/integration/limiters/leaky-bucket.spec.ts +0 -357
- package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +0 -175
- package/tests/integration/limiters/sliding-window-log.spec.ts +0 -185
- package/tests/integration/limiters/token-bucket.limiter.spec.ts +0 -363
- package/tests/tsconfig.json +0 -4
- package/tests/unit/policies/composite.policy.spec.ts +0 -244
- package/tests/unit/policies/fixed-window.policy.spec.ts +0 -260
- package/tests/unit/policies/generic-cell.policy.spec.ts +0 -178
- package/tests/unit/policies/leaky-bucket.policy.spec.ts +0 -215
- package/tests/unit/policies/sliding-window-counter.policy.spec.ts +0 -209
- package/tests/unit/policies/sliding-window-log.policy.spec.ts +0 -285
- package/tests/unit/policies/token-bucket.policy.spec.ts +0 -371
- package/tests/unit/runtime/execution-tickets.spec.ts +0 -121
- package/tests/unit/runtime/in-memory-state-store.spec.ts +0 -238
- package/tests/unit/runtime/rate-limiter.executor.spec.ts +0 -353
- package/tests/unit/runtime/semaphore.spec.ts +0 -98
- package/tests/unit/runtime/task.spec.ts +0 -182
- package/tests/unit/utils/generate-random-string.spec.ts +0 -51
- package/tests/unit/utils/promise-with-resolvers.spec.ts +0 -57
- package/tests/unit/utils/sanitize-priority.spec.ts +0 -46
- package/tests/unit/utils/validate-cost.spec.ts +0 -48
- package/tsconfig.json +0 -14
- package/vitest.config.js +0 -22
package/README.md
CHANGED
|
@@ -5,3 +5,23 @@ A collection of rate limiters designed primarily for **outbound request throttli
|
|
|
5
5
|
They are suited for client-side usage to respect third-party limits or protect internal resources.
|
|
6
6
|
|
|
7
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.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
**npm**
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npm add @stimulcross/rate-limiter
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**pnpm**
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pnpm add @stimulcross/rate-limiter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**yarn**
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
yarn add @stimulcross/rate-limiter
|
|
27
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter error codes.
|
|
3
|
+
*/
|
|
4
|
+
export var RateLimitErrorCode;
|
|
5
|
+
(function (RateLimitErrorCode) {
|
|
6
|
+
/**
|
|
7
|
+
* Indicates that the limit has been reached.
|
|
8
|
+
*/
|
|
9
|
+
RateLimitErrorCode["LimitExceeded"] = "LIMIT_EXCEEDED";
|
|
10
|
+
/**
|
|
11
|
+
* Indicates that the execution queue is full.
|
|
12
|
+
*/
|
|
13
|
+
RateLimitErrorCode["QueueOverflow"] = "QUEUE_OVERFLOW";
|
|
14
|
+
/**
|
|
15
|
+
* Indicates that the task has expired.
|
|
16
|
+
*/
|
|
17
|
+
RateLimitErrorCode["Expired"] = "EXPIRED";
|
|
18
|
+
/**
|
|
19
|
+
* Indicates that a task was cleared before it was executed.
|
|
20
|
+
*/
|
|
21
|
+
RateLimitErrorCode["Destroyed"] = "DESTROYED";
|
|
22
|
+
/**
|
|
23
|
+
* Indicates that a task was canceled via abort controller before it was executed.
|
|
24
|
+
*/
|
|
25
|
+
RateLimitErrorCode["Cancelled"] = "CANCELLED";
|
|
26
|
+
})(RateLimitErrorCode || (RateLimitErrorCode = {}));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export class CustomError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
7
|
+
Error.captureStackTrace?.(this, new.target.constructor);
|
|
8
|
+
}
|
|
9
|
+
get name() {
|
|
10
|
+
return this.constructor.name;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CustomError } from './custom.error.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error thrown when the cost is invalid.
|
|
4
|
+
*
|
|
5
|
+
* The cost must be a positive integer or zero.
|
|
6
|
+
*/
|
|
7
|
+
export class InvalidCostError extends CustomError {
|
|
8
|
+
_cost;
|
|
9
|
+
constructor(message, _cost) {
|
|
10
|
+
super(message);
|
|
11
|
+
this._cost = _cost;
|
|
12
|
+
}
|
|
13
|
+
get cost() {
|
|
14
|
+
return this._cost;
|
|
15
|
+
}
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
17
|
+
toJSON() {
|
|
18
|
+
return {
|
|
19
|
+
name: this.name,
|
|
20
|
+
message: this.message,
|
|
21
|
+
cost: this._cost,
|
|
22
|
+
stack: this.stack,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
|
|
2
|
+
/**
|
|
3
|
+
* An error thrown when a rate limit is exceeded.
|
|
4
|
+
*
|
|
5
|
+
* This error has a {@link code} property that indicates the type of error.
|
|
6
|
+
*
|
|
7
|
+
* The `code` can be:
|
|
8
|
+
* - `LIMIT_EXCEEDED` - When the rate limit is exceeded.
|
|
9
|
+
* - `QUEUE_OVERFLOW` - When the queue is full (if the limiter has a queue and the capacity has been exceeded).
|
|
10
|
+
* - `EXPIRED` - When the task has expired (waited too long in the queue). This is related to the `maxWaitMs` option.
|
|
11
|
+
* **NOTE:** This is never thrown if the task is executing too long. Such scenarios should be handled by the
|
|
12
|
+
* task itself.
|
|
13
|
+
* - `DESTROYED` - When the task is destroyed due to the rate limiter's `clear()` or `destroy()` methods.
|
|
14
|
+
* - `CANCELLED` - When the task is cancelled using an abort signal.
|
|
15
|
+
*/
|
|
16
|
+
export class RateLimitError extends Error {
|
|
17
|
+
_code;
|
|
18
|
+
_retryAt;
|
|
19
|
+
/** @internal */
|
|
20
|
+
constructor(code, retryAt, message) {
|
|
21
|
+
if (!message) {
|
|
22
|
+
switch (code) {
|
|
23
|
+
case RateLimitErrorCode.LimitExceeded: {
|
|
24
|
+
message = `Rate limit exceeded.${retryAt ? ` Retry at ${new Date(retryAt).toISOString()}.` : ''}`;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case RateLimitErrorCode.QueueOverflow: {
|
|
28
|
+
message = 'Queue overflow.';
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case RateLimitErrorCode.Expired: {
|
|
32
|
+
message = 'Task expired.';
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case RateLimitErrorCode.Destroyed: {
|
|
36
|
+
message = 'Task destroyed.';
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case RateLimitErrorCode.Cancelled: {
|
|
40
|
+
message = 'Task cancelled.';
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
// No default
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
super(message);
|
|
47
|
+
this._code = code;
|
|
48
|
+
this._retryAt = retryAt ?? null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The error code.
|
|
52
|
+
*/
|
|
53
|
+
get code() {
|
|
54
|
+
return this._code;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The timestamp (in milliseconds) when the task can be retried.
|
|
58
|
+
*
|
|
59
|
+
* Can be `null` if the retry time is not known.
|
|
60
|
+
*/
|
|
61
|
+
get retryAt() {
|
|
62
|
+
return this._retryAt ?? null;
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
65
|
+
toJSON() {
|
|
66
|
+
return {
|
|
67
|
+
name: this.name,
|
|
68
|
+
message: this.message,
|
|
69
|
+
code: this._code,
|
|
70
|
+
retryAt: this._retryAt,
|
|
71
|
+
stack: this.stack,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
package/{src/errors/rate-limiter-destroyed.error.ts → lib/errors/rate-limiter-destroyed.error.js}
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* An error thrown when running a task on a destroyed rate limiter.
|
|
3
3
|
*/
|
|
4
4
|
export class RateLimiterDestroyedError extends Error {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Rate limiter has been destroyed and cannot be used.');
|
|
7
|
+
}
|
|
8
8
|
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { RateLimitError } from './errors/rate-limit.error.js';
|
|
2
|
+
export { RateLimiterDestroyedError } from './errors/rate-limiter-destroyed.error.js';
|
|
3
|
+
export { InvalidCostError } from './errors/invalid-cost.error.js';
|
|
4
|
+
export { RateLimitErrorCode } from './enums/rate-limit-error-code.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Priority } from '@stimulcross/ds-policy-priority-queue';
|
|
2
|
+
import { createLogger, LogLevel } from '@stimulcross/logger';
|
|
3
|
+
import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
|
|
4
|
+
import { RateLimitError } from '../errors/rate-limit.error.js';
|
|
5
|
+
import { RateLimiterDestroyedError } from '../errors/rate-limiter-destroyed.error.js';
|
|
6
|
+
import { defaultClock } from '../runtime/default-clock.js';
|
|
7
|
+
import { InMemoryStateStore } from '../runtime/in-memory-state-store.js';
|
|
8
|
+
import { RateLimiterExecutor } from '../runtime/rate-limiter.executor.js';
|
|
9
|
+
import { generateRandomString } from '../utils/generate-random-string.js';
|
|
10
|
+
const DEFAULT_KEY_PREFIX = 'limiter';
|
|
11
|
+
/** @internal */
|
|
12
|
+
export class AbstractRateLimiter {
|
|
13
|
+
_logger;
|
|
14
|
+
_clock;
|
|
15
|
+
_store;
|
|
16
|
+
_executor;
|
|
17
|
+
_getStoreKey;
|
|
18
|
+
_generateId;
|
|
19
|
+
_isDestroyed = false;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this._logger = createLogger(new.target.name, { minLevel: 'WARNING', ...options?.loggerOptions });
|
|
22
|
+
this._clock = options?.clock ?? defaultClock;
|
|
23
|
+
this._store = options?.store ?? new InMemoryStateStore(this._clock);
|
|
24
|
+
this._executor = new RateLimiterExecutor(this._logger, this._clock, options?.queue);
|
|
25
|
+
this._getStoreKey =
|
|
26
|
+
options?.key && typeof options.key === 'function'
|
|
27
|
+
? options.key
|
|
28
|
+
: (key) => (key ? `${DEFAULT_KEY_PREFIX}:${key}` : DEFAULT_KEY_PREFIX);
|
|
29
|
+
this._generateId = options?.idGenerator ?? generateRandomString;
|
|
30
|
+
}
|
|
31
|
+
async run(fn, options = {}) {
|
|
32
|
+
const ctx = {
|
|
33
|
+
id: options.id ?? this._generateId(),
|
|
34
|
+
key: this._getStoreKey(options.key),
|
|
35
|
+
cost: options.cost ?? 1,
|
|
36
|
+
limitBehavior: options.limitBehavior,
|
|
37
|
+
priority: options.priority,
|
|
38
|
+
signal: options.signal,
|
|
39
|
+
maxWaitMs: options.maxWaitMs,
|
|
40
|
+
};
|
|
41
|
+
await this._ensureCanExecute(ctx);
|
|
42
|
+
return await this._runInternal(fn, ctx);
|
|
43
|
+
}
|
|
44
|
+
async getStatus(key) {
|
|
45
|
+
const now = this._clock.now();
|
|
46
|
+
const state = await this._store.get(this._getStoreKey(key));
|
|
47
|
+
return this._policy.getStatus(state ?? this._policy.getInitialState(now), now);
|
|
48
|
+
}
|
|
49
|
+
async clear(key) {
|
|
50
|
+
this._executor.clear();
|
|
51
|
+
const storeKey = this._getStoreKey(key);
|
|
52
|
+
this._shouldPrintDebug && this._logger.debug(`[CLR] [key: ${storeKey}]`);
|
|
53
|
+
await this._store.acquireLock?.(storeKey);
|
|
54
|
+
try {
|
|
55
|
+
await this._store.delete(storeKey);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await this._store.releaseLock?.(storeKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async destroy() {
|
|
62
|
+
if (this._isDestroyed) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this._shouldPrintDebug && this._logger.debug('[KILL] Destroying limiter');
|
|
66
|
+
this._isDestroyed = true;
|
|
67
|
+
this._executor.clear();
|
|
68
|
+
await this._store.destroy?.();
|
|
69
|
+
}
|
|
70
|
+
get _shouldPrintDebug() {
|
|
71
|
+
return this._logger.minLevel >= LogLevel.DEBUG;
|
|
72
|
+
}
|
|
73
|
+
async _execute(fn, runAt, storeTtlMs, ctx, expiresAt) {
|
|
74
|
+
await this._ensureCanExecute(ctx);
|
|
75
|
+
try {
|
|
76
|
+
return await this._executor.execute(fn, runAt, {
|
|
77
|
+
id: ctx.id,
|
|
78
|
+
key: ctx.key,
|
|
79
|
+
priority: ctx.priority,
|
|
80
|
+
signal: ctx.signal,
|
|
81
|
+
expiresAt,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (this._shouldRevert(e)) {
|
|
86
|
+
await this._store.acquireLock?.(ctx.key);
|
|
87
|
+
try {
|
|
88
|
+
const currentState = await this._store.get(ctx.key);
|
|
89
|
+
if (currentState) {
|
|
90
|
+
const revertedState = this._policy.revert(currentState, ctx.cost, this._clock.now());
|
|
91
|
+
await this._store.set(ctx.key, revertedState, storeTtlMs);
|
|
92
|
+
this._shouldPrintDebug &&
|
|
93
|
+
this._logger.debug(`[RVRT] [id: ${ctx.id}, key: ${ctx.key}, cost: ${ctx.cost}] - ${this._getDebugStateString(revertedState)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (e_) {
|
|
97
|
+
this._logger.error(`[ERR] [id: ${ctx.id}, key: ${ctx.key}, cost: ${ctx.cost}] - Revert failed`, e_);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await this._store.releaseLock?.(ctx.key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
_shouldRevert(e) {
|
|
107
|
+
return e instanceof RateLimitError && e.code !== RateLimitErrorCode.LimitExceeded;
|
|
108
|
+
}
|
|
109
|
+
async _ensureCanExecute(ctx) {
|
|
110
|
+
if (this._executor.isQueueFull) {
|
|
111
|
+
this._shouldPrintDebug &&
|
|
112
|
+
this._logger.debug(`[DROP OVERFLOW] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority ?? Priority.Normal} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}`);
|
|
113
|
+
let retryAt;
|
|
114
|
+
const state = await this._store.get(this._getStoreKey());
|
|
115
|
+
if (state) {
|
|
116
|
+
const status = this._policy.getStatus(state, this._clock.now());
|
|
117
|
+
retryAt = Array.isArray(status)
|
|
118
|
+
? Math.min(...status.map(s => s.nextAvailableAt))
|
|
119
|
+
: status.nextAvailableAt;
|
|
120
|
+
}
|
|
121
|
+
throw new RateLimitError(RateLimitErrorCode.QueueOverflow, retryAt);
|
|
122
|
+
}
|
|
123
|
+
if (ctx.signal?.aborted) {
|
|
124
|
+
this._logger.debug(`[DROP CANCELLED] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}`);
|
|
125
|
+
throw new RateLimitError(RateLimitErrorCode.Cancelled);
|
|
126
|
+
}
|
|
127
|
+
if (this._isDestroyed) {
|
|
128
|
+
this._logger.debug(`[DROP DESTROYED] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority ?? Priority.Normal} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}}`);
|
|
129
|
+
throw new RateLimiterDestroyedError();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { validateCost } from '../utils/validate-cost.js';
|
|
2
|
+
/** @internal */
|
|
3
|
+
export class CompositePolicy {
|
|
4
|
+
_policies;
|
|
5
|
+
constructor(_policies) {
|
|
6
|
+
this._policies = _policies;
|
|
7
|
+
}
|
|
8
|
+
get policies() {
|
|
9
|
+
return this._policies;
|
|
10
|
+
}
|
|
11
|
+
getInitialState(now) {
|
|
12
|
+
return this._policies.map(policy => policy.getInitialState(now));
|
|
13
|
+
}
|
|
14
|
+
getStatus(states, now) {
|
|
15
|
+
const result = [];
|
|
16
|
+
for (let i = 0; i < this._policies.length; i++) {
|
|
17
|
+
const policy = this._policies[i];
|
|
18
|
+
const state = states[i];
|
|
19
|
+
const info = policy.getStatus(state, now);
|
|
20
|
+
result.push(info);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
evaluate(states, now, cost, shouldReserve) {
|
|
25
|
+
if (cost === undefined) {
|
|
26
|
+
cost = 1;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
validateCost(cost);
|
|
30
|
+
}
|
|
31
|
+
const results = [];
|
|
32
|
+
let compositeDecision = 'allow';
|
|
33
|
+
let maxRetryAt = 0;
|
|
34
|
+
let maxRunAt = 0;
|
|
35
|
+
for (let i = 0; i < this._policies.length; i++) {
|
|
36
|
+
const policy = this._policies[i];
|
|
37
|
+
const state = states[i];
|
|
38
|
+
const result = policy.evaluate(state, now, cost, shouldReserve);
|
|
39
|
+
results.push(result);
|
|
40
|
+
if (result.decision.kind === 'deny') {
|
|
41
|
+
compositeDecision = 'deny';
|
|
42
|
+
maxRetryAt = Math.max(maxRetryAt, result.decision.retryAt);
|
|
43
|
+
}
|
|
44
|
+
else if (result.decision.kind === 'delay') {
|
|
45
|
+
if (compositeDecision !== 'deny') {
|
|
46
|
+
compositeDecision = 'delay';
|
|
47
|
+
}
|
|
48
|
+
maxRunAt = Math.max(maxRunAt, result.decision.runAt);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (compositeDecision === 'deny') {
|
|
52
|
+
const nextState = results.map((res, i) => res.decision.kind === 'deny' ? res.nextState : this._policies[i].revert(res.nextState, cost, now));
|
|
53
|
+
return {
|
|
54
|
+
decision: { kind: 'deny', retryAt: maxRetryAt },
|
|
55
|
+
nextState,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (compositeDecision === 'delay') {
|
|
59
|
+
return {
|
|
60
|
+
decision: { kind: 'delay', runAt: maxRunAt },
|
|
61
|
+
nextState: results.map(res => res.nextState),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
decision: { kind: 'allow' },
|
|
66
|
+
nextState: results.map(res => res.nextState),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
revert(states, cost, now) {
|
|
70
|
+
return states.map((state, i) => this._policies[i].revert(state, cost, now));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { LogLevel } from '@stimulcross/logger';
|
|
2
|
+
import { FixedWindowPolicy } from './fixed-window.policy.js';
|
|
3
|
+
import { RateLimitErrorCode } from '../../enums/rate-limit-error-code.js';
|
|
4
|
+
import { RateLimitError } from '../../errors/rate-limit.error.js';
|
|
5
|
+
import { AbstractRateLimiter } from '../abstract-rate-limiter.js';
|
|
6
|
+
import { CompositePolicy } from '../composite.policy.js';
|
|
7
|
+
/**
|
|
8
|
+
* Fixed Window rate limiter.
|
|
9
|
+
*
|
|
10
|
+
* Designed primarily for client-side use to respect third-party limits or protect resources.
|
|
11
|
+
* While this can be used as a server-side limiter with custom distributed storage
|
|
12
|
+
* (e.g., Redis), it is best-effort and not recommended due to high network round-trip latency.
|
|
13
|
+
*
|
|
14
|
+
* Key features:
|
|
15
|
+
* - **Composite limits** - supports multiple windows simultaneously (e.g., 10 per second AND 1000 per hour)
|
|
16
|
+
* - **Queueing & overflow** - optionally enqueues excess requests up to a maximum allowed overflow capacity
|
|
17
|
+
* - **Concurrency** - limits how many requests can be executed simultaneously
|
|
18
|
+
* - **Priority** - supports task priorities (with fairness and custom policy) to execute critical requests first
|
|
19
|
+
* - **Cancellation** - supports `AbortSignal` to safely remove pending requests from the queue
|
|
20
|
+
* - **Expiration** - automatically drops queued requests that wait longer than the allowed `maxWaitMs`
|
|
21
|
+
* - **Auto-rollback** - reverts spent quota if an enqueued task is canceled or expired
|
|
22
|
+
*/
|
|
23
|
+
export class FixedWindowLimiter extends AbstractRateLimiter {
|
|
24
|
+
_defaultLimitBehaviour;
|
|
25
|
+
_defaultMaxWaitMs;
|
|
26
|
+
_maxWindowSizeMs;
|
|
27
|
+
_policy;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
super(options);
|
|
30
|
+
this._defaultLimitBehaviour = options.limitBehavior ?? 'reject';
|
|
31
|
+
if (options.queue?.maxWaitMs) {
|
|
32
|
+
this._defaultMaxWaitMs = options.queue.maxWaitMs;
|
|
33
|
+
}
|
|
34
|
+
this._policy = new CompositePolicy(Array.isArray(options.limitOptions)
|
|
35
|
+
? options.limitOptions.map(({ limit, windowMs }) => new FixedWindowPolicy(limit, windowMs))
|
|
36
|
+
: [new FixedWindowPolicy(options.limitOptions.limit, options.limitOptions.windowMs)]);
|
|
37
|
+
this._maxWindowSizeMs = Math.max(...this._policy.policies.map(policy => policy.windowMs));
|
|
38
|
+
}
|
|
39
|
+
async _runInternal(fn, ctx) {
|
|
40
|
+
const now = this._clock.now();
|
|
41
|
+
let runAt;
|
|
42
|
+
let storeTtlMs;
|
|
43
|
+
await this._store.acquireLock?.(ctx.key);
|
|
44
|
+
try {
|
|
45
|
+
const state = (await this._store.get(ctx.key)) ?? this._policy.getInitialState(this._clock.now());
|
|
46
|
+
const finalLimitBehavior = ctx.limitBehavior ?? this._defaultLimitBehaviour;
|
|
47
|
+
const { decision, nextState } = this._policy.evaluate(state, now, ctx.cost, finalLimitBehavior === 'enqueue');
|
|
48
|
+
if (decision.kind === 'deny') {
|
|
49
|
+
this._logger.debug(`[DENY] [id: ${ctx.id}, key: ${ctx.key}] - Retry: +${decision.retryAt - now}ms`);
|
|
50
|
+
throw new RateLimitError(RateLimitErrorCode.LimitExceeded, decision.retryAt);
|
|
51
|
+
}
|
|
52
|
+
runAt = decision.kind === 'delay' ? decision.runAt : now;
|
|
53
|
+
storeTtlMs = Math.max(this._maxWindowSizeMs, runAt - now + this._maxWindowSizeMs);
|
|
54
|
+
await this._store.set(ctx.key, nextState, storeTtlMs);
|
|
55
|
+
this._printDebug(decision, nextState, now, ctx);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await this._store.releaseLock?.(ctx.key);
|
|
59
|
+
}
|
|
60
|
+
const finalMaxWaitMs = ctx.maxWaitMs ?? this._defaultMaxWaitMs;
|
|
61
|
+
const expiresAt = finalMaxWaitMs ? now + finalMaxWaitMs : undefined;
|
|
62
|
+
return await this._execute(fn, runAt, storeTtlMs, ctx, expiresAt);
|
|
63
|
+
}
|
|
64
|
+
_getDebugStateString(state) {
|
|
65
|
+
const result = [];
|
|
66
|
+
for (const [i, { used, reserved }] of state.entries()) {
|
|
67
|
+
const { windowMs, limit } = this._policy.policies[i];
|
|
68
|
+
result.push(`w/l: ${windowMs}/${limit}; u/r: ${used}/${reserved}`);
|
|
69
|
+
}
|
|
70
|
+
return result.join(', ');
|
|
71
|
+
}
|
|
72
|
+
_printDebug(decision, nextState, now, ctx) {
|
|
73
|
+
if (this._logger.minLevel < LogLevel.DEBUG) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const debugStateString = this._getDebugStateString(nextState);
|
|
77
|
+
if (decision.kind === 'delay') {
|
|
78
|
+
this._logger.debug(`[DELAY] [id: ${ctx.id}, key: ${ctx.key}] +${decision.runAt - now}ms - ${debugStateString}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
this._logger.debug(`[ALLOW] [id: ${ctx.id}, key: ${ctx.key}] - ${debugStateString}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { validateCost } from '../../utils/validate-cost.js';
|
|
2
|
+
/** @internal */
|
|
3
|
+
export class FixedWindowPolicy {
|
|
4
|
+
_limit;
|
|
5
|
+
_windowMs;
|
|
6
|
+
_maxReserved;
|
|
7
|
+
constructor(_limit, _windowMs, _maxReserved = Number.POSITIVE_INFINITY) {
|
|
8
|
+
this._limit = _limit;
|
|
9
|
+
this._windowMs = _windowMs;
|
|
10
|
+
this._maxReserved = _maxReserved;
|
|
11
|
+
if (!Number.isSafeInteger(_limit) || _limit <= 0) {
|
|
12
|
+
throw new Error(`Invalid limit: ${_limit}. Must be a positive integer.`);
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isSafeInteger(_windowMs) || _windowMs <= 0) {
|
|
15
|
+
throw new Error(`Invalid windowMs: ${_windowMs}. Must be a positive integer.`);
|
|
16
|
+
}
|
|
17
|
+
if (_maxReserved < 0 || (_maxReserved !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(_maxReserved))) {
|
|
18
|
+
throw new Error(`Invalid maxReserved: ${_maxReserved}. Must be a positive integer or Infinity.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
get limit() {
|
|
22
|
+
return this._limit;
|
|
23
|
+
}
|
|
24
|
+
get windowMs() {
|
|
25
|
+
return this._windowMs;
|
|
26
|
+
}
|
|
27
|
+
getInitialState() {
|
|
28
|
+
return { windowStart: 0, used: 0, reserved: 0 };
|
|
29
|
+
}
|
|
30
|
+
getStatus(state, now) {
|
|
31
|
+
const { windowStart, used, reserved } = this._syncState(state, now);
|
|
32
|
+
const windowEnd = windowStart + this._windowMs;
|
|
33
|
+
let nextAvailableAt;
|
|
34
|
+
if (used < this._limit) {
|
|
35
|
+
nextAvailableAt = windowStart;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const blockedWindows = Math.floor((used + reserved) / this._limit);
|
|
39
|
+
nextAvailableAt = windowStart + blockedWindows * this._windowMs;
|
|
40
|
+
}
|
|
41
|
+
const windowsToClear = Math.ceil((used + reserved) / this._limit);
|
|
42
|
+
const resetAt = windowStart + Math.max(1, windowsToClear) * this._windowMs;
|
|
43
|
+
return {
|
|
44
|
+
windowStart,
|
|
45
|
+
windowEnd,
|
|
46
|
+
limit: this._limit,
|
|
47
|
+
used,
|
|
48
|
+
reserved,
|
|
49
|
+
remaining: Math.max(0, this._limit - used),
|
|
50
|
+
nextAvailableAt,
|
|
51
|
+
resetAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
evaluate(state, now, cost, shouldReserve) {
|
|
55
|
+
validateCost(cost, this._limit);
|
|
56
|
+
const { windowStart, used, reserved } = this._syncState(state, now);
|
|
57
|
+
const currentTotal = used + cost;
|
|
58
|
+
if (reserved === 0 && currentTotal <= this._limit) {
|
|
59
|
+
return {
|
|
60
|
+
decision: { kind: 'allow' },
|
|
61
|
+
nextState: { windowStart, used: currentTotal, reserved: 0 },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (shouldReserve) {
|
|
65
|
+
if (reserved + cost > this._maxReserved) {
|
|
66
|
+
const deficit = reserved + cost - this._maxReserved;
|
|
67
|
+
const windowsToWait = Math.ceil(deficit / this._limit);
|
|
68
|
+
const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
|
|
69
|
+
return this._deny(windowStart, used, reserved, retryAt);
|
|
70
|
+
}
|
|
71
|
+
const totalItems = used + reserved + cost;
|
|
72
|
+
const windowIndex = Math.floor((totalItems - 1) / this._limit);
|
|
73
|
+
const runAt = windowStart + windowIndex * this._windowMs;
|
|
74
|
+
return {
|
|
75
|
+
decision: { kind: 'delay', runAt },
|
|
76
|
+
nextState: {
|
|
77
|
+
windowStart,
|
|
78
|
+
used,
|
|
79
|
+
reserved: reserved + cost,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const windowsToWait = Math.ceil((used + reserved + cost - this._limit) / this._limit);
|
|
84
|
+
const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
|
|
85
|
+
return this._deny(windowStart, used, reserved, retryAt);
|
|
86
|
+
}
|
|
87
|
+
revert(state, cost, now) {
|
|
88
|
+
let { used, reserved, windowStart } = this._syncState(state, now);
|
|
89
|
+
if (reserved >= cost) {
|
|
90
|
+
reserved -= cost;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const remainder = cost - reserved;
|
|
94
|
+
reserved = 0;
|
|
95
|
+
used = Math.max(0, used - remainder);
|
|
96
|
+
}
|
|
97
|
+
return { windowStart, used, reserved };
|
|
98
|
+
}
|
|
99
|
+
_deny(start, used, reserved, retryAt) {
|
|
100
|
+
return {
|
|
101
|
+
decision: { kind: 'deny', retryAt },
|
|
102
|
+
nextState: { windowStart: start, used, reserved },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
_syncState(state, now) {
|
|
106
|
+
const currentWindowStart = Math.max(Math.floor(now / this._windowMs) * this._windowMs, state.windowStart);
|
|
107
|
+
if (currentWindowStart <= state.windowStart) {
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
let { reserved } = state;
|
|
111
|
+
const windowsPassed = Math.floor((currentWindowStart - state.windowStart) / this._windowMs);
|
|
112
|
+
const burnableWindows = windowsPassed - 1;
|
|
113
|
+
if (burnableWindows > 0 && reserved > 0) {
|
|
114
|
+
reserved = Math.max(0, reserved - burnableWindows * this._limit);
|
|
115
|
+
}
|
|
116
|
+
const used = Math.min(reserved, this._limit);
|
|
117
|
+
reserved = Math.max(0, reserved - used);
|
|
118
|
+
return { windowStart: currentWindowStart, used, reserved };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FixedWindowLimiter } from './fixed-window.limiter.js';
|