@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
@@ -0,0 +1,371 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { type Clock, RateLimiterDestroyedError, RateLimitErrorCode } from '../../../src/index.js';
3
+ import { FixedWindowLimiter, type FixedWindowState } from '../../../src/limiters/fixed-window/index.js';
4
+ import { InMemoryStateStore } from '../../../src/runtime/in-memory-state-store.js';
5
+
6
+ describe('FixedWindowLimiter (Integration)', () => {
7
+ let clock: Clock;
8
+ let store: InMemoryStateStore<FixedWindowState[]>;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ vi.setSystemTime(10_000);
13
+
14
+ clock = { now: () => Date.now() };
15
+ store = new InMemoryStateStore(clock);
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.clearAllTimers();
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ describe('Reject mode (immediate decisions)', () => {
24
+ it('should allow requests within the limit immediately', async () => {
25
+ const limiter = new FixedWindowLimiter({
26
+ limitBehavior: 'reject',
27
+ limitOptions: { limit: 2, windowMs: 1000 },
28
+ clock,
29
+ store,
30
+ });
31
+
32
+ const res1 = await limiter.run(async () => 'A');
33
+ const res2 = await limiter.run(async () => 'B');
34
+
35
+ expect(res1).toBe('A');
36
+ expect(res2).toBe('B');
37
+ });
38
+
39
+ it('should reject requests that exceed the limit if behavior is "reject"', async () => {
40
+ const limiter = new FixedWindowLimiter({
41
+ limitBehavior: 'reject',
42
+ limitOptions: { limit: 1, windowMs: 1000 },
43
+ clock,
44
+ store,
45
+ });
46
+
47
+ await limiter.run(() => 'A');
48
+
49
+ const promise = limiter.run(() => 'B');
50
+ await expect(promise).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
51
+ });
52
+
53
+ it('should consume multiple tokens if cost > 1', async () => {
54
+ const limiter = new FixedWindowLimiter({
55
+ limitBehavior: 'reject',
56
+ limitOptions: { limit: 5, windowMs: 1000 },
57
+ clock,
58
+ store,
59
+ });
60
+
61
+ await expect(limiter.run(() => 'A', { cost: 3 })).resolves.toBe('A');
62
+ await expect(limiter.run(() => 'B', { cost: 3 })).rejects.toMatchObject({
63
+ code: RateLimitErrorCode.LimitExceeded,
64
+ });
65
+ });
66
+ });
67
+
68
+ describe('Enqueue mode (queueing & scheduling)', () => {
69
+ it('should delay request and execute it in the next window', async () => {
70
+ const limiter = new FixedWindowLimiter({
71
+ limitBehavior: 'enqueue',
72
+ limitOptions: { limit: 1, windowMs: 1000 },
73
+ clock,
74
+ store,
75
+ });
76
+
77
+ const spy = vi.fn().mockReturnValue('B');
78
+
79
+ // window 10_000
80
+ await limiter.run(() => 'A');
81
+
82
+ // enqueued for 11_000
83
+ const pendingPromise = limiter.run<unknown>(spy);
84
+
85
+ expect(spy).not.toHaveBeenCalled();
86
+
87
+ await vi.advanceTimersByTimeAsync(1000);
88
+
89
+ const result = await pendingPromise;
90
+ expect(spy).toHaveBeenCalledOnce();
91
+ expect(result).toBe('B');
92
+ });
93
+
94
+ it('should maintain order and timings for multiple queued requests', async () => {
95
+ const limiter = new FixedWindowLimiter({
96
+ limitBehavior: 'enqueue',
97
+ limitOptions: { limit: 1, windowMs: 1000 },
98
+ clock,
99
+ store,
100
+ });
101
+
102
+ const results: string[] = [];
103
+ const push = (val: string) => () => results.push(val);
104
+
105
+ // instantly executed
106
+ const pA = limiter.run(push('A'));
107
+ // enqueued for 11_000
108
+ const pB = limiter.run(push('B'));
109
+ // enqueued for 12_000
110
+ const pC = limiter.run(push('C'));
111
+
112
+ await pA;
113
+ expect(results).toEqual(['A']);
114
+
115
+ // t = 11_000
116
+ await vi.advanceTimersByTimeAsync(1000);
117
+ await pB;
118
+ expect(results).toEqual(['A', 'B']);
119
+
120
+ // t = 12_000
121
+ await vi.advanceTimersByTimeAsync(1000);
122
+ await pC;
123
+ expect(results).toEqual(['A', 'B', 'C']);
124
+ });
125
+
126
+ it('should reject with QueueOverflow if executor queue exceeds capacity', async () => {
127
+ const limiter = new FixedWindowLimiter({
128
+ limitBehavior: 'enqueue',
129
+ limitOptions: { limit: 1, windowMs: 1000 },
130
+ queue: { capacity: 1 },
131
+ clock,
132
+ store,
133
+ });
134
+
135
+ const pA = limiter.run(() => 'A');
136
+ const pB = limiter.run(() => 'B');
137
+
138
+ // overflow
139
+ const pC = limiter.run(() => 'C');
140
+
141
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.QueueOverflow });
142
+
143
+ await vi.advanceTimersByTimeAsync(0);
144
+ await expect(pA).resolves.toBe('A');
145
+
146
+ await vi.advanceTimersByTimeAsync(1000);
147
+ await expect(pB).resolves.toBe('B');
148
+ });
149
+
150
+ it('should execute queued tasks based on priority order, not just chronological', async () => {
151
+ const limiter = new FixedWindowLimiter({
152
+ limitBehavior: 'enqueue',
153
+ limitOptions: { limit: 2, windowMs: 1000 },
154
+ clock,
155
+ store,
156
+ });
157
+
158
+ void limiter.run(() => 'A', { priority: 1 });
159
+ void limiter.run(() => 'B', { priority: 1 });
160
+
161
+ const order: string[] = [];
162
+
163
+ void limiter.run(() => order.push('Lowest'), { priority: 1 });
164
+ void limiter.run(() => order.push('Highest'), { priority: 5 });
165
+
166
+ await vi.advanceTimersByTimeAsync(1000);
167
+
168
+ expect(order).toEqual(['Highest', 'Lowest']);
169
+ });
170
+
171
+ it('should enqueue the task and reject it with Expired when TTL is reached', async () => {
172
+ const limiter = new FixedWindowLimiter({
173
+ limitBehavior: 'enqueue',
174
+ limitOptions: { limit: 1, windowMs: 1000 },
175
+ queue: { maxWaitMs: 1500 },
176
+ clock,
177
+ store,
178
+ });
179
+
180
+ // immediately executed
181
+ const pA = limiter.run(() => 'A');
182
+ // expected t = 11_000, expires at 11_500
183
+ const pB = limiter.run(() => 'B');
184
+ // expected t = 12_000 but expires at 11_500
185
+ const spyC = vi.fn().mockReturnValue('C');
186
+ const pC = limiter.run(spyC);
187
+ pC.catch(() => {});
188
+
189
+ await expect(pA).resolves.toBe('A');
190
+
191
+ // t = 11_000
192
+ await vi.advanceTimersByTimeAsync(1000);
193
+
194
+ await expect(pB).resolves.toBe('B');
195
+
196
+ expect(spyC).not.toHaveBeenCalled();
197
+
198
+ // t = 11_500
199
+ await vi.advanceTimersByTimeAsync(500);
200
+
201
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
202
+ expect(spyC).not.toHaveBeenCalled();
203
+ });
204
+
205
+ it('should free up the canceled ticket for new requests, while keeping already queued requests at their scheduled time', async () => {
206
+ const limiter = new FixedWindowLimiter({
207
+ limitBehavior: 'enqueue',
208
+ limitOptions: { limit: 1, windowMs: 1000 },
209
+ clock,
210
+ store,
211
+ });
212
+
213
+ // tA = 10_000 (now)
214
+ const pA = limiter.run(() => 'A');
215
+
216
+ // tB = 11_000
217
+ const controllerB = new AbortController();
218
+ const pB = limiter.run(() => 'B', { signal: controllerB.signal });
219
+
220
+ // tC = 12_000
221
+ const spyC = vi.fn().mockReturnValue('C');
222
+ const pC = limiter.run(spyC);
223
+
224
+ // settle
225
+ await vi.advanceTimersByTimeAsync(0);
226
+
227
+ // cancel B before t = 11_000
228
+ controllerB.abort();
229
+ await expect(pB).rejects.toMatchObject({ code: RateLimitErrorCode.Cancelled });
230
+
231
+ // tD = 12_000 (instead of 13_000, because B was canceled before execution)
232
+ const spyD = vi.fn().mockReturnValue('D');
233
+ const pD = limiter.run(spyD);
234
+
235
+ await expect(pA).resolves.toBe('A');
236
+ expect(spyC).not.toHaveBeenCalled();
237
+ expect(spyD).not.toHaveBeenCalled();
238
+
239
+ // t = 11_000
240
+ await vi.advanceTimersByTimeAsync(1000);
241
+
242
+ // C should take the ticket created by B (that was canceled)
243
+ await expect(pC).resolves.toBe('C');
244
+ expect(spyC).toHaveBeenCalledOnce();
245
+
246
+ expect(spyD).not.toHaveBeenCalled();
247
+
248
+ // t = 12_000
249
+ await vi.advanceTimersByTimeAsync(1000);
250
+
251
+ await expect(pD).resolves.toBe('D');
252
+ expect(spyD).toHaveBeenCalledOnce();
253
+ });
254
+ });
255
+
256
+ describe('Multiple windows', () => {
257
+ it('should respect the most restrictive limit in composite policies', async () => {
258
+ const limiter = new FixedWindowLimiter({
259
+ limitBehavior: 'enqueue',
260
+ limitOptions: [
261
+ { limit: 2, windowMs: 1000 },
262
+ { limit: 3, windowMs: 5000 },
263
+ ],
264
+ clock,
265
+ store,
266
+ });
267
+
268
+ // t = 10_000 (now)
269
+ void limiter.run(() => 'A'); // 1/2 (1s), 1/3 (5s)
270
+ void limiter.run(() => 'B'); // 2/2 (1s), 2/3 (5s)
271
+
272
+ // 1s limit; delayed to 11_000 window
273
+ const pC = limiter.run(() => 'C'); // 1/2 (1s), 3/3 (5s)
274
+
275
+ // 5s limit; delayed to 15_000 window
276
+ const pD = limiter.run(() => 'D');
277
+
278
+ await vi.advanceTimersByTimeAsync(1000);
279
+ await expect(pC).resolves.toBe('C');
280
+
281
+ // t = 12_000
282
+ await vi.advanceTimersByTimeAsync(1000);
283
+
284
+ let dResolved = false;
285
+ void pD.then(() => {
286
+ dResolved = true;
287
+ });
288
+
289
+ await vi.advanceTimersByTimeAsync(0);
290
+
291
+ expect(dResolved).toBe(false);
292
+
293
+ // t = 15_000
294
+ await vi.advanceTimersByTimeAsync(3000);
295
+
296
+ await expect(pD).resolves.toBe('D');
297
+ });
298
+ });
299
+
300
+ describe('Runtime overrides', () => {
301
+ it('should allow overriding limitBehavior per task', async () => {
302
+ const limiter = new FixedWindowLimiter({
303
+ limitBehavior: 'reject',
304
+ limitOptions: { limit: 1, windowMs: 1000 },
305
+ clock,
306
+ store,
307
+ });
308
+
309
+ await limiter.run(() => 'A');
310
+ const pB = limiter.run(() => 'B', { limitBehavior: 'enqueue' });
311
+
312
+ await vi.advanceTimersByTimeAsync(1000);
313
+ await expect(pB).resolves.toBe('B');
314
+ });
315
+
316
+ it('should override max wait time for a specific task and expire it independently', async () => {
317
+ const limiter = new FixedWindowLimiter({
318
+ limitBehavior: 'enqueue',
319
+ limitOptions: { limit: 1, windowMs: 1000 },
320
+ queue: { maxWaitMs: 5000 },
321
+ clock,
322
+ store,
323
+ });
324
+
325
+ void limiter.run(() => 'A');
326
+
327
+ const pB = limiter.run(() => 'B');
328
+ const pC = limiter.run(() => 'C', { maxWaitMs: 1500 });
329
+ pC.catch(() => {});
330
+ const pD = limiter.run(() => 'D', { maxWaitMs: 4000 });
331
+
332
+ await vi.advanceTimersByTimeAsync(1000);
333
+ await expect(pB).resolves.toBe('B');
334
+
335
+ await vi.advanceTimersByTimeAsync(500);
336
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
337
+
338
+ await vi.advanceTimersByTimeAsync(1500);
339
+ await expect(pD).resolves.toBe('D');
340
+ });
341
+ });
342
+
343
+ describe('State lifecycle', () => {
344
+ it('should reset limits and clear queued tasks on clear()', async () => {
345
+ const limiter = new FixedWindowLimiter({
346
+ limitBehavior: 'reject',
347
+ limitOptions: { limit: 1, windowMs: 1000 },
348
+ clock,
349
+ store,
350
+ });
351
+
352
+ await limiter.run(() => 'A');
353
+
354
+ await limiter.clear();
355
+
356
+ await expect(limiter.run(() => 'B')).resolves.toBe('B');
357
+ });
358
+
359
+ it('should throw RateLimiterDestroyedError after destroy() is called', async () => {
360
+ const limiter = new FixedWindowLimiter({
361
+ limitOptions: { limit: 5, windowMs: 1000 },
362
+ clock,
363
+ store,
364
+ });
365
+
366
+ await limiter.destroy();
367
+
368
+ await expect(limiter.run(() => 'A')).rejects.toThrow(RateLimiterDestroyedError);
369
+ });
370
+ });
371
+ });