@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.
Files changed (112) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/node.yml +87 -0
  3. package/.husky/commit-msg +1 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.megaignore +8 -0
  6. package/.prettierignore +3 -0
  7. package/LICENSE +21 -0
  8. package/README.md +7 -0
  9. package/commitlint.config.js +8 -0
  10. package/eslint.config.js +65 -0
  11. package/lint-staged.config.js +4 -0
  12. package/package.json +89 -0
  13. package/prettier.config.cjs +1 -0
  14. package/src/core/cancellable.ts +4 -0
  15. package/src/core/clock.ts +9 -0
  16. package/src/core/decision.ts +27 -0
  17. package/src/core/rate-limit-policy.ts +15 -0
  18. package/src/core/rate-limiter-status.ts +14 -0
  19. package/src/core/rate-limiter.ts +37 -0
  20. package/src/core/state-storage.ts +51 -0
  21. package/src/enums/rate-limit-error-code.ts +29 -0
  22. package/src/errors/custom.error.ts +14 -0
  23. package/src/errors/invalid-cost.error.ts +33 -0
  24. package/src/errors/rate-limit.error.ts +91 -0
  25. package/src/errors/rate-limiter-destroyed.error.ts +8 -0
  26. package/src/index.ts +11 -0
  27. package/src/interfaces/rate-limiter-options.ts +84 -0
  28. package/src/interfaces/rate-limiter-queue-options.ts +45 -0
  29. package/src/interfaces/rate-limiter-run-options.ts +58 -0
  30. package/src/limiters/abstract-rate-limiter.ts +206 -0
  31. package/src/limiters/composite.policy.ts +102 -0
  32. package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
  33. package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
  34. package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
  35. package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
  36. package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
  37. package/src/limiters/fixed-window/index.ts +4 -0
  38. package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
  39. package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
  40. package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
  41. package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
  42. package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
  43. package/src/limiters/generic-cell/index.ts +4 -0
  44. package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
  45. package/src/limiters/http-response-based/http-limit.info.ts +41 -0
  46. package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
  47. package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
  48. package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
  49. package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
  50. package/src/limiters/http-response-based/index.ts +6 -0
  51. package/src/limiters/leaky-bucket/index.ts +4 -0
  52. package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
  53. package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
  54. package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
  55. package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
  56. package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
  57. package/src/limiters/sliding-window-counter/index.ts +7 -0
  58. package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
  59. package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
  60. package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
  61. package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
  62. package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
  63. package/src/limiters/sliding-window-log/index.ts +4 -0
  64. package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
  65. package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
  66. package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
  67. package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
  68. package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
  69. package/src/limiters/token-bucket/index.ts +4 -0
  70. package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
  71. package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
  72. package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
  73. package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
  74. package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
  75. package/src/runtime/default-clock.ts +8 -0
  76. package/src/runtime/execution-tickets.ts +34 -0
  77. package/src/runtime/in-memory-state-store.ts +135 -0
  78. package/src/runtime/rate-limiter.executor.ts +286 -0
  79. package/src/runtime/semaphore.ts +31 -0
  80. package/src/runtime/task.ts +141 -0
  81. package/src/types/limit-behavior.ts +8 -0
  82. package/src/utils/generate-random-string.ts +16 -0
  83. package/src/utils/promise-with-resolvers.ts +23 -0
  84. package/src/utils/sanitize-error.ts +4 -0
  85. package/src/utils/sanitize-priority.ts +22 -0
  86. package/src/utils/validate-cost.ts +16 -0
  87. package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
  88. package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
  89. package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
  90. package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
  91. package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
  92. package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
  93. package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
  94. package/tests/tsconfig.json +4 -0
  95. package/tests/unit/policies/composite.policy.spec.ts +244 -0
  96. package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
  97. package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
  98. package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
  99. package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
  100. package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
  101. package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
  102. package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
  103. package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
  104. package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
  105. package/tests/unit/runtime/semaphore.spec.ts +98 -0
  106. package/tests/unit/runtime/task.spec.ts +182 -0
  107. package/tests/unit/utils/generate-random-string.spec.ts +51 -0
  108. package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
  109. package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
  110. package/tests/unit/utils/validate-cost.spec.ts +48 -0
  111. package/tsconfig.json +14 -0
  112. package/vitest.config.js +22 -0
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type { Clock } from './core/clock.js';
2
+ export type { StateStorage } from './core/state-storage.js';
3
+ export type { RateLimiter } from './core/rate-limiter.js';
4
+ export type { LimitBehavior } from './types/limit-behavior.js';
5
+ export type { KeyResolver, RateLimiterOptions } from './interfaces/rate-limiter-options.js';
6
+ export type { RateLimiterQueueOptions } from './interfaces/rate-limiter-queue-options.js';
7
+ export type { RateLimiterRunOptions } from './interfaces/rate-limiter-run-options.js';
8
+ export { type RateLimitErrorPlainObject, RateLimitError } from './errors/rate-limit.error.js';
9
+ export { RateLimiterDestroyedError } from './errors/rate-limiter-destroyed.error.js';
10
+ export { type InvalidCostErrorPlainObject, InvalidCostError } from './errors/invalid-cost.error.js';
11
+ export { RateLimitErrorCode } from './enums/rate-limit-error-code.js';
@@ -0,0 +1,84 @@
1
+ import { type LoggerOptions } from '@stimulcross/logger';
2
+ import { type RateLimiterQueueOptions } from './rate-limiter-queue-options.js';
3
+ import { type Clock } from '../core/clock.js';
4
+ import { type StateStorage } from '../core/state-storage.js';
5
+ import { type LimitBehavior } from '../types/limit-behavior.js';
6
+
7
+ /**
8
+ * A function that generates a unique ID for the task.
9
+ */
10
+ export type IdGenerator = () => string;
11
+
12
+ /**
13
+ * A function that resolves a global key for the given key.
14
+ */
15
+ export type KeyResolver = (key?: string) => string;
16
+
17
+ /**
18
+ * Rate limiter options.
19
+ *
20
+ * @template TState The type of the rate limiter state.
21
+ */
22
+ export interface RateLimiterOptions<TState = unknown> {
23
+ /**
24
+ * A custom clock implementation.
25
+ */
26
+ clock?: Clock;
27
+
28
+ /**
29
+ * An optional key that can be either a string or a key resolver function.
30
+ *
31
+ * If a string is provided, it will be used as a global prefix for all keys.
32
+ *
33
+ * If a function is provided, it will be called with the provided key and should return a unique global key.
34
+ *
35
+ * Useful for distributed stores.
36
+ *
37
+ * @default limiter
38
+ */
39
+ key?: string | KeyResolver;
40
+
41
+ /**
42
+ * A custom ID factory function.
43
+ *
44
+ * It is used to generate unique IDs for each task for logging and debugging purposes.
45
+ */
46
+ idGenerator?: IdGenerator;
47
+
48
+ /**
49
+ * State storage implementation for persisting rate limiter state.
50
+ *
51
+ * By default, an in-memory state store is used, which is unique to each process.
52
+ * This is suitable for single-instance applications or when rate limiting doesn't need
53
+ * to be shared across multiple processes.
54
+ *
55
+ * For distributed applications, you can implement a custom state store using Redis, Memcached,
56
+ * or other distributed storage systems. However, be aware that this introduces network latency
57
+ * due to multiple round-trips (typically 3-4 requests with lock acquire/release).
58
+ *
59
+ * For distributed rate limiting, consider using, for example, Redis with Lua scripts.
60
+ * This allows atomic operations and minimizes latency.
61
+ */
62
+ store?: StateStorage<TState>;
63
+
64
+ /**
65
+ * Defines the behavior when the limit is reached.
66
+ *
67
+ * Available options:
68
+ * - `reject` - rejects the task with `LIMIT_EXCEEDED` error code
69
+ * - `enqueue` - enqueues the task
70
+ *
71
+ * @default 'reject'
72
+ */
73
+ limitBehavior?: LimitBehavior;
74
+
75
+ /**
76
+ * Logger options.
77
+ */
78
+ loggerOptions?: Omit<LoggerOptions, 'context'>;
79
+
80
+ /**
81
+ * Queue settings.
82
+ */
83
+ queue?: RateLimiterQueueOptions;
84
+ }
@@ -0,0 +1,45 @@
1
+ import { type SelectionPolicy } from '@stimulcross/ds-policy-priority-queue';
2
+
3
+ /**
4
+ * Queue options for rate limiter.
5
+ *
6
+ * These options are applied to limiters that support delayed execution.
7
+ */
8
+ export interface RateLimiterQueueOptions {
9
+ /**
10
+ * Defines the maximum number of tasks that can be executed concurrently.
11
+ *
12
+ * @default Infinity
13
+ */
14
+ concurrency?: number;
15
+
16
+ /**
17
+ * Maximum time to wait in the queue (in milliseconds).
18
+ *
19
+ * If a task is not started within this time, it will be rejected with `EXPIRED` error code.
20
+ *
21
+ * @default Infinity
22
+ */
23
+ maxWaitMs?: number;
24
+
25
+ /**
26
+ * Maximum queue size.
27
+ *
28
+ * When overflowed, new tasks will be rejected immediately.
29
+ *
30
+ * @default Infinity
31
+ */
32
+ capacity?: number;
33
+
34
+ /**
35
+ * Selection policy for the priority queue.
36
+ *
37
+ * Defaults to Weighted round-robin (WRR) with the following weights:
38
+ * - `Priority.Lowest` - 1
39
+ * - `Priority.Low` - 2
40
+ * - `Priority.Normal` - 4
41
+ * - `Priority.High` - 8
42
+ * - `Priority.Highest` - 16
43
+ */
44
+ selectionPolicy?: SelectionPolicy;
45
+ }
@@ -0,0 +1,58 @@
1
+ import { type Priority } from '@stimulcross/ds-policy-priority-queue';
2
+ import { type LimitBehavior } from '../types/limit-behavior.js';
3
+
4
+ /**
5
+ * Options for running a single task.
6
+ */
7
+ export interface RateLimiterRunOptions {
8
+ /**
9
+ * A unique identifier for the task for logging and debugging.
10
+ *
11
+ * If not provided, the library will generate a unique ID.
12
+ */
13
+ id?: string;
14
+
15
+ /**
16
+ * A storage key for the task.
17
+ */
18
+ key?: string;
19
+
20
+ /**
21
+ * The cost to consume the limit.
22
+ *
23
+ * @default 1
24
+ */
25
+ cost?: number;
26
+
27
+ /**
28
+ * Defines the behavior when the limit is reached for the current task. Overrides the global limit behavior
29
+ * set in {@link RateLimiterOptions.limitBehavior}.
30
+ *
31
+ * - `reject` - rejects the task with `LIMIT_EXCEEDED` error code
32
+ * - `enqueue` - enqueues the task if possible
33
+ *
34
+ * Defaults to global {@link RateLimiterOptions.limitBehavior}
35
+ */
36
+ limitBehavior?: LimitBehavior;
37
+
38
+ /**
39
+ * Task priority.
40
+ *
41
+ * @default Priority.Normal (3)
42
+ */
43
+ priority?: Priority;
44
+
45
+ /**
46
+ * An abort signal to abort the task execution.
47
+ */
48
+ signal?: AbortSignal;
49
+
50
+ /**
51
+ * Maximum wait time in milliseconds for the task in the queue.
52
+ *
53
+ * This does not affect execution time.
54
+ *
55
+ * @default Infinity
56
+ */
57
+ maxWaitMs?: number;
58
+ }
@@ -0,0 +1,206 @@
1
+ import { Priority } from '@stimulcross/ds-policy-priority-queue';
2
+ import { createLogger, type Logger, LogLevel } from '@stimulcross/logger';
3
+ import { type Clock } from '../core/clock.js';
4
+ import { type RateLimitPolicy } from '../core/rate-limit-policy.js';
5
+ import { type RateLimiterStatus } from '../core/rate-limiter-status.js';
6
+ import { type RateLimiter } from '../core/rate-limiter.js';
7
+ import { type StateStorage } from '../core/state-storage.js';
8
+ import { RateLimitErrorCode } from '../enums/rate-limit-error-code.js';
9
+ import { RateLimitError } from '../errors/rate-limit.error.js';
10
+ import { RateLimiterDestroyedError } from '../errors/rate-limiter-destroyed.error.js';
11
+ import { type IdGenerator, type KeyResolver, type RateLimiterOptions } from '../interfaces/rate-limiter-options.js';
12
+ import { type RateLimiterRunOptions } from '../interfaces/rate-limiter-run-options.js';
13
+ import { defaultClock } from '../runtime/default-clock.js';
14
+ import { InMemoryStateStore } from '../runtime/in-memory-state-store.js';
15
+ import { RateLimiterExecutor } from '../runtime/rate-limiter.executor.js';
16
+ import { type LimitBehavior } from '../types/limit-behavior.js';
17
+ import { generateRandomString } from '../utils/generate-random-string.js';
18
+
19
+ const DEFAULT_KEY_PREFIX = 'limiter';
20
+
21
+ /** @internal */
22
+ export interface ExecutionContext {
23
+ readonly id: string;
24
+ readonly key: string;
25
+ readonly cost: number;
26
+ readonly limitBehavior?: LimitBehavior;
27
+ readonly priority?: Priority;
28
+ readonly signal?: AbortSignal;
29
+ readonly maxWaitMs?: number;
30
+ }
31
+
32
+ /** @internal */
33
+ export abstract class AbstractRateLimiter<
34
+ TState extends object,
35
+ TStatus extends RateLimiterStatus | RateLimiterStatus[],
36
+ TResult = unknown,
37
+ > implements RateLimiter<TStatus> {
38
+ protected readonly _logger: Logger;
39
+ protected readonly _clock: Clock;
40
+ protected readonly _store: StateStorage<TState>;
41
+ protected readonly _executor: RateLimiterExecutor;
42
+ protected readonly _getStoreKey: KeyResolver;
43
+ protected readonly _generateId: IdGenerator;
44
+
45
+ private _isDestroyed = false;
46
+
47
+ protected abstract readonly _policy: RateLimitPolicy<TState, TStatus>;
48
+
49
+ protected constructor(options?: RateLimiterOptions<TState>) {
50
+ this._logger = createLogger(new.target.name, { minLevel: 'WARNING', ...options?.loggerOptions });
51
+ this._clock = options?.clock ?? defaultClock;
52
+ this._store = options?.store ?? new InMemoryStateStore<TState>(this._clock);
53
+ this._executor = new RateLimiterExecutor(this._logger, this._clock, options?.queue);
54
+
55
+ this._getStoreKey =
56
+ options?.key && typeof options.key === 'function'
57
+ ? options.key
58
+ : (key?: string): string => (key ? `${DEFAULT_KEY_PREFIX}:${key}` : DEFAULT_KEY_PREFIX);
59
+
60
+ this._generateId = options?.idGenerator ?? generateRandomString;
61
+ }
62
+
63
+ public async run<T = TResult>(fn: () => T | Promise<T>, options: RateLimiterRunOptions = {}): Promise<T> {
64
+ const ctx: ExecutionContext = {
65
+ id: options.id ?? this._generateId(),
66
+ key: this._getStoreKey(options.key),
67
+ cost: options.cost ?? 1,
68
+ limitBehavior: options.limitBehavior,
69
+ priority: options.priority,
70
+ signal: options.signal,
71
+ maxWaitMs: options.maxWaitMs,
72
+ };
73
+
74
+ await this._ensureCanExecute(ctx);
75
+
76
+ return await this._runInternal<T>(fn, ctx);
77
+ }
78
+
79
+ public async getStatus(key?: string): Promise<TStatus> {
80
+ const now = this._clock.now();
81
+ const state = await this._store.get(this._getStoreKey(key));
82
+
83
+ return this._policy.getStatus(state ?? this._policy.getInitialState(now), now);
84
+ }
85
+
86
+ public async clear(key?: string): Promise<void> {
87
+ this._executor.clear();
88
+ const storeKey = this._getStoreKey(key);
89
+
90
+ this._shouldPrintDebug && this._logger.debug(`[CLR] [key: ${storeKey}]`);
91
+
92
+ await this._store.acquireLock?.(storeKey);
93
+
94
+ try {
95
+ await this._store.delete(storeKey);
96
+ } finally {
97
+ await this._store.releaseLock?.(storeKey);
98
+ }
99
+ }
100
+
101
+ public async destroy(): Promise<void> {
102
+ if (this._isDestroyed) {
103
+ return;
104
+ }
105
+
106
+ this._shouldPrintDebug && this._logger.debug('[KILL] Destroying limiter');
107
+
108
+ this._isDestroyed = true;
109
+
110
+ this._executor.clear();
111
+ await this._store.destroy?.();
112
+ }
113
+
114
+ protected get _shouldPrintDebug(): boolean {
115
+ return this._logger.minLevel >= LogLevel.DEBUG;
116
+ }
117
+
118
+ protected abstract _runInternal<T = TResult>(fn: () => T | Promise<T>, ctx: ExecutionContext): Promise<T>;
119
+
120
+ protected async _execute<T = TResult>(
121
+ fn: () => T | Promise<T>,
122
+ runAt: number,
123
+ storeTtlMs: number,
124
+ ctx: ExecutionContext,
125
+ expiresAt?: number,
126
+ ): Promise<T> {
127
+ await this._ensureCanExecute(ctx);
128
+
129
+ try {
130
+ return await this._executor.execute<T>(fn, runAt, {
131
+ id: ctx.id,
132
+ key: ctx.key,
133
+ priority: ctx.priority,
134
+ signal: ctx.signal,
135
+ expiresAt,
136
+ });
137
+ } catch (e) {
138
+ if (this._shouldRevert(e)) {
139
+ await this._store.acquireLock?.(ctx.key);
140
+
141
+ try {
142
+ const currentState = await this._store.get(ctx.key);
143
+
144
+ if (currentState) {
145
+ const revertedState = this._policy.revert(currentState, ctx.cost, this._clock.now());
146
+ await this._store.set(ctx.key, revertedState, storeTtlMs);
147
+
148
+ this._shouldPrintDebug &&
149
+ this._logger.debug(
150
+ `[RVRT] [id: ${ctx.id}, key: ${ctx.key}, cost: ${ctx.cost}] - ${this._getDebugStateString(revertedState)}`,
151
+ );
152
+ }
153
+ } catch (e_) {
154
+ this._logger.error(`[ERR] [id: ${ctx.id}, key: ${ctx.key}, cost: ${ctx.cost}] - Revert failed`, e_);
155
+ } finally {
156
+ await this._store.releaseLock?.(ctx.key);
157
+ }
158
+ }
159
+
160
+ throw e;
161
+ }
162
+ }
163
+
164
+ protected abstract _getDebugStateString(state: TState): string;
165
+
166
+ protected _shouldRevert(e: unknown): boolean {
167
+ return e instanceof RateLimitError && e.code !== RateLimitErrorCode.LimitExceeded;
168
+ }
169
+
170
+ private async _ensureCanExecute(ctx: ExecutionContext): Promise<void> {
171
+ if (this._executor.isQueueFull) {
172
+ this._shouldPrintDebug &&
173
+ this._logger.debug(
174
+ `[DROP OVERFLOW] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority ?? Priority.Normal} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}`,
175
+ );
176
+
177
+ let retryAt: number | undefined;
178
+ const state = await this._store.get(this._getStoreKey());
179
+
180
+ if (state) {
181
+ const status = this._policy.getStatus(state, this._clock.now());
182
+ retryAt = Array.isArray(status)
183
+ ? Math.min(...status.map(s => s.nextAvailableAt))
184
+ : status.nextAvailableAt;
185
+ }
186
+
187
+ throw new RateLimitError(RateLimitErrorCode.QueueOverflow, retryAt);
188
+ }
189
+
190
+ if (ctx.signal?.aborted) {
191
+ this._logger.debug(
192
+ `[DROP CANCELLED] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}`,
193
+ );
194
+
195
+ throw new RateLimitError(RateLimitErrorCode.Cancelled);
196
+ }
197
+
198
+ if (this._isDestroyed) {
199
+ this._logger.debug(
200
+ `[DROP DESTROYED] [id: ${ctx.id}, key: ${ctx.key}] - prt: ${ctx.priority ?? Priority.Normal} | q: ${this._executor.queueSize}/${this._executor.queueCapacity}}`,
201
+ );
202
+
203
+ throw new RateLimiterDestroyedError();
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,102 @@
1
+ import { type DecisionKind } from '../core/decision.js';
2
+ import { type RateLimitPolicy, type RateLimitPolicyResult } from '../core/rate-limit-policy.js';
3
+ import { validateCost } from '../utils/validate-cost.js';
4
+
5
+ type CompositeState<T> = T[];
6
+ type CompositeInfo<T> = T[];
7
+
8
+ /** @internal */
9
+ export class CompositePolicy<
10
+ S extends object = object,
11
+ I extends object = object,
12
+ P extends RateLimitPolicy<S, I> = RateLimitPolicy<S, I>,
13
+ > implements RateLimitPolicy<CompositeState<S>, CompositeInfo<I>> {
14
+ constructor(private readonly _policies: P[]) {}
15
+
16
+ public get policies(): P[] {
17
+ return this._policies;
18
+ }
19
+
20
+ public getInitialState(now: number): CompositeState<S> {
21
+ return this._policies.map(policy => policy.getInitialState(now));
22
+ }
23
+
24
+ public getStatus(states: CompositeState<S>, now: number): CompositeInfo<I> {
25
+ const result: CompositeInfo<I> = [];
26
+
27
+ for (let i = 0; i < this._policies.length; i++) {
28
+ const policy = this._policies[i];
29
+ const state = states[i];
30
+ const info = policy.getStatus(state, now);
31
+
32
+ result.push(info);
33
+ }
34
+
35
+ return result;
36
+ }
37
+
38
+ public evaluate(
39
+ states: CompositeState<S>,
40
+ now: number,
41
+ cost?: number,
42
+ shouldReserve?: boolean,
43
+ ): RateLimitPolicyResult<CompositeState<S>> {
44
+ if (cost === undefined) {
45
+ cost = 1;
46
+ } else {
47
+ validateCost(cost);
48
+ }
49
+
50
+ const results: Array<RateLimitPolicyResult<S>> = [];
51
+
52
+ let compositeDecision: DecisionKind = 'allow';
53
+ let maxRetryAt = 0;
54
+ let maxRunAt = 0;
55
+
56
+ for (let i = 0; i < this._policies.length; i++) {
57
+ const policy = this._policies[i];
58
+ const state = states[i];
59
+
60
+ const result = policy.evaluate(state, now, cost, shouldReserve);
61
+ results.push(result);
62
+
63
+ if (result.decision.kind === 'deny') {
64
+ compositeDecision = 'deny';
65
+ maxRetryAt = Math.max(maxRetryAt, result.decision.retryAt);
66
+ } else if (result.decision.kind === 'delay') {
67
+ if (compositeDecision !== 'deny') {
68
+ compositeDecision = 'delay';
69
+ }
70
+
71
+ maxRunAt = Math.max(maxRunAt, result.decision.runAt);
72
+ }
73
+ }
74
+
75
+ if (compositeDecision === 'deny') {
76
+ const nextState = results.map((res, i) =>
77
+ res.decision.kind === 'deny' ? res.nextState : this._policies[i].revert(res.nextState, cost, now),
78
+ );
79
+
80
+ return {
81
+ decision: { kind: 'deny', retryAt: maxRetryAt },
82
+ nextState,
83
+ };
84
+ }
85
+
86
+ if (compositeDecision === 'delay') {
87
+ return {
88
+ decision: { kind: 'delay', runAt: maxRunAt },
89
+ nextState: results.map(res => res.nextState),
90
+ };
91
+ }
92
+
93
+ return {
94
+ decision: { kind: 'allow' },
95
+ nextState: results.map(res => res.nextState),
96
+ };
97
+ }
98
+
99
+ public revert(states: S[], cost: number, now: number): S[] {
100
+ return states.map((state, i) => this._policies[i].revert(state, cost, now));
101
+ }
102
+ }
@@ -0,0 +1,121 @@
1
+ import { LogLevel } from '@stimulcross/logger';
2
+ import { type FixedWindowOptions } from './fixed-window.options.js';
3
+ import { FixedWindowPolicy } from './fixed-window.policy.js';
4
+ import { type FixedWindowState } from './fixed-window.state.js';
5
+ import { type FixedWindowStatus } from './fixed-window.status.js';
6
+ import { type Decision } from '../../core/decision.js';
7
+ import { RateLimitErrorCode } from '../../enums/rate-limit-error-code.js';
8
+ import { RateLimitError } from '../../errors/rate-limit.error.js';
9
+ import { type LimitBehavior } from '../../types/limit-behavior.js';
10
+ import { AbstractRateLimiter, type ExecutionContext } from '../abstract-rate-limiter.js';
11
+ import { CompositePolicy } from '../composite.policy.js';
12
+
13
+ /**
14
+ * Fixed Window rate limiter.
15
+ *
16
+ * Designed primarily for client-side use to respect third-party limits or protect resources.
17
+ * While this can be used as a server-side limiter with custom distributed storage
18
+ * (e.g., Redis), it is best-effort and not recommended due to high network round-trip latency.
19
+ *
20
+ * Key features:
21
+ * - **Composite limits** - supports multiple windows simultaneously (e.g., 10 per second AND 1000 per hour)
22
+ * - **Queueing & overflow** - optionally enqueues excess requests up to a maximum allowed overflow capacity
23
+ * - **Concurrency** - limits how many requests can be executed simultaneously
24
+ * - **Priority** - supports task priorities (with fairness and custom policy) to execute critical requests first
25
+ * - **Cancellation** - supports `AbortSignal` to safely remove pending requests from the queue
26
+ * - **Expiration** - automatically drops queued requests that wait longer than the allowed `maxWaitMs`
27
+ * - **Auto-rollback** - reverts spent quota if an enqueued task is canceled or expired
28
+ */
29
+ export class FixedWindowLimiter extends AbstractRateLimiter<FixedWindowState[], FixedWindowStatus[]> {
30
+ private readonly _defaultLimitBehaviour: LimitBehavior;
31
+ private readonly _defaultMaxWaitMs: number | undefined;
32
+ private readonly _maxWindowSizeMs: number;
33
+
34
+ protected override readonly _policy: CompositePolicy<FixedWindowState, FixedWindowStatus, FixedWindowPolicy>;
35
+
36
+ constructor(options: FixedWindowOptions) {
37
+ super(options);
38
+
39
+ this._defaultLimitBehaviour = options.limitBehavior ?? 'reject';
40
+
41
+ if (options.queue?.maxWaitMs) {
42
+ this._defaultMaxWaitMs = options.queue.maxWaitMs;
43
+ }
44
+
45
+ this._policy = new CompositePolicy(
46
+ Array.isArray(options.limitOptions)
47
+ ? options.limitOptions.map(({ limit, windowMs }) => new FixedWindowPolicy(limit, windowMs))
48
+ : [new FixedWindowPolicy(options.limitOptions.limit, options.limitOptions.windowMs)],
49
+ );
50
+
51
+ this._maxWindowSizeMs = Math.max(...this._policy.policies.map(policy => policy.windowMs));
52
+ }
53
+
54
+ protected override async _runInternal<T>(fn: () => T | Promise<T>, ctx: ExecutionContext): Promise<T> {
55
+ const now = this._clock.now();
56
+
57
+ let runAt: number;
58
+ let storeTtlMs: number;
59
+
60
+ await this._store.acquireLock?.(ctx.key);
61
+
62
+ try {
63
+ const state = (await this._store.get(ctx.key)) ?? this._policy.getInitialState(this._clock.now());
64
+ const finalLimitBehavior = ctx.limitBehavior ?? this._defaultLimitBehaviour;
65
+
66
+ const { decision, nextState } = this._policy.evaluate(
67
+ state,
68
+ now,
69
+ ctx.cost,
70
+ finalLimitBehavior === 'enqueue',
71
+ );
72
+
73
+ if (decision.kind === 'deny') {
74
+ this._logger.debug(`[DENY] [id: ${ctx.id}, key: ${ctx.key}] - Retry: +${decision.retryAt - now}ms`);
75
+ throw new RateLimitError(RateLimitErrorCode.LimitExceeded, decision.retryAt);
76
+ }
77
+
78
+ runAt = decision.kind === 'delay' ? decision.runAt : now;
79
+ storeTtlMs = Math.max(this._maxWindowSizeMs, runAt - now + this._maxWindowSizeMs);
80
+
81
+ await this._store.set(ctx.key, nextState, storeTtlMs);
82
+
83
+ this._printDebug(decision, nextState, now, ctx);
84
+ } finally {
85
+ await this._store.releaseLock?.(ctx.key);
86
+ }
87
+
88
+ const finalMaxWaitMs = ctx.maxWaitMs ?? this._defaultMaxWaitMs;
89
+ const expiresAt = finalMaxWaitMs ? now + finalMaxWaitMs : undefined;
90
+
91
+ return await this._execute(fn, runAt, storeTtlMs, ctx, expiresAt);
92
+ }
93
+
94
+ protected override _getDebugStateString(state: FixedWindowState[]): string {
95
+ const result: string[] = [];
96
+
97
+ for (const [i, { used, reserved }] of state.entries()) {
98
+ const { windowMs, limit } = this._policy.policies[i];
99
+
100
+ result.push(`w/l: ${windowMs}/${limit}; u/r: ${used}/${reserved}`);
101
+ }
102
+
103
+ return result.join(', ');
104
+ }
105
+
106
+ private _printDebug(decision: Decision, nextState: FixedWindowState[], now: number, ctx: ExecutionContext): void {
107
+ if (this._logger.minLevel < LogLevel.DEBUG) {
108
+ return;
109
+ }
110
+
111
+ const debugStateString = this._getDebugStateString(nextState);
112
+
113
+ if (decision.kind === 'delay') {
114
+ this._logger.debug(
115
+ `[DELAY] [id: ${ctx.id}, key: ${ctx.key}] +${decision.runAt - now}ms - ${debugStateString}`,
116
+ );
117
+ } else {
118
+ this._logger.debug(`[ALLOW] [id: ${ctx.id}, key: ${ctx.key}] - ${debugStateString}`);
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,29 @@
1
+ import { type FixedWindowState } from './fixed-window.state.js';
2
+ import { type RateLimiterOptions } from '../../interfaces/rate-limiter-options.js';
3
+
4
+ /**
5
+ * Fixed Window limit options.
6
+ */
7
+ export interface FixedWindowLimitOptions {
8
+ /**
9
+ * Maximum number of requests allowed within the time window.
10
+ */
11
+ limit: number;
12
+
13
+ /**
14
+ * Duration of the time window in milliseconds.
15
+ */
16
+ windowMs: number;
17
+ }
18
+
19
+ /**
20
+ * Options for the Fixed Window rate limiter.
21
+ */
22
+ export interface FixedWindowOptions extends RateLimiterOptions<FixedWindowState[]> {
23
+ /**
24
+ * Options time window.
25
+ *
26
+ * Can be a single object or an array of objects for composite time windows.
27
+ */
28
+ limitOptions: FixedWindowLimitOptions | FixedWindowLimitOptions[];
29
+ }