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