@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
@@ -1,206 +0,0 @@
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
- }
@@ -1,102 +0,0 @@
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
- }
@@ -1,121 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }
@@ -1,159 +0,0 @@
1
- import { type FixedWindowState } from './fixed-window.state.js';
2
- import { type FixedWindowStatus } from './fixed-window.status.js';
3
- import { type RateLimitPolicy, type RateLimitPolicyResult } from '../../core/rate-limit-policy.js';
4
- import { validateCost } from '../../utils/validate-cost.js';
5
-
6
- /** @internal */
7
- export class FixedWindowPolicy implements RateLimitPolicy<FixedWindowState, FixedWindowStatus> {
8
- constructor(
9
- private readonly _limit: number,
10
- private readonly _windowMs: number,
11
- private readonly _maxReserved: number = Number.POSITIVE_INFINITY,
12
- ) {
13
- if (!Number.isSafeInteger(_limit) || _limit <= 0) {
14
- throw new Error(`Invalid limit: ${_limit}. Must be a positive integer.`);
15
- }
16
-
17
- if (!Number.isSafeInteger(_windowMs) || _windowMs <= 0) {
18
- throw new Error(`Invalid windowMs: ${_windowMs}. Must be a positive integer.`);
19
- }
20
-
21
- if (_maxReserved < 0 || (_maxReserved !== Number.POSITIVE_INFINITY && !Number.isSafeInteger(_maxReserved))) {
22
- throw new Error(`Invalid maxReserved: ${_maxReserved}. Must be a positive integer or Infinity.`);
23
- }
24
- }
25
-
26
- public get limit(): number {
27
- return this._limit;
28
- }
29
-
30
- public get windowMs(): number {
31
- return this._windowMs;
32
- }
33
-
34
- public getInitialState(): FixedWindowState {
35
- return { windowStart: 0, used: 0, reserved: 0 };
36
- }
37
-
38
- public getStatus(state: FixedWindowState, now: number): FixedWindowStatus {
39
- const { windowStart, used, reserved } = this._syncState(state, now);
40
- const windowEnd = windowStart + this._windowMs;
41
-
42
- let nextAvailableAt: number;
43
-
44
- if (used < this._limit) {
45
- nextAvailableAt = windowStart;
46
- } else {
47
- const blockedWindows = Math.floor((used + reserved) / this._limit);
48
- nextAvailableAt = windowStart + blockedWindows * this._windowMs;
49
- }
50
-
51
- const windowsToClear = Math.ceil((used + reserved) / this._limit);
52
- const resetAt = windowStart + Math.max(1, windowsToClear) * this._windowMs;
53
-
54
- return {
55
- windowStart,
56
- windowEnd,
57
- limit: this._limit,
58
- used,
59
- reserved,
60
- remaining: Math.max(0, this._limit - used),
61
- nextAvailableAt,
62
- resetAt,
63
- };
64
- }
65
-
66
- public evaluate(
67
- state: FixedWindowState,
68
- now: number,
69
- cost: number,
70
- shouldReserve?: boolean,
71
- ): RateLimitPolicyResult<FixedWindowState> {
72
- validateCost(cost, this._limit);
73
-
74
- const { windowStart, used, reserved } = this._syncState(state, now);
75
- const currentTotal = used + cost;
76
-
77
- if (reserved === 0 && currentTotal <= this._limit) {
78
- return {
79
- decision: { kind: 'allow' },
80
- nextState: { windowStart, used: currentTotal, reserved: 0 },
81
- };
82
- }
83
-
84
- if (shouldReserve) {
85
- if (reserved + cost > this._maxReserved) {
86
- const deficit = reserved + cost - this._maxReserved;
87
- const windowsToWait = Math.ceil(deficit / this._limit);
88
- const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
89
-
90
- return this._deny(windowStart, used, reserved, retryAt);
91
- }
92
-
93
- const totalItems = used + reserved + cost;
94
- const windowIndex = Math.floor((totalItems - 1) / this._limit);
95
- const runAt = windowStart + windowIndex * this._windowMs;
96
-
97
- return {
98
- decision: { kind: 'delay', runAt },
99
- nextState: {
100
- windowStart,
101
- used,
102
- reserved: reserved + cost,
103
- },
104
- };
105
- }
106
-
107
- const windowsToWait = Math.ceil((used + reserved + cost - this._limit) / this._limit);
108
- const retryAt = windowStart + Math.max(1, windowsToWait) * this._windowMs;
109
-
110
- return this._deny(windowStart, used, reserved, retryAt);
111
- }
112
-
113
- public revert(state: FixedWindowState, cost: number, now: number): FixedWindowState {
114
- let { used, reserved, windowStart } = this._syncState(state, now);
115
-
116
- if (reserved >= cost) {
117
- reserved -= cost;
118
- } else {
119
- const remainder = cost - reserved;
120
- reserved = 0;
121
- used = Math.max(0, used - remainder);
122
- }
123
-
124
- return { windowStart, used, reserved };
125
- }
126
-
127
- private _deny(
128
- start: number,
129
- used: number,
130
- reserved: number,
131
- retryAt: number,
132
- ): RateLimitPolicyResult<FixedWindowState> {
133
- return {
134
- decision: { kind: 'deny', retryAt },
135
- nextState: { windowStart: start, used, reserved },
136
- };
137
- }
138
-
139
- private _syncState(state: FixedWindowState, now: number): FixedWindowState {
140
- const currentWindowStart = Math.max(Math.floor(now / this._windowMs) * this._windowMs, state.windowStart);
141
-
142
- if (currentWindowStart <= state.windowStart) {
143
- return state;
144
- }
145
-
146
- let { reserved } = state;
147
- const windowsPassed = Math.floor((currentWindowStart - state.windowStart) / this._windowMs);
148
- const burnableWindows = windowsPassed - 1;
149
-
150
- if (burnableWindows > 0 && reserved > 0) {
151
- reserved = Math.max(0, reserved - burnableWindows * this._limit);
152
- }
153
-
154
- const used = Math.min(reserved, this._limit);
155
- reserved = Math.max(0, reserved - used);
156
-
157
- return { windowStart: currentWindowStart, used, reserved };
158
- }
159
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Fixed Window rate limiter state.
3
- *
4
- * When using a distributed state store, make sure it properly serializes and deserializes the state.
5
- */
6
- export interface FixedWindowState {
7
- windowStart: number;
8
- used: number;
9
- reserved: number;
10
- }
@@ -1,46 +0,0 @@
1
- import { type RateLimiterStatus } from '../../core/rate-limiter-status.js';
2
-
3
- /**
4
- * The status of the Fixed Window Limiter.
5
- */
6
- export interface FixedWindowStatus extends RateLimiterStatus {
7
- /**
8
- * The start of the current window.
9
- */
10
- readonly windowStart: number;
11
-
12
- /**
13
- * The end of the current window.
14
- */
15
- readonly windowEnd: number;
16
-
17
- /**
18
- * The maximum number of tokens can be used in the window.
19
- */
20
- readonly limit: number;
21
-
22
- /**
23
- * The number of tokens that have been used in the current window.
24
- */
25
- readonly used: number;
26
-
27
- /**
28
- * The number of tokens that are reserved for future windows.
29
- */
30
- readonly reserved: number;
31
-
32
- /**
33
- * The number of tokens that can be used in the current window.
34
- */
35
- readonly remaining: number;
36
-
37
- /**
38
- * The timestamp (in milliseconds) when the next token will be available for use.
39
- */
40
- readonly nextAvailableAt: number;
41
-
42
- /**
43
- * The timestamp (in milliseconds) when the limit will be reset (all tokens will be available for use again).
44
- */
45
- readonly resetAt: number;
46
- }
@@ -1,4 +0,0 @@
1
- export type { FixedWindowState } from './fixed-window.state.js';
2
- export type { FixedWindowStatus } from './fixed-window.status.js';
3
- export type { FixedWindowLimitOptions, FixedWindowOptions } from './fixed-window.options.js';
4
- export { FixedWindowLimiter } from './fixed-window.limiter.js';