@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,363 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { type Clock, RateLimiterDestroyedError, RateLimitErrorCode } from '../../../src/index.js';
3
+ import { TokenBucketLimiter, type TokenBucketState } from '../../../src/limiters/token-bucket/index.js';
4
+ import { InMemoryStateStore } from '../../../src/runtime/in-memory-state-store.js';
5
+
6
+ describe('TokenBucketLimiter (Integration)', () => {
7
+ let clock: Clock;
8
+ let store: InMemoryStateStore<TokenBucketState>;
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 capacity immediately', async () => {
25
+ const limiter = new TokenBucketLimiter({
26
+ limitBehavior: 'reject',
27
+ capacity: 2,
28
+ refillRate: 10,
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 capacity if behavior is "reject"', async () => {
41
+ const limiter = new TokenBucketLimiter({
42
+ limitBehavior: 'reject',
43
+ capacity: 1,
44
+ refillRate: 10,
45
+ clock,
46
+ store,
47
+ });
48
+
49
+ await 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 tokens if cost > 1', async () => {
56
+ const limiter = new TokenBucketLimiter({
57
+ limitBehavior: 'reject',
58
+ capacity: 5,
59
+ refillRate: 10,
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 tokens are refilled', async () => {
71
+ const limiter = new TokenBucketLimiter({
72
+ limitBehavior: 'reject',
73
+ capacity: 1,
74
+ refillRate: 10,
75
+ clock,
76
+ store,
77
+ });
78
+
79
+ await limiter.run(() => 'A');
80
+ await expect(limiter.run(() => 'B')).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
81
+
82
+ // 1 token refill
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 enough tokens are refilled', async () => {
91
+ const limiter = new TokenBucketLimiter({
92
+ limitBehavior: 'enqueue',
93
+ capacity: 1,
94
+ refillRate: 10,
95
+ clock,
96
+ store,
97
+ });
98
+
99
+ void limiter.run(() => 'A');
100
+
101
+ const pBSpy = vi.fn().mockReturnValue('B');
102
+ const pB = limiter.run<unknown>(pBSpy);
103
+
104
+ expect(pBSpy).not.toHaveBeenCalled();
105
+
106
+ await vi.advanceTimersByTimeAsync(100);
107
+
108
+ const result = await pB;
109
+ expect(pBSpy).toHaveBeenCalledOnce();
110
+ expect(result).toBe('B');
111
+ });
112
+
113
+ it('should maintain order and timings for multiple queued requests', async () => {
114
+ const limiter = new TokenBucketLimiter({
115
+ limitBehavior: 'enqueue',
116
+ capacity: 1,
117
+ refillRate: 10,
118
+ clock,
119
+ store,
120
+ });
121
+
122
+ void limiter.run(() => {});
123
+
124
+ const results: string[] = [];
125
+
126
+ // delay 100ms
127
+ const pA = limiter.run(() => results.push('A'));
128
+ // delay 200ms
129
+ const pB = limiter.run(() => results.push('B'));
130
+ // delay 300ms
131
+ const pC = limiter.run(() => results.push('C'));
132
+
133
+ await vi.advanceTimersByTimeAsync(100);
134
+ await pA;
135
+ expect(results).toEqual(['A']);
136
+
137
+ await vi.advanceTimersByTimeAsync(100);
138
+ await pB;
139
+ expect(results).toEqual(['A', 'B']);
140
+
141
+ await vi.advanceTimersByTimeAsync(100);
142
+ await pC;
143
+ expect(results).toEqual(['A', 'B', 'C']);
144
+ });
145
+
146
+ it('should reject with QueueOverflow if executor queue exceeds maxSize', async () => {
147
+ const limiter = new TokenBucketLimiter({
148
+ limitBehavior: 'enqueue',
149
+ capacity: 1,
150
+ refillRate: 10,
151
+ queue: { capacity: 1 },
152
+ clock,
153
+ store,
154
+ });
155
+
156
+ const pA = limiter.run(() => 'A');
157
+ const pB = limiter.run(() => 'B');
158
+
159
+ await vi.advanceTimersByTimeAsync(0);
160
+
161
+ // overflow
162
+ const pC = limiter.run(() => 'C');
163
+
164
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.QueueOverflow });
165
+
166
+ await vi.advanceTimersByTimeAsync(0);
167
+ await expect(pA).resolves.toBe('A');
168
+
169
+ await vi.advanceTimersByTimeAsync(100);
170
+ await expect(pB).resolves.toBe('B');
171
+ });
172
+
173
+ it('should execute queued tasks based on priority order, not just chronological', async () => {
174
+ const limiter = new TokenBucketLimiter({
175
+ limitBehavior: 'enqueue',
176
+ capacity: 2,
177
+ refillRate: 10,
178
+ clock,
179
+ store,
180
+ });
181
+
182
+ void limiter.run(() => 'A', { priority: 1 });
183
+ void limiter.run(() => 'B', { priority: 1 });
184
+
185
+ const order: string[] = [];
186
+
187
+ // delay 100ms
188
+ void limiter.run(() => order.push('Lowest'), { priority: 1 });
189
+ // delay 200мс
190
+ void limiter.run(() => order.push('Highest'), { priority: 5 });
191
+
192
+ await vi.advanceTimersByTimeAsync(100);
193
+ expect(order).toEqual(['Highest']);
194
+
195
+ await vi.advanceTimersByTimeAsync(100);
196
+ expect(order).toEqual(['Highest', 'Lowest']);
197
+ });
198
+
199
+ it('should enqueue the task and reject it with Expired when TTL is reached', async () => {
200
+ const limiter = new TokenBucketLimiter({
201
+ limitBehavior: 'enqueue',
202
+ capacity: 1,
203
+ refillRate: 10,
204
+ queue: { maxWaitMs: 150 },
205
+ clock,
206
+ store,
207
+ });
208
+
209
+ const pA = limiter.run(() => 'A');
210
+ // delay 100ms
211
+ const pB = limiter.run(() => 'B');
212
+ // delay 200ms, expires after 150ms
213
+ const spyC = vi.fn().mockReturnValue('C');
214
+ const pC = limiter.run(spyC);
215
+ pC.catch(() => {});
216
+
217
+ await expect(pA).resolves.toBe('A');
218
+
219
+ await vi.advanceTimersByTimeAsync(100);
220
+ await expect(pB).resolves.toBe('B');
221
+ expect(spyC).not.toHaveBeenCalled();
222
+
223
+ await vi.advanceTimersByTimeAsync(50);
224
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
225
+ expect(spyC).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('should free up the canceled ticket for new requests, while keeping already queued requests at their scheduled time', async () => {
229
+ const limiter = new TokenBucketLimiter({
230
+ limitBehavior: 'enqueue',
231
+ capacity: 1,
232
+ refillRate: 10,
233
+ clock,
234
+ store,
235
+ });
236
+
237
+ const pA = limiter.run(() => 'A');
238
+
239
+ const controllerB = new AbortController();
240
+ // delay 100ms
241
+ const pB = limiter.run(() => 'B', { signal: controllerB.signal });
242
+
243
+ const spyC = vi.fn().mockReturnValue('C');
244
+ // delay 200ms
245
+ const pC = limiter.run(spyC);
246
+
247
+ await vi.advanceTimersByTimeAsync(0);
248
+
249
+ // canceling B
250
+ // the 200ms ticket should be freed up, the consumed token should be returned to the bucket
251
+ controllerB.abort();
252
+ await expect(pB).rejects.toMatchObject({ code: RateLimitErrorCode.Cancelled });
253
+
254
+ const spyD = vi.fn().mockReturnValue('D');
255
+ // delay 200ms (it was freed up by canceling B)
256
+ const pD = limiter.run(spyD);
257
+
258
+ await expect(pA).resolves.toBe('A');
259
+ expect(spyC).not.toHaveBeenCalled();
260
+ expect(spyD).not.toHaveBeenCalled();
261
+
262
+ // t = 10_100
263
+ // C takes the 100ms ticket created by B
264
+ await vi.advanceTimersByTimeAsync(100);
265
+
266
+ await expect(pC).resolves.toBe('C');
267
+ expect(spyC).toHaveBeenCalledOnce();
268
+ expect(spyD).not.toHaveBeenCalled();
269
+
270
+ // t = 10_200
271
+ // D takes the 200ms ticket created by itself
272
+ await vi.advanceTimersByTimeAsync(100);
273
+
274
+ await expect(pD).resolves.toBe('D');
275
+ expect(spyD).toHaveBeenCalledOnce();
276
+ });
277
+ });
278
+
279
+ describe('Runtime overrides', () => {
280
+ it('should allow overriding limitBehavior per task', async () => {
281
+ const limiter = new TokenBucketLimiter({
282
+ limitBehavior: 'reject',
283
+ capacity: 1,
284
+ refillRate: 10,
285
+ clock,
286
+ store,
287
+ });
288
+
289
+ await limiter.run(() => 'A');
290
+ const pB = limiter.run(() => 'B', { limitBehavior: 'enqueue' });
291
+
292
+ await vi.advanceTimersByTimeAsync(100);
293
+ await expect(pB).resolves.toBe('B');
294
+ });
295
+
296
+ it('should override max wait time for a specific task and expire it independently', async () => {
297
+ const limiter = new TokenBucketLimiter({
298
+ limitBehavior: 'enqueue',
299
+ capacity: 1,
300
+ refillRate: 10,
301
+ queue: { maxWaitMs: 5000 },
302
+ clock,
303
+ store,
304
+ });
305
+
306
+ void limiter.run(() => 'A');
307
+
308
+ // delay 100ms
309
+ const pB = limiter.run(() => 'B');
310
+
311
+ // delay 200ms
312
+ // expires after 150ms, so it should be rejected
313
+ const pC = limiter.run(() => 'C', { maxWaitMs: 150 });
314
+ pC.catch(() => {});
315
+
316
+ // delay 300ms
317
+ const pD = limiter.run(() => 'D', { maxWaitMs: 400 });
318
+
319
+ // t=10_100
320
+ await vi.advanceTimersByTimeAsync(100);
321
+ await expect(pB).resolves.toBe('B');
322
+
323
+ // t=10_150
324
+ await vi.advanceTimersByTimeAsync(50);
325
+ await expect(pC).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
326
+
327
+ // t=10_300
328
+ await vi.advanceTimersByTimeAsync(150);
329
+ await expect(pD).resolves.toBe('D');
330
+ });
331
+ });
332
+
333
+ describe('State lifecycle', () => {
334
+ it('should reset limits and clear queued tasks on clear()', async () => {
335
+ const limiter = new TokenBucketLimiter({
336
+ limitBehavior: 'reject',
337
+ capacity: 1,
338
+ refillRate: 10,
339
+ clock,
340
+ store,
341
+ });
342
+
343
+ await limiter.run(() => 'A');
344
+
345
+ await limiter.clear();
346
+
347
+ await expect(limiter.run(() => 'B')).resolves.toBe('B');
348
+ });
349
+
350
+ it('should throw RateLimiterDestroyedError after destroy() is called', async () => {
351
+ const limiter = new TokenBucketLimiter({
352
+ capacity: 5,
353
+ refillRate: 10,
354
+ clock,
355
+ store,
356
+ });
357
+
358
+ await limiter.destroy();
359
+
360
+ await expect(limiter.run(() => 'A')).rejects.toThrow(RateLimiterDestroyedError);
361
+ });
362
+ });
363
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "include": ["./", "../src"]
4
+ }
@@ -0,0 +1,244 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InvalidCostError } from '../../../src/index.js';
3
+ import { CompositePolicy } from '../../../src/limiters/composite.policy.js';
4
+ import { FixedWindowPolicy } from '../../../src/limiters/fixed-window/fixed-window.policy.js';
5
+ import { type FixedWindowState, type FixedWindowStatus } from '../../../src/limiters/fixed-window/index.js';
6
+
7
+ const createFixedWindowCompositePolicy = (
8
+ policies: FixedWindowPolicy[],
9
+ ): CompositePolicy<FixedWindowState, FixedWindowStatus, FixedWindowPolicy> =>
10
+ new CompositePolicy<FixedWindowState, FixedWindowStatus, FixedWindowPolicy>(policies);
11
+
12
+ describe('CompositePolicy', () => {
13
+ describe('Initialization', () => {
14
+ it('should initialize with provided policies and expose them', () => {
15
+ const policies = [new FixedWindowPolicy(10, 1000), new FixedWindowPolicy(5, 5000)];
16
+ const composite = createFixedWindowCompositePolicy(policies);
17
+
18
+ expect(composite.policies).toHaveLength(2);
19
+ expect(composite.policies).toStrictEqual(policies);
20
+ });
21
+
22
+ it('should aggregate initial states from all underlying policies', () => {
23
+ const composite = createFixedWindowCompositePolicy([
24
+ new FixedWindowPolicy(10, 1000),
25
+ new FixedWindowPolicy(5, 5000),
26
+ ]);
27
+
28
+ const states = composite.getInitialState(0);
29
+
30
+ expect(states).toHaveLength(2);
31
+ expect(states[0]).toEqual({ windowStart: 0, used: 0, reserved: 0 });
32
+ expect(states[1]).toEqual({ windowStart: 0, used: 0, reserved: 0 });
33
+ });
34
+ });
35
+
36
+ describe('Get Status', () => {
37
+ const policies = [new FixedWindowPolicy(10, 1000), new FixedWindowPolicy(5, 5000)];
38
+ const composite = createFixedWindowCompositePolicy(policies);
39
+
40
+ it('should return correct status for empty state', () => {
41
+ const state = composite.getInitialState(1000);
42
+ const status = composite.getStatus(state, 500);
43
+
44
+ expect(status.map(inf => inf.windowStart)).toEqual([0, 0]);
45
+ expect(status.map(inf => inf.windowEnd)).toEqual([1000, 5000]);
46
+ expect(status.map(inf => inf.used)).toEqual([0, 0]);
47
+ expect(status.map(inf => inf.reserved)).toEqual([0, 0]);
48
+ expect(status.map(inf => inf.remaining)).toEqual([10, 5]);
49
+ expect(status.map(inf => inf.nextAvailableAt)).toEqual([0, 0]);
50
+ expect(status.map(inf => inf.resetAt)).toEqual([1000, 5000]);
51
+ });
52
+
53
+ it('should return correct timing when queue is partially filled', () => {
54
+ const state = [
55
+ { windowStart: 0, used: 10, reserved: 5 },
56
+ { windowStart: 0, used: 5, reserved: 3 },
57
+ ];
58
+ const status = composite.getStatus(state, 500);
59
+
60
+ expect(status.map(i => i.nextAvailableAt)).toEqual([1000, 5000]);
61
+ expect(status.map(i => i.resetAt)).toEqual([2000, 10_000]);
62
+ });
63
+
64
+ it('should return correct timing when multiple windows are blocked', () => {
65
+ const state = [
66
+ { windowStart: 0, used: 10, reserved: 25 },
67
+ { windowStart: 0, used: 5, reserved: 13 },
68
+ ];
69
+ const status = composite.getStatus(state, 500);
70
+
71
+ expect(status.map(i => i.nextAvailableAt)).toEqual([3000, 15_000]);
72
+ expect(status.map(i => i.resetAt)).toEqual([4000, 20_000]);
73
+ });
74
+ });
75
+
76
+ describe('Evaluation', () => {
77
+ describe('Allow', () => {
78
+ it('should allow and update states when all policies allow', () => {
79
+ const composite = createFixedWindowCompositePolicy([
80
+ new FixedWindowPolicy(10, 1000),
81
+ new FixedWindowPolicy(5, 5000),
82
+ ]);
83
+ const states = composite.getInitialState(0);
84
+
85
+ const result = composite.evaluate(states, 500, 1);
86
+
87
+ expect(result.decision).toEqual({ kind: 'allow' });
88
+ expect(result.nextState[0].used).toBe(1);
89
+ expect(result.nextState[1].used).toBe(1);
90
+ });
91
+ });
92
+
93
+ describe('Deny and Revert', () => {
94
+ it('should deny and return maximum retryAt when all policies deny', () => {
95
+ const composite = createFixedWindowCompositePolicy([
96
+ new FixedWindowPolicy(1, 1000),
97
+ new FixedWindowPolicy(1, 5000),
98
+ ]);
99
+ const states = [
100
+ { windowStart: 0, used: 1, reserved: 0 },
101
+ { windowStart: 0, used: 1, reserved: 0 },
102
+ ];
103
+
104
+ const result = composite.evaluate(states, 500, 1);
105
+
106
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 5000 });
107
+ });
108
+
109
+ it('should deny, return maximum retryAt, and revert states for policies that allowed', () => {
110
+ const composite = createFixedWindowCompositePolicy([
111
+ new FixedWindowPolicy(10, 1000),
112
+ new FixedWindowPolicy(1, 5000),
113
+ ]);
114
+ const states = [
115
+ { windowStart: 0, used: 0, reserved: 0 },
116
+ { windowStart: 0, used: 1, reserved: 0 },
117
+ ];
118
+
119
+ const result = composite.evaluate(states, 500, 1);
120
+
121
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 5000 });
122
+ expect(result.nextState[0].used).toBe(0);
123
+ expect(result.nextState[1].used).toBe(1);
124
+ });
125
+ });
126
+
127
+ describe('Delay and Mixed Decisions', () => {
128
+ it('should delay and return maximum runAt when policies delay and none deny', () => {
129
+ const composite = createFixedWindowCompositePolicy([
130
+ new FixedWindowPolicy(1, 1000, 5),
131
+ new FixedWindowPolicy(1, 2000, 5),
132
+ ]);
133
+ const states = [
134
+ { windowStart: 0, used: 1, reserved: 0 },
135
+ { windowStart: 0, used: 1, reserved: 0 },
136
+ ];
137
+
138
+ const result = composite.evaluate(states, 500, 1, true);
139
+
140
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 2000 });
141
+ expect(result.nextState[0].reserved).toBe(1);
142
+ expect(result.nextState[1].reserved).toBe(1);
143
+ });
144
+
145
+ it('should prioritize deny over delay and revert both delayed and allowed policies', () => {
146
+ const composite = createFixedWindowCompositePolicy([
147
+ new FixedWindowPolicy(10, 1000),
148
+ new FixedWindowPolicy(1, 2000, 5),
149
+ new FixedWindowPolicy(1, 5000, 0),
150
+ ]);
151
+ const states = [
152
+ { windowStart: 0, used: 0, reserved: 0 },
153
+ { windowStart: 0, used: 1, reserved: 0 },
154
+ { windowStart: 0, used: 1, reserved: 0 },
155
+ ];
156
+
157
+ const result = composite.evaluate(states, 500, 1, true);
158
+
159
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 5000 });
160
+ expect(result.nextState[0].used).toBe(0);
161
+ expect(result.nextState[1].reserved).toBe(0);
162
+ expect(result.nextState[2].used).toBe(1);
163
+ });
164
+ });
165
+
166
+ describe('Edge Cases', () => {
167
+ it('should default cost to 1 if omitted', () => {
168
+ const composite = createFixedWindowCompositePolicy([new FixedWindowPolicy(10, 1000)]);
169
+ const states = composite.getInitialState(0);
170
+
171
+ const result = composite.evaluate(states, 500);
172
+
173
+ expect(result.nextState[0].used).toBe(1);
174
+ });
175
+
176
+ it('should throw when evaluate cost validation fails', () => {
177
+ const composite = createFixedWindowCompositePolicy([new FixedWindowPolicy(10, 1000)]);
178
+ const states = composite.getInitialState(0);
179
+
180
+ expect(() => composite.evaluate(states, 500, -5)).toThrow(InvalidCostError);
181
+ expect(() => composite.evaluate(states, 500, 1.5)).toThrow(InvalidCostError);
182
+ });
183
+ });
184
+ });
185
+
186
+ describe('Revert', () => {
187
+ it('should correctly proxy revert calls to all underlying policies within the same window', () => {
188
+ const composite = createFixedWindowCompositePolicy([
189
+ new FixedWindowPolicy(10, 1000),
190
+ new FixedWindowPolicy(5, 5000),
191
+ ]);
192
+ const states = [
193
+ { windowStart: 0, used: 5, reserved: 0 },
194
+ { windowStart: 0, used: 3, reserved: 0 },
195
+ ];
196
+
197
+ const revertedStates = composite.revert(states, 2, 500);
198
+
199
+ expect(revertedStates[0]).toEqual({ windowStart: 0, used: 3, reserved: 0 });
200
+ expect(revertedStates[1]).toEqual({ windowStart: 0, used: 1, reserved: 0 });
201
+ });
202
+
203
+ it('should correctly revert single unit cost', () => {
204
+ const composite = createFixedWindowCompositePolicy([new FixedWindowPolicy(10, 1000)]);
205
+ const states = [{ windowStart: 0, used: 5, reserved: 0 }];
206
+
207
+ const revertedStates = composite.revert(states, 1, 500);
208
+
209
+ expect(revertedStates[0]).toEqual({ windowStart: 0, used: 4, reserved: 0 });
210
+ });
211
+
212
+ it('should sync state for each policy independently based on their window sizes before reverting', () => {
213
+ const composite = createFixedWindowCompositePolicy([
214
+ new FixedWindowPolicy(10, 1000),
215
+ new FixedWindowPolicy(5, 5000),
216
+ ]);
217
+ const states = [
218
+ { windowStart: 0, used: 10, reserved: 5 },
219
+ { windowStart: 0, used: 5, reserved: 3 },
220
+ ];
221
+
222
+ const revertedStates = composite.revert(states, 2, 1500);
223
+
224
+ expect(revertedStates[0]).toEqual({ windowStart: 1000, used: 3, reserved: 0 });
225
+ expect(revertedStates[1]).toEqual({ windowStart: 0, used: 5, reserved: 1 });
226
+ });
227
+
228
+ it('should handle revert when time jumps past multiple windows differently for each policy', () => {
229
+ const composite = createFixedWindowCompositePolicy([
230
+ new FixedWindowPolicy(10, 1000),
231
+ new FixedWindowPolicy(5, 5000),
232
+ ]);
233
+ const states = [
234
+ { windowStart: 0, used: 10, reserved: 5 },
235
+ { windowStart: 0, used: 5, reserved: 5 },
236
+ ];
237
+
238
+ const revertedStates = composite.revert(states, 2, 6000);
239
+
240
+ expect(revertedStates[0]).toEqual({ windowStart: 6000, used: 0, reserved: 0 });
241
+ expect(revertedStates[1]).toEqual({ windowStart: 5000, used: 3, reserved: 0 });
242
+ });
243
+ });
244
+ });