@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,260 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InvalidCostError } from '../../../src/index.js';
3
+ import { FixedWindowPolicy } from '../../../src/limiters/fixed-window/fixed-window.policy.js';
4
+
5
+ describe('FixedWindowPolicy', () => {
6
+ describe('Constructor', () => {
7
+ it('should initialize with valid parameters', () => {
8
+ const policy = new FixedWindowPolicy(10, 1000);
9
+ expect(policy.limit).toBe(10);
10
+ expect(policy.windowMs).toBe(1000);
11
+ });
12
+
13
+ it('should throw an error for invalid limit', () => {
14
+ expect(() => new FixedWindowPolicy(0, 1000)).toThrow(/Invalid limit/u);
15
+ expect(() => new FixedWindowPolicy(-5, 1000)).toThrow(/Invalid limit/u);
16
+ expect(() => new FixedWindowPolicy(1.5, 1000)).toThrow(/Invalid limit/u);
17
+ });
18
+
19
+ it('should throw an error for invalid windowMs', () => {
20
+ expect(() => new FixedWindowPolicy(10, 0)).toThrow(/Invalid windowMs/u);
21
+ expect(() => new FixedWindowPolicy(10, -100)).toThrow(/Invalid windowMs/u);
22
+ expect(() => new FixedWindowPolicy(10, 100.5)).toThrow(/Invalid windowMs/u);
23
+ });
24
+
25
+ it('should throw an error for invalid maxReserved', () => {
26
+ expect(() => new FixedWindowPolicy(10, 1000, -5)).toThrow(/Invalid maxReserved/u);
27
+ expect(() => new FixedWindowPolicy(10, 1000, 2.5)).toThrow(/Invalid maxReserved/u);
28
+ });
29
+
30
+ it('should return correct initial state', () => {
31
+ const policy = new FixedWindowPolicy(5, 1000);
32
+ expect(policy.getInitialState()).toEqual({ windowStart: 0, used: 0, reserved: 0 });
33
+ });
34
+ });
35
+
36
+ describe('Get Status', () => {
37
+ const policy = new FixedWindowPolicy(10, 1000);
38
+
39
+ it('should return correct info for empty state', () => {
40
+ const state = policy.getInitialState();
41
+ const status = policy.getStatus(state, 500);
42
+
43
+ expect(status.windowStart).toBe(0);
44
+ expect(status.windowEnd).toBe(1000);
45
+ expect(status.used).toBe(0);
46
+ expect(status.reserved).toBe(0);
47
+ expect(status.remaining).toBe(10);
48
+ expect(status.nextAvailableAt).toBe(0);
49
+ expect(status.resetAt).toBe(1000);
50
+ });
51
+
52
+ it('should return correct timing when queue is partially filled', () => {
53
+ const state = { windowStart: 0, used: 10, reserved: 5 };
54
+ const info = policy.getStatus(state, 500);
55
+
56
+ expect(info.nextAvailableAt).toBe(1000);
57
+ expect(info.resetAt).toBe(2000);
58
+ });
59
+
60
+ it('should return correct timing when multiple windows are blocked', () => {
61
+ const state = { windowStart: 0, used: 10, reserved: 25 };
62
+ const info = policy.getStatus(state, 500);
63
+
64
+ expect(info.nextAvailableAt).toBe(3000);
65
+ expect(info.resetAt).toBe(4000);
66
+ });
67
+ });
68
+
69
+ describe('Evaluate', () => {
70
+ describe('Validation and Edge Cases', () => {
71
+ const policy = new FixedWindowPolicy(10, 1000);
72
+
73
+ it('should throw if cost exceeds the policy limit', () => {
74
+ const state = policy.getInitialState();
75
+ expect(() => policy.evaluate(state, 500, 15)).toThrow(InvalidCostError);
76
+ });
77
+
78
+ it('should allow zero cost without consuming tokens (status check)', () => {
79
+ const state = { windowStart: 0, used: 5, reserved: 0 };
80
+ const result = policy.evaluate(state, 500, 0);
81
+
82
+ expect(result.decision.kind).toBe('allow');
83
+ expect(result.nextState.used).toBe(5);
84
+ });
85
+
86
+ it('should handle time jumping backwards gracefully', () => {
87
+ const state = { windowStart: 5000, used: 5, reserved: 0 };
88
+ const result = policy.evaluate(state, 4000, 1);
89
+
90
+ expect(result.decision.kind).toBe('allow');
91
+ expect(result.nextState.windowStart).toBe(5000);
92
+ expect(result.nextState.used).toBe(6);
93
+ });
94
+ });
95
+
96
+ describe('Immediate execution', () => {
97
+ const policy = new FixedWindowPolicy(2, 1000);
98
+
99
+ it('should allow request when within limit', () => {
100
+ const state = policy.getInitialState();
101
+ const result = policy.evaluate(state, 500, 1);
102
+
103
+ expect(result.decision).toEqual({ kind: 'allow' });
104
+ expect(result.nextState).toEqual({ windowStart: 0, used: 1, reserved: 0 });
105
+ });
106
+
107
+ it('should allow request with multi-token cost if within limit', () => {
108
+ const state = policy.getInitialState();
109
+ const result = policy.evaluate(state, 500, 2);
110
+
111
+ expect(result.decision).toEqual({ kind: 'allow' });
112
+ expect(result.nextState).toEqual({ windowStart: 0, used: 2, reserved: 0 });
113
+ });
114
+
115
+ it('should deny request and return correct retryAt when limit is exceeded', () => {
116
+ const state = { windowStart: 0, used: 2, reserved: 0 };
117
+ const result = policy.evaluate(state, 500, 1);
118
+
119
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1000 });
120
+ expect(result.nextState).toEqual(state);
121
+ });
122
+ });
123
+
124
+ describe('Reservation and Delay', () => {
125
+ const policy = new FixedWindowPolicy(2, 1000, 5);
126
+
127
+ it('should delay request when limit is exceeded and reservation is requested', () => {
128
+ const state = { windowStart: 0, used: 2, reserved: 0 };
129
+ const result = policy.evaluate(state, 500, 1, true);
130
+
131
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 1000 });
132
+ expect(result.nextState).toEqual({ windowStart: 0, used: 2, reserved: 1 });
133
+ });
134
+
135
+ it('should calculate correct runAt for delayed requests across multiple future windows', () => {
136
+ const state = { windowStart: 0, used: 2, reserved: 1 };
137
+ const result1 = policy.evaluate(state, 500, 1, true);
138
+ const result2 = policy.evaluate(result1.nextState, 500, 1, true);
139
+
140
+ expect(result1.decision).toEqual({ kind: 'delay', runAt: 1000 });
141
+ expect(result2.decision).toEqual({ kind: 'delay', runAt: 2000 });
142
+ expect(result2.nextState.reserved).toBe(3);
143
+ });
144
+
145
+ it('should deny reservation if maxReserved is exceeded', () => {
146
+ const state = { windowStart: 0, used: 2, reserved: 4 };
147
+ const result = policy.evaluate(state, 500, 2, true);
148
+
149
+ expect(result.decision.kind).toBe('deny');
150
+ expect(result.nextState).toEqual(state);
151
+ });
152
+
153
+ it('should exactly calculate retryAt when maxReserved is exceeded (deficit logic)', () => {
154
+ const fixedWindowPolicy = new FixedWindowPolicy(10, 1000, 20);
155
+ const state = { windowStart: 0, used: 10, reserved: 20 };
156
+ const result = fixedWindowPolicy.evaluate(state, 500, 5, true);
157
+
158
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1000 });
159
+ });
160
+ });
161
+
162
+ describe('Window Transitions and State Sync', () => {
163
+ const policy = new FixedWindowPolicy(10, 1000);
164
+
165
+ it('should completely reset used count when moving to a new window without debt', () => {
166
+ const state = { windowStart: 0, used: 10, reserved: 0 };
167
+ const result = policy.evaluate(state, 1500, 1);
168
+
169
+ expect(result.decision.kind).toBe('allow');
170
+ expect(result.nextState).toEqual({ windowStart: 1000, used: 1, reserved: 0 });
171
+ });
172
+
173
+ it('should carry over reserved requests into used count of the next window', () => {
174
+ const state = { windowStart: 0, used: 10, reserved: 5 };
175
+ const result = policy.evaluate(state, 1500, 1, true);
176
+
177
+ expect(result.decision.kind).toBe('allow');
178
+ expect(result.nextState).toEqual({ windowStart: 1000, used: 6, reserved: 0 });
179
+ });
180
+
181
+ it('should burn passed windows and correctly allocate remaining debt into current window', () => {
182
+ const state = { windowStart: 0, used: 10, reserved: 25 };
183
+ const result = policy.evaluate(state, 2500, 1, true);
184
+
185
+ expect(result.nextState).toEqual({ windowStart: 2000, used: 10, reserved: 6 });
186
+ });
187
+ });
188
+ });
189
+
190
+ describe('Revert', () => {
191
+ const policy = new FixedWindowPolicy(10, 1000);
192
+
193
+ describe('Within the same window', () => {
194
+ it('should revert only used when reserved is empty', () => {
195
+ const state = { windowStart: 1000, used: 5, reserved: 0 };
196
+ const nextState = policy.revert(state, 2, 1500);
197
+
198
+ expect(nextState).toEqual({ windowStart: 1000, used: 3, reserved: 0 });
199
+ });
200
+
201
+ it('should revert from reserved first', () => {
202
+ const state = { windowStart: 1000, used: 10, reserved: 5 };
203
+ const nextState = policy.revert(state, 3, 1500);
204
+
205
+ expect(nextState).toEqual({ windowStart: 1000, used: 10, reserved: 2 });
206
+ });
207
+
208
+ it('should revert from both reserved and used if cost spans across both', () => {
209
+ const state = { windowStart: 1000, used: 10, reserved: 2 };
210
+ const nextState = policy.revert(state, 5, 1500);
211
+
212
+ expect(nextState).toEqual({ windowStart: 1000, used: 7, reserved: 0 });
213
+ });
214
+
215
+ it('should not drop used or reserved below zero', () => {
216
+ const state = { windowStart: 1000, used: 2, reserved: 0 };
217
+ const nextState = policy.revert(state, 10, 1500);
218
+
219
+ expect(nextState).toEqual({ windowStart: 1000, used: 0, reserved: 0 });
220
+ });
221
+ });
222
+
223
+ describe('With time sync (window transitions)', () => {
224
+ it('should sync state and clear used capacity when moving to an empty new window before reverting', () => {
225
+ const state = { windowStart: 0, used: 10, reserved: 0 };
226
+ const nextState = policy.revert(state, 2, 1500);
227
+
228
+ expect(nextState).toEqual({ windowStart: 1000, used: 0, reserved: 0 });
229
+ });
230
+
231
+ it('should sync state, carry over reserved to used, and then apply revert', () => {
232
+ const state = { windowStart: 0, used: 10, reserved: 5 };
233
+ const nextState = policy.revert(state, 2, 1500);
234
+
235
+ expect(nextState).toEqual({ windowStart: 1000, used: 3, reserved: 0 });
236
+ });
237
+
238
+ it('should burn passed windows, correctly allocate remaining debt, and then apply revert', () => {
239
+ const state = { windowStart: 0, used: 10, reserved: 25 };
240
+ const nextState = policy.revert(state, 7, 2500);
241
+
242
+ expect(nextState).toEqual({ windowStart: 2000, used: 8, reserved: 0 });
243
+ });
244
+
245
+ it('should handle exact window boundary sync before reverting', () => {
246
+ const state = { windowStart: 0, used: 10, reserved: 15 };
247
+ const nextState = policy.revert(state, 5, 1000);
248
+
249
+ expect(nextState).toEqual({ windowStart: 1000, used: 10, reserved: 0 });
250
+ });
251
+
252
+ it('should gracefully handle time jumping backwards during revert', () => {
253
+ const state = { windowStart: 5000, used: 5, reserved: 0 };
254
+ const nextState = policy.revert(state, 2, 4000);
255
+
256
+ expect(nextState).toEqual({ windowStart: 5000, used: 3, reserved: 0 });
257
+ });
258
+ });
259
+ });
260
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InvalidCostError } from '../../../src/index.js';
3
+ import { GenericCellPolicy } from '../../../src/limiters/generic-cell/generic-cell.policy.js';
4
+
5
+ describe('GenericCellPolicy', () => {
6
+ describe('Constructor', () => {
7
+ it('should initialize with valid parameters', () => {
8
+ const policy = new GenericCellPolicy(100, 5, 200);
9
+ expect(policy.intervalMs).toBe(100);
10
+ expect(policy.burst).toBe(5);
11
+ });
12
+
13
+ it('should throw an error for invalid intervalMs', () => {
14
+ expect(() => new GenericCellPolicy(0, 5)).toThrow(/Invalid intervalMs/u);
15
+ expect(() => new GenericCellPolicy(-10, 5)).toThrow(/Invalid intervalMs/u);
16
+ expect(() => new GenericCellPolicy(10.5, 5)).toThrow(/Invalid intervalMs/u);
17
+ });
18
+
19
+ it('should throw an error for invalid burst', () => {
20
+ expect(() => new GenericCellPolicy(100, 0)).toThrow(/Invalid burst/u);
21
+ expect(() => new GenericCellPolicy(100, -2)).toThrow(/Invalid burst/u);
22
+ expect(() => new GenericCellPolicy(100, 1.5)).toThrow(/Invalid burst/u);
23
+ });
24
+
25
+ it('should throw an error for invalid maxDelayMs', () => {
26
+ expect(() => new GenericCellPolicy(100, 5, -50)).toThrow(/Invalid maxDelayMs/u);
27
+ expect(() => new GenericCellPolicy(100, 5, 5.5)).toThrow(/Invalid maxDelayMs/u);
28
+ });
29
+
30
+ it('should return correct initial state', () => {
31
+ const policy = new GenericCellPolicy(100, 5);
32
+ expect(policy.getInitialState()).toEqual({ tat: 0 });
33
+ });
34
+ });
35
+
36
+ describe('Get Status', () => {
37
+ const policy = new GenericCellPolicy(100, 5);
38
+
39
+ it('should return correct info for completely idle system', () => {
40
+ const state = policy.getInitialState();
41
+ const status = policy.getStatus(state, 1000);
42
+
43
+ expect(status.intervalMs).toBe(100);
44
+ expect(status.burst).toBe(5);
45
+ expect(status.tat).toBe(1000);
46
+ expect(status.remaining).toBe(5);
47
+ expect(status.nextAvailableAt).toBe(1000);
48
+ expect(status.resetAt).toBe(1000);
49
+ });
50
+
51
+ it('should return correct info for partially utilized burst', () => {
52
+ const state = { tat: 1200 };
53
+ const status = policy.getStatus(state, 1000);
54
+
55
+ expect(status.tat).toBe(1200);
56
+ expect(status.remaining).toBe(3);
57
+ expect(status.nextAvailableAt).toBe(1000);
58
+ expect(status.resetAt).toBe(1200);
59
+ });
60
+
61
+ it('should return correct info for fully utilized and delayed system (queueing)', () => {
62
+ const state = { tat: 1700 };
63
+ const status = policy.getStatus(state, 1000);
64
+
65
+ expect(status.tat).toBe(1700);
66
+ expect(status.remaining).toBe(0);
67
+
68
+ expect(status.nextAvailableAt).toBe(1300);
69
+ expect(status.resetAt).toBe(1700);
70
+ });
71
+ });
72
+
73
+ describe('Evaluate', () => {
74
+ describe('Validation and Edge Cases', () => {
75
+ const policy = new GenericCellPolicy(100, 5, 300);
76
+
77
+ it('should throw if cost exceeds burst without reservation', () => {
78
+ const state = policy.getInitialState();
79
+ expect(() => policy.evaluate(state, 1000, 6, false)).toThrow(InvalidCostError);
80
+ });
81
+
82
+ it('should throw if cost exceeds total capacity (burst + max queue) with reservation', () => {
83
+ const state = policy.getInitialState();
84
+ expect(() => policy.evaluate(state, 1000, 9, true)).toThrow(InvalidCostError);
85
+ });
86
+
87
+ it('should allow zero cost without increasing TAT (status check)', () => {
88
+ const state = { tat: 1200 };
89
+ const result = policy.evaluate(state, 1000, 0);
90
+
91
+ expect(result.decision.kind).toBe('allow');
92
+ expect(result.nextState.tat).toBe(1200);
93
+ });
94
+ });
95
+
96
+ describe('Immediate execution (Burst allowance)', () => {
97
+ const policy = new GenericCellPolicy(100, 5);
98
+
99
+ it('should allow request when TAT is in the past (idle system)', () => {
100
+ const state = { tat: 500 };
101
+ const result = policy.evaluate(state, 1000, 1);
102
+
103
+ expect(result.decision).toEqual({ kind: 'allow' });
104
+ expect(result.nextState).toEqual({ tat: 1100 });
105
+ });
106
+
107
+ it('should allow request when TAT is in the future but within burst offset', () => {
108
+ const state = { tat: 1400 };
109
+ const result = policy.evaluate(state, 1000, 1);
110
+
111
+ expect(result.decision).toEqual({ kind: 'allow' });
112
+ expect(result.nextState).toEqual({ tat: 1500 });
113
+ });
114
+
115
+ it('should deny request without reservation when TAT exceeds burst offset', () => {
116
+ const state = { tat: 1600 };
117
+ const result = policy.evaluate(state, 1000, 1);
118
+
119
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1200 });
120
+ expect(result.nextState).toEqual({ tat: 1600 });
121
+ });
122
+
123
+ it('should correctly consume time proportional to the request cost', () => {
124
+ const state = { tat: 1000 };
125
+ const result = policy.evaluate(state, 1000, 3);
126
+
127
+ expect(result.decision.kind).toBe('allow');
128
+ expect(result.nextState).toEqual({ tat: 1300 });
129
+ });
130
+ });
131
+
132
+ describe('Reservation and Delay (Queueing)', () => {
133
+ const policy = new GenericCellPolicy(100, 5, 300);
134
+
135
+ it('should delay request when limit exceeded but within max delay', () => {
136
+ const state = { tat: 1600 };
137
+ const result = policy.evaluate(state, 1000, 1, true);
138
+
139
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 1200 });
140
+ expect(result.nextState).toEqual({ tat: 1700 });
141
+ });
142
+
143
+ it('should stack multiple delayed requests sequentially', () => {
144
+ const state = { tat: 1700 };
145
+ const result = policy.evaluate(state, 1000, 1, true);
146
+
147
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 1300 });
148
+ expect(result.nextState).toEqual({ tat: 1800 });
149
+ });
150
+
151
+ it('should accurately calculate retryAt when reservation exceeds max delay', () => {
152
+ const state = { tat: 1900 };
153
+ const result = policy.evaluate(state, 1000, 1, true);
154
+
155
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1200 });
156
+ expect(result.nextState).toEqual({ tat: 1900 });
157
+ });
158
+ });
159
+ });
160
+
161
+ describe('Revert', () => {
162
+ const policy = new GenericCellPolicy(100, 5);
163
+
164
+ it('should decrease TAT by the cost interval', () => {
165
+ const state = { tat: 1500 };
166
+ const nextState = policy.revert(state, 2);
167
+
168
+ expect(nextState).toEqual({ tat: 1300 });
169
+ });
170
+
171
+ it('should not drop TAT below zero', () => {
172
+ const state = { tat: 150 };
173
+ const nextState = policy.revert(state, 2);
174
+
175
+ expect(nextState).toEqual({ tat: 0 });
176
+ });
177
+ });
178
+ });
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InvalidCostError } from '../../../src/index.js';
3
+ import { LeakyBucketPolicy } from '../../../src/limiters/leaky-bucket/leaky-bucket.policy.js';
4
+
5
+ describe('LeakyBucketPolicy', () => {
6
+ describe('Constructor', () => {
7
+ it('should initialize with valid parameters', () => {
8
+ const policy = new LeakyBucketPolicy(100, 10, 50);
9
+ expect(policy.capacity).toBe(100);
10
+ expect(policy.leakRate).toBe(10);
11
+ });
12
+
13
+ it('should throw an error for invalid capacity', () => {
14
+ expect(() => new LeakyBucketPolicy(0, 10)).toThrow(/Invalid capacity/u);
15
+ expect(() => new LeakyBucketPolicy(-50, 10)).toThrow(/Invalid capacity/u);
16
+ expect(() => new LeakyBucketPolicy(10.5, 10)).toThrow(/Invalid capacity/u);
17
+ });
18
+
19
+ it('should throw an error for invalid leakRate', () => {
20
+ expect(() => new LeakyBucketPolicy(100, 0)).toThrow(/Invalid leakRate/u);
21
+ expect(() => new LeakyBucketPolicy(100, -5)).toThrow(/Invalid leakRate/u);
22
+ expect(() => new LeakyBucketPolicy(100, Number.NaN)).toThrow(/Invalid leakRate/u);
23
+ });
24
+
25
+ it('should throw an error for invalid maxOverflow', () => {
26
+ expect(() => new LeakyBucketPolicy(100, 10, -5)).toThrow(/Invalid maxOverflow/u);
27
+ expect(() => new LeakyBucketPolicy(100, 10, 5.5)).toThrow(/Invalid maxOverflow/u);
28
+ });
29
+
30
+ it('should return correct initial state', () => {
31
+ const policy = new LeakyBucketPolicy(100, 10);
32
+ expect(policy.getInitialState()).toEqual({ level: 0, lastUpdate: 0 });
33
+ });
34
+ });
35
+
36
+ describe('Get Status', () => {
37
+ const policy = new LeakyBucketPolicy(100, 10);
38
+
39
+ it('should return correct status for empty bucket', () => {
40
+ const state = policy.getInitialState();
41
+ const status = policy.getStatus(state, 1000);
42
+
43
+ expect(status.capacity).toBe(100);
44
+ expect(status.leakRate).toBe(10);
45
+ expect(status.level).toBe(0);
46
+ expect(status.remaining).toBe(100);
47
+ expect(status.nextAvailableAt).toBe(1000);
48
+ expect(status.resetAt).toBe(1000);
49
+ });
50
+
51
+ it('should return correct status for partially filled bucket', () => {
52
+ const state = { level: 50, lastUpdate: 1000 };
53
+ const status = policy.getStatus(state, 1000);
54
+
55
+ expect(status.level).toBe(50);
56
+ expect(status.remaining).toBe(50);
57
+ expect(status.nextAvailableAt).toBe(1000);
58
+ expect(status.resetAt).toBe(6000);
59
+ });
60
+
61
+ it('should return correct status for overflowed bucket', () => {
62
+ const state = { level: 150, lastUpdate: 1000 };
63
+ const status = policy.getStatus(state, 1000);
64
+
65
+ expect(status.level).toBe(150);
66
+ expect(status.remaining).toBe(0);
67
+
68
+ expect(status.nextAvailableAt).toBe(6100);
69
+ expect(status.resetAt).toBe(16_000);
70
+ });
71
+ });
72
+
73
+ describe('Evaluate', () => {
74
+ describe('Validation and Edge Cases', () => {
75
+ const policy = new LeakyBucketPolicy(10, 5);
76
+
77
+ it('should throw if cost exceeds the bucket capacity', () => {
78
+ const state = policy.getInitialState();
79
+ expect(() => policy.evaluate(state, 1000, 15)).toThrow(InvalidCostError);
80
+ });
81
+
82
+ it('should allow zero cost without increasing level', () => {
83
+ const state = { level: 5, lastUpdate: 1000 };
84
+ const result = policy.evaluate(state, 1000, 0);
85
+
86
+ expect(result.decision.kind).toBe('allow');
87
+ expect(result.nextState.level).toBe(5);
88
+ });
89
+
90
+ it('should handle time jumping backwards gracefully', () => {
91
+ const state = { level: 5, lastUpdate: 2000 };
92
+ const result = policy.evaluate(state, 1000, 2);
93
+
94
+ expect(result.decision.kind).toBe('allow');
95
+ expect(result.nextState.level).toBe(7);
96
+ expect(result.nextState.lastUpdate).toBe(2000);
97
+ });
98
+ });
99
+
100
+ describe('Immediate execution (Burst allowance)', () => {
101
+ const policy = new LeakyBucketPolicy(10, 5);
102
+
103
+ it('should deny request without reservation if capacity is exceeded', () => {
104
+ const state = { level: 8, lastUpdate: 1000 };
105
+ const result = policy.evaluate(state, 1000, 4);
106
+
107
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1400 });
108
+ expect(result.nextState).toEqual(state);
109
+ });
110
+
111
+ it('should allow request when level exactly reaches capacity', () => {
112
+ const state = { level: 8, lastUpdate: 1000 };
113
+ const result = policy.evaluate(state, 1000, 2);
114
+
115
+ expect(result.decision).toEqual({ kind: 'allow' });
116
+ expect(result.nextState.level).toBe(10);
117
+ });
118
+
119
+ it('should deny request without reservation if capacity is exceeded', () => {
120
+ const state = { level: 8, lastUpdate: 1000 };
121
+ const result = policy.evaluate(state, 1000, 4);
122
+
123
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 1400 });
124
+ expect(result.nextState).toEqual(state);
125
+ });
126
+ });
127
+
128
+ describe('Leak and State Sync', () => {
129
+ const policy = new LeakyBucketPolicy(20, 10);
130
+
131
+ it('should reduce level based on elapsed time before applying cost', () => {
132
+ const state = { level: 15, lastUpdate: 1000 };
133
+ const result = policy.evaluate(state, 1500, 5);
134
+
135
+ expect(result.decision.kind).toBe('allow');
136
+ expect(result.nextState).toEqual({ level: 15, lastUpdate: 1500 });
137
+ });
138
+
139
+ it('should not leak below zero', () => {
140
+ const state = { level: 5, lastUpdate: 1000 };
141
+ const result = policy.evaluate(state, 3000, 2);
142
+
143
+ expect(result.decision.kind).toBe('allow');
144
+ expect(result.nextState).toEqual({ level: 2, lastUpdate: 3000 });
145
+ });
146
+ });
147
+
148
+ describe('Reservation and Delay', () => {
149
+ const policy = new LeakyBucketPolicy(10, 5, 15);
150
+
151
+ it('should delay request when capacity exceeded and reservation is enabled', () => {
152
+ const state = { level: 10, lastUpdate: 1000 };
153
+ const result = policy.evaluate(state, 1000, 4, true);
154
+
155
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 1800 });
156
+ expect(result.nextState).toEqual({ level: 14, lastUpdate: 1000 });
157
+ });
158
+
159
+ it('should stack multiple reservations sequentially', () => {
160
+ const state = { level: 14, lastUpdate: 1000 };
161
+ const result = policy.evaluate(state, 1000, 6, true);
162
+
163
+ expect(result.decision).toEqual({ kind: 'delay', runAt: 3000 });
164
+ expect(result.nextState.level).toBe(20);
165
+ });
166
+
167
+ it('should deny reservation if overflow exceeds maxOverflow', () => {
168
+ const state = { level: 16, lastUpdate: 1000 };
169
+ const result = policy.evaluate(state, 1000, 10, true);
170
+
171
+ expect(result.decision.kind).toBe('deny');
172
+ expect(result.nextState).toEqual(state);
173
+ });
174
+
175
+ it('should accurately calculate retryAt when maxOverflow is exceeded', () => {
176
+ const state = { level: 20, lastUpdate: 1000 };
177
+ const result = policy.evaluate(state, 1000, 10, true);
178
+
179
+ expect(result.decision).toEqual({ kind: 'deny', retryAt: 2000 });
180
+ });
181
+ });
182
+ });
183
+
184
+ describe('Revert', () => {
185
+ const policy = new LeakyBucketPolicy(10, 5);
186
+
187
+ it('should reduce the level by the requested cost', () => {
188
+ const state = { level: 8, lastUpdate: 1000 };
189
+ const nextState = policy.revert(state, 3, 1000);
190
+
191
+ expect(nextState).toEqual({ level: 5, lastUpdate: 1000 });
192
+ });
193
+
194
+ it('should not reduce the level below zero', () => {
195
+ const state = { level: 2, lastUpdate: 1000 };
196
+ const nextState = policy.revert(state, 5, 1000);
197
+
198
+ expect(nextState).toEqual({ level: 0, lastUpdate: 1000 });
199
+ });
200
+
201
+ it('should correctly reduce the level from an overflowed state', () => {
202
+ const state = { level: 15, lastUpdate: 1000 };
203
+ const nextState = policy.revert(state, 10, 1000);
204
+
205
+ expect(nextState).toEqual({ level: 5, lastUpdate: 1000 });
206
+ });
207
+
208
+ it('should sync time, leak correctly, and then revert', () => {
209
+ const state = { level: 10, lastUpdate: 1000 };
210
+ const nextState = policy.revert(state, 2, 2000);
211
+
212
+ expect(nextState).toEqual({ level: 3, lastUpdate: 2000 });
213
+ });
214
+ });
215
+ });