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