@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.
Files changed (183) hide show
  1. package/README.md +20 -0
  2. package/lib/core/cancellable.js +1 -0
  3. package/lib/core/clock.js +1 -0
  4. package/lib/core/decision.js +1 -0
  5. package/lib/core/rate-limit-policy.js +1 -0
  6. package/lib/core/rate-limiter-status.js +1 -0
  7. package/lib/core/rate-limiter.js +1 -0
  8. package/lib/core/state-storage.js +1 -0
  9. package/lib/enums/rate-limit-error-code.js +26 -0
  10. package/lib/errors/custom.error.js +12 -0
  11. package/lib/errors/invalid-cost.error.js +25 -0
  12. package/lib/errors/rate-limit.error.js +74 -0
  13. package/{src/errors/rate-limiter-destroyed.error.ts → lib/errors/rate-limiter-destroyed.error.js} +3 -3
  14. package/lib/index.js +4 -0
  15. package/lib/interfaces/rate-limiter-options.js +1 -0
  16. package/lib/interfaces/rate-limiter-queue-options.js +1 -0
  17. package/lib/interfaces/rate-limiter-run-options.js +1 -0
  18. package/lib/limiters/abstract-rate-limiter.js +132 -0
  19. package/lib/limiters/composite.policy.js +72 -0
  20. package/lib/limiters/fixed-window/fixed-window.limiter.js +84 -0
  21. package/lib/limiters/fixed-window/fixed-window.options.js +1 -0
  22. package/lib/limiters/fixed-window/fixed-window.policy.js +120 -0
  23. package/lib/limiters/fixed-window/fixed-window.state.js +1 -0
  24. package/lib/limiters/fixed-window/fixed-window.status.js +1 -0
  25. package/lib/limiters/fixed-window/index.js +1 -0
  26. package/lib/limiters/generic-cell/generic-cell.limiter.js +73 -0
  27. package/lib/limiters/generic-cell/generic-cell.options.js +1 -0
  28. package/lib/limiters/generic-cell/generic-cell.policy.js +86 -0
  29. package/lib/limiters/generic-cell/generic-cell.state.js +1 -0
  30. package/lib/limiters/generic-cell/generic-cell.status.js +1 -0
  31. package/lib/limiters/generic-cell/index.js +1 -0
  32. package/lib/limiters/http-response-based/http-limit-info.extractor.js +1 -0
  33. package/lib/limiters/http-response-based/http-limit.info.js +1 -0
  34. package/lib/limiters/http-response-based/http-response-based-limiter.options.js +1 -0
  35. package/lib/limiters/http-response-based/http-response-based-limiter.state.js +1 -0
  36. package/lib/limiters/http-response-based/http-response-based-limiter.status.js +1 -0
  37. package/lib/limiters/http-response-based/http-response-based.limiter.js +379 -0
  38. package/lib/limiters/http-response-based/index.js +1 -0
  39. package/lib/limiters/leaky-bucket/index.js +1 -0
  40. package/lib/limiters/leaky-bucket/leaky-bucket.limiter.js +74 -0
  41. package/lib/limiters/leaky-bucket/leaky-bucket.options.js +1 -0
  42. package/lib/limiters/leaky-bucket/leaky-bucket.policy.js +100 -0
  43. package/lib/limiters/leaky-bucket/leaky-bucket.state.js +1 -0
  44. package/lib/limiters/leaky-bucket/leaky-bucket.status.js +1 -0
  45. package/lib/limiters/sliding-window-counter/index.js +1 -0
  46. package/lib/limiters/sliding-window-counter/sliding-window-counter.limiter.js +46 -0
  47. package/lib/limiters/sliding-window-counter/sliding-window-counter.options.js +1 -0
  48. package/lib/limiters/sliding-window-counter/sliding-window-counter.policy.js +127 -0
  49. package/lib/limiters/sliding-window-counter/sliding-window-counter.state.js +1 -0
  50. package/lib/limiters/sliding-window-counter/sliding-window-counter.status.js +1 -0
  51. package/lib/limiters/sliding-window-log/index.js +1 -0
  52. package/lib/limiters/sliding-window-log/sliding-window-log.limiter.js +43 -0
  53. package/lib/limiters/sliding-window-log/sliding-window-log.options.js +1 -0
  54. package/lib/limiters/sliding-window-log/sliding-window-log.policy.js +123 -0
  55. package/lib/limiters/sliding-window-log/sliding-window-log.state.js +1 -0
  56. package/lib/limiters/sliding-window-log/sliding-window-log.status.js +1 -0
  57. package/lib/limiters/token-bucket/index.js +1 -0
  58. package/lib/limiters/token-bucket/token-bucket.limiter.js +74 -0
  59. package/lib/limiters/token-bucket/token-bucket.options.js +1 -0
  60. package/lib/limiters/token-bucket/token-bucket.policy.js +115 -0
  61. package/lib/limiters/token-bucket/token-bucket.state.js +1 -0
  62. package/lib/limiters/token-bucket/token-bucket.status.js +1 -0
  63. package/lib/runtime/default-clock.js +6 -0
  64. package/lib/runtime/execution-tickets.js +26 -0
  65. package/lib/runtime/in-memory-state-store.js +96 -0
  66. package/lib/runtime/rate-limiter.executor.js +195 -0
  67. package/lib/runtime/semaphore.js +27 -0
  68. package/lib/runtime/task.js +100 -0
  69. package/lib/types/limit-behavior.js +1 -0
  70. package/lib/utils/generate-random-string.js +12 -0
  71. package/lib/utils/promise-with-resolvers.js +14 -0
  72. package/lib/utils/sanitize-error.js +4 -0
  73. package/lib/utils/sanitize-priority.js +17 -0
  74. package/lib/utils/validate-cost.js +13 -0
  75. package/package.json +12 -2
  76. package/.editorconfig +0 -21
  77. package/.github/workflows/node.yml +0 -87
  78. package/.husky/commit-msg +0 -1
  79. package/.husky/pre-commit +0 -1
  80. package/.megaignore +0 -8
  81. package/.prettierignore +0 -3
  82. package/commitlint.config.js +0 -8
  83. package/eslint.config.js +0 -65
  84. package/lint-staged.config.js +0 -4
  85. package/prettier.config.cjs +0 -1
  86. package/src/core/cancellable.ts +0 -4
  87. package/src/core/clock.ts +0 -9
  88. package/src/core/decision.ts +0 -27
  89. package/src/core/rate-limit-policy.ts +0 -15
  90. package/src/core/rate-limiter-status.ts +0 -14
  91. package/src/core/rate-limiter.ts +0 -37
  92. package/src/core/state-storage.ts +0 -51
  93. package/src/enums/rate-limit-error-code.ts +0 -29
  94. package/src/errors/custom.error.ts +0 -14
  95. package/src/errors/invalid-cost.error.ts +0 -33
  96. package/src/errors/rate-limit.error.ts +0 -91
  97. package/src/index.ts +0 -11
  98. package/src/interfaces/rate-limiter-options.ts +0 -84
  99. package/src/interfaces/rate-limiter-queue-options.ts +0 -45
  100. package/src/interfaces/rate-limiter-run-options.ts +0 -58
  101. package/src/limiters/abstract-rate-limiter.ts +0 -206
  102. package/src/limiters/composite.policy.ts +0 -102
  103. package/src/limiters/fixed-window/fixed-window.limiter.ts +0 -121
  104. package/src/limiters/fixed-window/fixed-window.options.ts +0 -29
  105. package/src/limiters/fixed-window/fixed-window.policy.ts +0 -159
  106. package/src/limiters/fixed-window/fixed-window.state.ts +0 -10
  107. package/src/limiters/fixed-window/fixed-window.status.ts +0 -46
  108. package/src/limiters/fixed-window/index.ts +0 -4
  109. package/src/limiters/generic-cell/generic-cell.limiter.ts +0 -108
  110. package/src/limiters/generic-cell/generic-cell.options.ts +0 -23
  111. package/src/limiters/generic-cell/generic-cell.policy.ts +0 -115
  112. package/src/limiters/generic-cell/generic-cell.state.ts +0 -8
  113. package/src/limiters/generic-cell/generic-cell.status.ts +0 -54
  114. package/src/limiters/generic-cell/index.ts +0 -4
  115. package/src/limiters/http-response-based/http-limit-info.extractor.ts +0 -20
  116. package/src/limiters/http-response-based/http-limit.info.ts +0 -41
  117. package/src/limiters/http-response-based/http-response-based-limiter.options.ts +0 -18
  118. package/src/limiters/http-response-based/http-response-based-limiter.state.ts +0 -13
  119. package/src/limiters/http-response-based/http-response-based-limiter.status.ts +0 -74
  120. package/src/limiters/http-response-based/http-response-based.limiter.ts +0 -512
  121. package/src/limiters/http-response-based/index.ts +0 -6
  122. package/src/limiters/leaky-bucket/index.ts +0 -4
  123. package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +0 -105
  124. package/src/limiters/leaky-bucket/leaky-bucket.options.ts +0 -23
  125. package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +0 -134
  126. package/src/limiters/leaky-bucket/leaky-bucket.state.ts +0 -9
  127. package/src/limiters/leaky-bucket/leaky-bucket.status.ts +0 -36
  128. package/src/limiters/sliding-window-counter/index.ts +0 -7
  129. package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +0 -76
  130. package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +0 -20
  131. package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +0 -167
  132. package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +0 -10
  133. package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +0 -53
  134. package/src/limiters/sliding-window-log/index.ts +0 -4
  135. package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +0 -65
  136. package/src/limiters/sliding-window-log/sliding-window-log.options.ts +0 -20
  137. package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +0 -166
  138. package/src/limiters/sliding-window-log/sliding-window-log.state.ts +0 -19
  139. package/src/limiters/sliding-window-log/sliding-window-log.status.ts +0 -44
  140. package/src/limiters/token-bucket/index.ts +0 -4
  141. package/src/limiters/token-bucket/token-bucket.limiter.ts +0 -110
  142. package/src/limiters/token-bucket/token-bucket.options.ts +0 -17
  143. package/src/limiters/token-bucket/token-bucket.policy.ts +0 -155
  144. package/src/limiters/token-bucket/token-bucket.state.ts +0 -10
  145. package/src/limiters/token-bucket/token-bucket.status.ts +0 -36
  146. package/src/runtime/default-clock.ts +0 -8
  147. package/src/runtime/execution-tickets.ts +0 -34
  148. package/src/runtime/in-memory-state-store.ts +0 -135
  149. package/src/runtime/rate-limiter.executor.ts +0 -286
  150. package/src/runtime/semaphore.ts +0 -31
  151. package/src/runtime/task.ts +0 -141
  152. package/src/types/limit-behavior.ts +0 -8
  153. package/src/utils/generate-random-string.ts +0 -16
  154. package/src/utils/promise-with-resolvers.ts +0 -23
  155. package/src/utils/sanitize-error.ts +0 -4
  156. package/src/utils/sanitize-priority.ts +0 -22
  157. package/src/utils/validate-cost.ts +0 -16
  158. package/tests/integration/limiters/fixed-window.limiter.spec.ts +0 -371
  159. package/tests/integration/limiters/generic-cell.limiter.spec.ts +0 -361
  160. package/tests/integration/limiters/http-response-based.limiter.spec.ts +0 -833
  161. package/tests/integration/limiters/leaky-bucket.spec.ts +0 -357
  162. package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +0 -175
  163. package/tests/integration/limiters/sliding-window-log.spec.ts +0 -185
  164. package/tests/integration/limiters/token-bucket.limiter.spec.ts +0 -363
  165. package/tests/tsconfig.json +0 -4
  166. package/tests/unit/policies/composite.policy.spec.ts +0 -244
  167. package/tests/unit/policies/fixed-window.policy.spec.ts +0 -260
  168. package/tests/unit/policies/generic-cell.policy.spec.ts +0 -178
  169. package/tests/unit/policies/leaky-bucket.policy.spec.ts +0 -215
  170. package/tests/unit/policies/sliding-window-counter.policy.spec.ts +0 -209
  171. package/tests/unit/policies/sliding-window-log.policy.spec.ts +0 -285
  172. package/tests/unit/policies/token-bucket.policy.spec.ts +0 -371
  173. package/tests/unit/runtime/execution-tickets.spec.ts +0 -121
  174. package/tests/unit/runtime/in-memory-state-store.spec.ts +0 -238
  175. package/tests/unit/runtime/rate-limiter.executor.spec.ts +0 -353
  176. package/tests/unit/runtime/semaphore.spec.ts +0 -98
  177. package/tests/unit/runtime/task.spec.ts +0 -182
  178. package/tests/unit/utils/generate-random-string.spec.ts +0 -51
  179. package/tests/unit/utils/promise-with-resolvers.spec.ts +0 -57
  180. package/tests/unit/utils/sanitize-priority.spec.ts +0 -46
  181. package/tests/unit/utils/validate-cost.spec.ts +0 -48
  182. package/tsconfig.json +0 -14
  183. 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
+ }
@@ -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
- constructor() {
6
- super('Rate limiter has been destroyed and cannot be used.');
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';