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