@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,238 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { RateLimitError } from '../../../src/errors/rate-limit.error.js';
|
|
3
|
+
import { InMemoryStateStore } from '../../../src/runtime/in-memory-state-store.js';
|
|
4
|
+
|
|
5
|
+
interface TestClock {
|
|
6
|
+
now: () => number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('InMemoryStateStore', () => {
|
|
10
|
+
const createClock = (startMs = 0): { clock: TestClock; advanceBy: (ms: number) => void } => {
|
|
11
|
+
let nowMs = startMs;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
clock: { now: () => nowMs },
|
|
15
|
+
advanceBy: (ms: number) => {
|
|
16
|
+
nowMs += ms;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('CRUD operations', () => {
|
|
22
|
+
it('should return null for missing keys', async () => {
|
|
23
|
+
const { clock } = createClock(1000);
|
|
24
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
25
|
+
|
|
26
|
+
await expect(store.get('missing')).resolves.toBeNull();
|
|
27
|
+
|
|
28
|
+
await store.destroy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should store and retrieve the last set value', async () => {
|
|
32
|
+
const { clock } = createClock(1000);
|
|
33
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
34
|
+
|
|
35
|
+
await store.set('k', 'v1');
|
|
36
|
+
await store.set('k', 'v2');
|
|
37
|
+
|
|
38
|
+
await expect(store.get('k')).resolves.toBe('v2');
|
|
39
|
+
|
|
40
|
+
await store.destroy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should delete keys properly', async () => {
|
|
44
|
+
const { clock } = createClock(1000);
|
|
45
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
46
|
+
|
|
47
|
+
await store.set('k', 'v');
|
|
48
|
+
await store.delete('k');
|
|
49
|
+
|
|
50
|
+
await expect(store.get('k')).resolves.toBeNull();
|
|
51
|
+
|
|
52
|
+
await store.destroy();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('TTL semantics', () => {
|
|
57
|
+
it('should expire a value strictly after ttlMs has passed', async () => {
|
|
58
|
+
const { clock, advanceBy } = createClock(1000);
|
|
59
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
60
|
+
|
|
61
|
+
await store.set('k', 'v', 100);
|
|
62
|
+
|
|
63
|
+
await expect(store.get('k')).resolves.toBe('v');
|
|
64
|
+
|
|
65
|
+
advanceBy(100);
|
|
66
|
+
|
|
67
|
+
await expect(store.get('k')).resolves.toBeNull();
|
|
68
|
+
|
|
69
|
+
await store.destroy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should never expire when ttlMs is not provided', async () => {
|
|
73
|
+
const { clock, advanceBy } = createClock(1000);
|
|
74
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
75
|
+
|
|
76
|
+
await store.set('k', 'v');
|
|
77
|
+
|
|
78
|
+
advanceBy(10_000_000);
|
|
79
|
+
|
|
80
|
+
await expect(store.get('k')).resolves.toBe('v');
|
|
81
|
+
|
|
82
|
+
await store.destroy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should delete key immediately if ttlMs is 0, even if overwriting an existing key', async () => {
|
|
86
|
+
const { clock } = createClock(1000);
|
|
87
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
88
|
+
|
|
89
|
+
await store.set('k', 'old_value');
|
|
90
|
+
await store.set('k', 'new_value', 0);
|
|
91
|
+
|
|
92
|
+
await expect(store.get('k')).resolves.toBeNull();
|
|
93
|
+
|
|
94
|
+
await store.destroy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should delete key immediately if ttlMs is negative', async () => {
|
|
98
|
+
const { clock } = createClock(1000);
|
|
99
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
100
|
+
|
|
101
|
+
await store.set('k', 'v', -50);
|
|
102
|
+
|
|
103
|
+
await expect(store.get('k')).resolves.toBeNull();
|
|
104
|
+
|
|
105
|
+
await store.destroy();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('Periodic Cleanup', () => {
|
|
110
|
+
it('should physically remove expired entries on cleanup interval tick', async () => {
|
|
111
|
+
vi.useFakeTimers();
|
|
112
|
+
|
|
113
|
+
const { clock, advanceBy } = createClock(1000);
|
|
114
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
115
|
+
|
|
116
|
+
await store.set('k', 'v', 10);
|
|
117
|
+
|
|
118
|
+
advanceBy(10);
|
|
119
|
+
|
|
120
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
121
|
+
|
|
122
|
+
// @ts-ignore private field access
|
|
123
|
+
expect(store._state.has('k')).toBe(false);
|
|
124
|
+
|
|
125
|
+
await store.destroy();
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('Mutex (Locks)', () => {
|
|
131
|
+
it('should allow acquireLock immediately when no lock is held', async () => {
|
|
132
|
+
const { clock } = createClock(1000);
|
|
133
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
134
|
+
|
|
135
|
+
await expect(store.acquireLock('k')).resolves.toBeUndefined();
|
|
136
|
+
|
|
137
|
+
await store.releaseLock('k');
|
|
138
|
+
await store.destroy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should not block locks for entirely different keys', async () => {
|
|
142
|
+
const { clock } = createClock(1000);
|
|
143
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
144
|
+
|
|
145
|
+
await store.acquireLock('k1');
|
|
146
|
+
|
|
147
|
+
let acquiredK2 = false;
|
|
148
|
+
await store.acquireLock('k2').then(() => (acquiredK2 = true));
|
|
149
|
+
|
|
150
|
+
expect(acquiredK2).toBe(true);
|
|
151
|
+
|
|
152
|
+
await store.releaseLock('k2');
|
|
153
|
+
await store.releaseLock('k1');
|
|
154
|
+
await store.destroy();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should serialize acquireLock calls and unblock them in strict FIFO order', async () => {
|
|
158
|
+
const { clock } = createClock(1000);
|
|
159
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
160
|
+
|
|
161
|
+
const executionOrder: number[] = [];
|
|
162
|
+
|
|
163
|
+
await store.acquireLock('k');
|
|
164
|
+
executionOrder.push(1);
|
|
165
|
+
|
|
166
|
+
const waiter1 = store.acquireLock('k').then(() => executionOrder.push(2));
|
|
167
|
+
const waiter2 = store.acquireLock('k').then(() => executionOrder.push(3));
|
|
168
|
+
|
|
169
|
+
await Promise.resolve();
|
|
170
|
+
expect(executionOrder).toEqual([1]);
|
|
171
|
+
|
|
172
|
+
await store.releaseLock('k');
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
await Promise.resolve();
|
|
175
|
+
expect(executionOrder).toEqual([1, 2]);
|
|
176
|
+
|
|
177
|
+
await store.releaseLock('k');
|
|
178
|
+
await waiter1;
|
|
179
|
+
await waiter2;
|
|
180
|
+
expect(executionOrder).toEqual([1, 2, 3]);
|
|
181
|
+
|
|
182
|
+
await store.releaseLock('k');
|
|
183
|
+
await store.destroy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('Clear and Destroy logic', () => {
|
|
188
|
+
it('should clear stored values on clear()', async () => {
|
|
189
|
+
const { clock } = createClock(1000);
|
|
190
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
191
|
+
|
|
192
|
+
await store.set('a', '1');
|
|
193
|
+
await store.set('b', '2');
|
|
194
|
+
|
|
195
|
+
await store.clear();
|
|
196
|
+
|
|
197
|
+
await expect(store.get('a')).resolves.toBeNull();
|
|
198
|
+
await expect(store.get('b')).resolves.toBeNull();
|
|
199
|
+
|
|
200
|
+
await store.destroy();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should stop the cleanup timer on destroy()', async () => {
|
|
204
|
+
vi.useFakeTimers();
|
|
205
|
+
|
|
206
|
+
const { clock } = createClock(1000);
|
|
207
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
208
|
+
|
|
209
|
+
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
|
|
210
|
+
|
|
211
|
+
await store.destroy();
|
|
212
|
+
|
|
213
|
+
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
|
|
214
|
+
|
|
215
|
+
clearIntervalSpy.mockRestore();
|
|
216
|
+
vi.useRealTimers();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should reject all waiting locks with RateLimitError when cleared', async () => {
|
|
220
|
+
const { clock } = createClock(1000);
|
|
221
|
+
const store = new InMemoryStateStore<string>(clock);
|
|
222
|
+
|
|
223
|
+
await store.acquireLock('k');
|
|
224
|
+
|
|
225
|
+
const waiter1 = store.acquireLock('k');
|
|
226
|
+
const waiter2 = store.acquireLock('k');
|
|
227
|
+
|
|
228
|
+
await Promise.resolve();
|
|
229
|
+
|
|
230
|
+
await store.clear();
|
|
231
|
+
|
|
232
|
+
await expect(waiter1).rejects.toThrowError(RateLimitError);
|
|
233
|
+
await expect(waiter2).rejects.toThrowError(RateLimitError);
|
|
234
|
+
|
|
235
|
+
await store.destroy();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { Priority } from '@stimulcross/ds-policy-priority-queue';
|
|
2
|
+
import { createLogger } from '@stimulcross/logger';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { type Clock, RateLimitError, RateLimitErrorCode } from '../../../src/index.js';
|
|
5
|
+
import { RateLimiterExecutor } from '../../../src/runtime/rate-limiter.executor.js';
|
|
6
|
+
|
|
7
|
+
const defaultOpts = { id: 'task-1', key: 'key-1' };
|
|
8
|
+
|
|
9
|
+
describe('RateLimiterExecutor', () => {
|
|
10
|
+
const logger = createLogger('test');
|
|
11
|
+
let clock: Clock;
|
|
12
|
+
let executor: RateLimiterExecutor;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
vi.setSystemTime(1000);
|
|
17
|
+
|
|
18
|
+
clock = { now: () => Date.now() };
|
|
19
|
+
executor = new RateLimiterExecutor(logger, clock);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
executor.clear();
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Properties & State', () => {
|
|
28
|
+
it('should expose correct queue state and capacity', () => {
|
|
29
|
+
executor = new RateLimiterExecutor(logger, clock, { capacity: 10 });
|
|
30
|
+
|
|
31
|
+
expect(executor.queueSize).toBe(0);
|
|
32
|
+
expect(executor.queueCapacity).toBe(10);
|
|
33
|
+
expect(executor.isQueueFull).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('Execution & Delay', () => {
|
|
38
|
+
it('should execute a task successfully', async () => {
|
|
39
|
+
const task = vi.fn().mockResolvedValue('result');
|
|
40
|
+
const promise = executor.execute(task, clock.now(), defaultOpts);
|
|
41
|
+
|
|
42
|
+
await vi.runAllTimersAsync();
|
|
43
|
+
|
|
44
|
+
await expect(promise).resolves.toBe('result');
|
|
45
|
+
expect(task).toHaveBeenCalledOnce();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should reject if the task throws an error', async () => {
|
|
49
|
+
const error = new Error('Task failed');
|
|
50
|
+
const task = vi.fn().mockRejectedValue(error);
|
|
51
|
+
|
|
52
|
+
const promise = executor.execute(task, clock.now(), defaultOpts);
|
|
53
|
+
promise.catch(() => {});
|
|
54
|
+
|
|
55
|
+
await vi.runAllTimersAsync();
|
|
56
|
+
|
|
57
|
+
await expect(promise).rejects.toThrow(error);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should delay task execution until the specified runAt timestamp', async () => {
|
|
61
|
+
const task = vi.fn().mockResolvedValue('result');
|
|
62
|
+
const runAt = clock.now() + 500;
|
|
63
|
+
|
|
64
|
+
const promise = executor.execute(task, runAt, defaultOpts);
|
|
65
|
+
|
|
66
|
+
await vi.advanceTimersByTimeAsync(499);
|
|
67
|
+
expect(task).not.toHaveBeenCalled();
|
|
68
|
+
|
|
69
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
70
|
+
await expect(promise).resolves.toBe('result');
|
|
71
|
+
expect(task).toHaveBeenCalledOnce();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should execute tasks in order of their priority', async () => {
|
|
75
|
+
executor = new RateLimiterExecutor(logger, clock, { concurrency: 1 });
|
|
76
|
+
|
|
77
|
+
const executionOrder: number[] = [];
|
|
78
|
+
let resolveBlocker: () => void;
|
|
79
|
+
const blocker = vi.fn(
|
|
80
|
+
() =>
|
|
81
|
+
new Promise<void>(res => {
|
|
82
|
+
resolveBlocker = res;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
executor.execute(blocker, clock.now(), { id: 'blocker', key: 'blocker-key' }).catch(() => {});
|
|
87
|
+
|
|
88
|
+
executor
|
|
89
|
+
.execute(
|
|
90
|
+
async () => {
|
|
91
|
+
executionOrder.push(1);
|
|
92
|
+
},
|
|
93
|
+
clock.now(),
|
|
94
|
+
{ id: 'task-low', key: 'key-low', priority: Priority.Low },
|
|
95
|
+
)
|
|
96
|
+
.catch(() => {});
|
|
97
|
+
executor
|
|
98
|
+
.execute(
|
|
99
|
+
async () => {
|
|
100
|
+
executionOrder.push(2);
|
|
101
|
+
},
|
|
102
|
+
clock.now(),
|
|
103
|
+
{ id: 'task-high', key: 'key-high', priority: Priority.High },
|
|
104
|
+
)
|
|
105
|
+
.catch(() => {});
|
|
106
|
+
executor
|
|
107
|
+
.execute(
|
|
108
|
+
async () => {
|
|
109
|
+
executionOrder.push(3);
|
|
110
|
+
},
|
|
111
|
+
clock.now(),
|
|
112
|
+
{ id: 'task-normal', key: 'key-normal', priority: Priority.Normal },
|
|
113
|
+
)
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
|
|
116
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
117
|
+
|
|
118
|
+
resolveBlocker!();
|
|
119
|
+
await vi.runAllTimersAsync();
|
|
120
|
+
|
|
121
|
+
expect(executionOrder).toEqual([2, 3, 1]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Concurrency', () => {
|
|
126
|
+
it('should limit the number of concurrent executions', async () => {
|
|
127
|
+
executor = new RateLimiterExecutor(logger, clock, { concurrency: 1 });
|
|
128
|
+
|
|
129
|
+
let resolveTask1: () => void;
|
|
130
|
+
const task1 = vi.fn(
|
|
131
|
+
() =>
|
|
132
|
+
new Promise<void>(res => {
|
|
133
|
+
resolveTask1 = res;
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const task2 = vi.fn().mockResolvedValue('task2');
|
|
137
|
+
|
|
138
|
+
const promise1 = executor.execute(task1, clock.now(), { id: 't1', key: 'k1' });
|
|
139
|
+
const promise2 = executor.execute(task2, clock.now(), { id: 't2', key: 'k2' });
|
|
140
|
+
|
|
141
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
142
|
+
|
|
143
|
+
expect(task1).toHaveBeenCalledOnce();
|
|
144
|
+
expect(task2).not.toHaveBeenCalled();
|
|
145
|
+
|
|
146
|
+
resolveTask1!();
|
|
147
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
148
|
+
|
|
149
|
+
await promise1;
|
|
150
|
+
await expect(promise2).resolves.toBe('task2');
|
|
151
|
+
expect(task2).toHaveBeenCalledOnce();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('Cancellation', () => {
|
|
156
|
+
it('should abort a queued task and remove it from execution queue', async () => {
|
|
157
|
+
const task = vi.fn();
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
|
|
160
|
+
const promise = executor.execute(task, clock.now() + 1000, {
|
|
161
|
+
...defaultOpts,
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
controller.abort();
|
|
166
|
+
|
|
167
|
+
await expect(promise).rejects.toThrow(RateLimitError);
|
|
168
|
+
await expect(promise).rejects.toMatchObject({
|
|
169
|
+
code: RateLimitErrorCode.Cancelled,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(task).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Expiration', () => {
|
|
177
|
+
it('should reject tasks that expire before they can be executed', async () => {
|
|
178
|
+
const task = vi.fn();
|
|
179
|
+
const runAt = clock.now() + 1000;
|
|
180
|
+
const expiresAt = clock.now() + 500;
|
|
181
|
+
|
|
182
|
+
const promise = executor.execute(task, runAt, { ...defaultOpts, expiresAt });
|
|
183
|
+
promise.catch(() => {});
|
|
184
|
+
|
|
185
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
186
|
+
|
|
187
|
+
await expect(promise).rejects.toMatchObject({
|
|
188
|
+
code: RateLimitErrorCode.Expired,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(task).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should expire tasks waiting in queue due to concurrency limits', async () => {
|
|
195
|
+
executor = new RateLimiterExecutor(logger, clock, { concurrency: 1 });
|
|
196
|
+
|
|
197
|
+
let resolveTask1: () => void;
|
|
198
|
+
const task1 = vi.fn(
|
|
199
|
+
() =>
|
|
200
|
+
new Promise<void>(res => {
|
|
201
|
+
resolveTask1 = res;
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
const task2 = vi.fn();
|
|
205
|
+
|
|
206
|
+
const promise1 = executor.execute(task1, clock.now(), { id: 't1', key: 'k1' });
|
|
207
|
+
const promise2 = executor.execute(task2, clock.now(), {
|
|
208
|
+
id: 't2',
|
|
209
|
+
key: 'k2',
|
|
210
|
+
expiresAt: clock.now() + 500,
|
|
211
|
+
});
|
|
212
|
+
promise2.catch(() => {});
|
|
213
|
+
|
|
214
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
215
|
+
|
|
216
|
+
await expect(promise2).rejects.toMatchObject({
|
|
217
|
+
code: RateLimitErrorCode.Expired,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(task2).not.toHaveBeenCalled();
|
|
221
|
+
|
|
222
|
+
resolveTask1!();
|
|
223
|
+
await promise1;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should handle multiple tasks with different expiration times', async () => {
|
|
227
|
+
const runAt = clock.now() + 5000;
|
|
228
|
+
const taskLong = vi.fn();
|
|
229
|
+
const taskShort = vi.fn();
|
|
230
|
+
|
|
231
|
+
const promiseLong = executor.execute(taskLong, runAt, {
|
|
232
|
+
id: 't-long',
|
|
233
|
+
key: 'k-long',
|
|
234
|
+
expiresAt: clock.now() + 2000,
|
|
235
|
+
});
|
|
236
|
+
promiseLong.catch(() => {});
|
|
237
|
+
|
|
238
|
+
const promiseShort = executor.execute(taskShort, runAt, {
|
|
239
|
+
id: 't-short',
|
|
240
|
+
key: 'k-short',
|
|
241
|
+
expiresAt: clock.now() + 1000,
|
|
242
|
+
});
|
|
243
|
+
promiseShort.catch(() => {});
|
|
244
|
+
|
|
245
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
246
|
+
await expect(promiseShort).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
|
|
247
|
+
expect(taskLong).not.toHaveBeenCalled();
|
|
248
|
+
|
|
249
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
250
|
+
await expect(promiseLong).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('Tickets Shifting', () => {
|
|
255
|
+
it('should shift the execution ticket when a queued task is cancelled', async () => {
|
|
256
|
+
executor = new RateLimiterExecutor(logger, clock, { concurrency: 1 });
|
|
257
|
+
const executionTimes: Record<string, number> = {};
|
|
258
|
+
|
|
259
|
+
const task1 = vi.fn().mockImplementation(() => {
|
|
260
|
+
executionTimes.task1 = clock.now();
|
|
261
|
+
});
|
|
262
|
+
const task2 = vi.fn().mockImplementation(() => {
|
|
263
|
+
executionTimes.task2 = clock.now();
|
|
264
|
+
});
|
|
265
|
+
const task3 = vi.fn().mockImplementation(() => {
|
|
266
|
+
executionTimes.task3 = clock.now();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const controller2 = new AbortController();
|
|
270
|
+
|
|
271
|
+
executor.execute(task1, clock.now() + 100, { id: 't1', key: 'k1' }).catch(() => {});
|
|
272
|
+
const promise2 = executor.execute(task2, clock.now() + 200, {
|
|
273
|
+
id: 't2',
|
|
274
|
+
key: 'k2',
|
|
275
|
+
signal: controller2.signal,
|
|
276
|
+
});
|
|
277
|
+
promise2.catch(() => {});
|
|
278
|
+
executor.execute(task3, clock.now() + 300, { id: 't3', key: 'k3' }).catch(() => {});
|
|
279
|
+
|
|
280
|
+
controller2.abort();
|
|
281
|
+
|
|
282
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
283
|
+
expect(task1).toHaveBeenCalledOnce();
|
|
284
|
+
expect(executionTimes.task1).toBe(1100);
|
|
285
|
+
await expect(promise2).rejects.toMatchObject({ code: RateLimitErrorCode.Cancelled });
|
|
286
|
+
|
|
287
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
288
|
+
|
|
289
|
+
expect(task2).not.toHaveBeenCalled();
|
|
290
|
+
expect(task3).toHaveBeenCalledOnce();
|
|
291
|
+
// task3 took task2 200ms ticket
|
|
292
|
+
expect(executionTimes.task3).toBe(1200);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should shift the execution ticket when a queued task expires', async () => {
|
|
296
|
+
executor = new RateLimiterExecutor(logger, clock, { concurrency: 1 });
|
|
297
|
+
const executionTimes: Record<string, number> = {};
|
|
298
|
+
|
|
299
|
+
const task1 = vi.fn().mockImplementation(() => {
|
|
300
|
+
executionTimes.task1 = clock.now();
|
|
301
|
+
});
|
|
302
|
+
const task2 = vi.fn().mockImplementation(() => {
|
|
303
|
+
executionTimes.task2 = clock.now();
|
|
304
|
+
});
|
|
305
|
+
const task3 = vi.fn().mockImplementation(() => {
|
|
306
|
+
executionTimes.task3 = clock.now();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
executor.execute(task1, clock.now() + 100, { id: 't1', key: 'k1' }).catch(() => {});
|
|
310
|
+
const promise2 = executor.execute(task2, clock.now() + 200, {
|
|
311
|
+
id: 't2',
|
|
312
|
+
key: 'k2',
|
|
313
|
+
expiresAt: clock.now() + 50,
|
|
314
|
+
});
|
|
315
|
+
promise2.catch(() => {});
|
|
316
|
+
executor.execute(task3, clock.now() + 300, { id: 't3', key: 'k3' }).catch(() => {});
|
|
317
|
+
|
|
318
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
319
|
+
expect(task1).not.toHaveBeenCalled();
|
|
320
|
+
await expect(promise2).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
|
|
321
|
+
|
|
322
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
323
|
+
expect(task1).toHaveBeenCalledOnce();
|
|
324
|
+
expect(executionTimes.task1).toBe(1100);
|
|
325
|
+
|
|
326
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
327
|
+
|
|
328
|
+
expect(task2).not.toHaveBeenCalled();
|
|
329
|
+
expect(task3).toHaveBeenCalledOnce();
|
|
330
|
+
// task3 took task2 200ms ticket
|
|
331
|
+
expect(executionTimes.task3).toBe(1200);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe('Clear & Teardown', () => {
|
|
336
|
+
it('should reject all pending tasks with Destroyed error when cleared', async () => {
|
|
337
|
+
const task = vi.fn();
|
|
338
|
+
const promise = executor.execute(task, clock.now() + 1000, defaultOpts);
|
|
339
|
+
|
|
340
|
+
executor.clear();
|
|
341
|
+
|
|
342
|
+
await expect(promise).rejects.toMatchObject({
|
|
343
|
+
code: RateLimitErrorCode.Destroyed,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(task).not.toHaveBeenCalled();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should handle clearing an empty queue gracefully', () => {
|
|
350
|
+
expect(() => executor.clear()).not.toThrow();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Semaphore } from '../../../src/runtime/semaphore.js';
|
|
3
|
+
|
|
4
|
+
describe('Semaphore', () => {
|
|
5
|
+
describe('should support unlimited mode', () => {
|
|
6
|
+
it('should always acquire successfully when maxPermits is null', () => {
|
|
7
|
+
const s = new Semaphore(null);
|
|
8
|
+
|
|
9
|
+
expect(s.acquire()).toBe(true);
|
|
10
|
+
expect(s.acquire()).toBe(true);
|
|
11
|
+
expect(s.acquire()).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should ignore release when maxPermits is null', () => {
|
|
15
|
+
const s = new Semaphore(null);
|
|
16
|
+
|
|
17
|
+
s.release();
|
|
18
|
+
s.release();
|
|
19
|
+
|
|
20
|
+
expect(s.acquire()).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('should validate constructor argument', () => {
|
|
25
|
+
it('should accept null (unlimited mode)', () => {
|
|
26
|
+
expect(() => new Semaphore(null)).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should throw for 0', () => {
|
|
30
|
+
expect(() => new Semaphore(0)).toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should accept positive safe integers', () => {
|
|
34
|
+
expect(() => new Semaphore(1)).not.toThrow();
|
|
35
|
+
expect(() => new Semaphore(10)).not.toThrow();
|
|
36
|
+
expect(() => new Semaphore(Number.MAX_SAFE_INTEGER)).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should throw for negative integers', () => {
|
|
40
|
+
expect(() => new Semaphore(-1)).toThrow(/non-negative integer or null/iu);
|
|
41
|
+
expect(() => new Semaphore(-10)).toThrow(/non-negative integer or null/iu);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should throw for non-integers (floats)', () => {
|
|
45
|
+
expect(() => new Semaphore(0.1)).toThrow(/non-negative integer or null/iu);
|
|
46
|
+
expect(() => new Semaphore(1.5)).toThrow(/non-negative integer or null/iu);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw for NaN', () => {
|
|
50
|
+
expect(() => new Semaphore(Number.NaN)).toThrow(/non-negative integer or null/iu);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should throw for Infinity and -Infinity', () => {
|
|
54
|
+
expect(() => new Semaphore(Number.POSITIVE_INFINITY)).toThrow(/non-negative integer or null/iu);
|
|
55
|
+
expect(() => new Semaphore(Number.NEGATIVE_INFINITY)).toThrow(/non-negative integer or null/iu);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should throw for unsafe integers', () => {
|
|
59
|
+
expect(() => new Semaphore(Number.MAX_SAFE_INTEGER + 1)).toThrow(/non-negative integer or null/iu);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('should enforce finite capacity', () => {
|
|
64
|
+
it('should start full when maxPermits is provided', () => {
|
|
65
|
+
const s = new Semaphore(2);
|
|
66
|
+
|
|
67
|
+
expect(s.acquire()).toBe(true);
|
|
68
|
+
expect(s.acquire()).toBe(true);
|
|
69
|
+
expect(s.acquire()).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should allow acquiring again after release, up to the maximum', () => {
|
|
73
|
+
const s = new Semaphore(1);
|
|
74
|
+
|
|
75
|
+
expect(s.acquire()).toBe(true);
|
|
76
|
+
expect(s.acquire()).toBe(false);
|
|
77
|
+
|
|
78
|
+
s.release();
|
|
79
|
+
|
|
80
|
+
expect(s.acquire()).toBe(true);
|
|
81
|
+
expect(s.acquire()).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should cap permits at maxPermits after extra releases', () => {
|
|
85
|
+
const s = new Semaphore(1);
|
|
86
|
+
|
|
87
|
+
expect(s.acquire()).toBe(true);
|
|
88
|
+
expect(s.acquire()).toBe(false);
|
|
89
|
+
|
|
90
|
+
s.release();
|
|
91
|
+
s.release();
|
|
92
|
+
s.release();
|
|
93
|
+
|
|
94
|
+
expect(s.acquire()).toBe(true);
|
|
95
|
+
expect(s.acquire()).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|