@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,833 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { type Clock, RateLimiterDestroyedError, RateLimitErrorCode } from '../../../src/index.js';
|
|
3
|
+
import {
|
|
4
|
+
type HttpLimitInfo,
|
|
5
|
+
type HttpLimitInfoExtractor,
|
|
6
|
+
HttpResponseBasedLimiter,
|
|
7
|
+
type HttpResponseBasedLimiterState,
|
|
8
|
+
} from '../../../src/limiters/http-response-based/index.js';
|
|
9
|
+
import { InMemoryStateStore } from '../../../src/runtime/in-memory-state-store.js';
|
|
10
|
+
|
|
11
|
+
describe('HttpResponseBasedLimiter (Integration)', () => {
|
|
12
|
+
let clock: Clock;
|
|
13
|
+
let store: InMemoryStateStore<HttpResponseBasedLimiterState>;
|
|
14
|
+
let mockExtractor: HttpLimitInfoExtractor<any> & ReturnType<typeof vi.fn>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.setSystemTime(10_000);
|
|
19
|
+
|
|
20
|
+
clock = { now: () => Date.now() };
|
|
21
|
+
store = new InMemoryStateStore(clock);
|
|
22
|
+
mockExtractor = vi.fn<HttpLimitInfoExtractor<any>>(res => res?.headers || null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllTimers();
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('Immediate execution (reject mode)', () => {
|
|
31
|
+
it('should allow the first request and sync state from response', async () => {
|
|
32
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
33
|
+
limitBehavior: 'reject',
|
|
34
|
+
limitInfoExtractor: mockExtractor,
|
|
35
|
+
clock,
|
|
36
|
+
store,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const task = vi.fn().mockReturnValue({
|
|
40
|
+
headers: { limit: 10, remaining: 9, resetAt: 15_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await expect(limiter.run(task)).resolves.toBeDefined();
|
|
44
|
+
|
|
45
|
+
const status = await limiter.getStatus();
|
|
46
|
+
expect(status.lastKnownLimit).toBe(10);
|
|
47
|
+
expect(status.lastKnownRemaining).toBe(9);
|
|
48
|
+
expect(status.lastKnownResetAt).toBe(15_000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should block subsequent requests if network returns successful response with 0 remaining tokens', async () => {
|
|
52
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
53
|
+
limitBehavior: 'reject',
|
|
54
|
+
limitInfoExtractor: mockExtractor,
|
|
55
|
+
clock,
|
|
56
|
+
store,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const taskA = vi.fn().mockReturnValue({
|
|
60
|
+
headers: { limit: 5, remaining: 0, resetAt: 15_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
61
|
+
});
|
|
62
|
+
const taskB = vi.fn();
|
|
63
|
+
|
|
64
|
+
await expect(limiter.run(taskA)).resolves.toBeDefined();
|
|
65
|
+
await expect(limiter.run(taskB)).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
|
|
66
|
+
expect(taskB).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should reject request immediately if local state indicates limit is reached', async () => {
|
|
70
|
+
await store.set(
|
|
71
|
+
'limiter',
|
|
72
|
+
{
|
|
73
|
+
isProbing: false,
|
|
74
|
+
isUnlimited: false,
|
|
75
|
+
lastKnownLimit: 5,
|
|
76
|
+
lastKnownRemaining: 0,
|
|
77
|
+
lastKnownResetAt: 15_000,
|
|
78
|
+
lastSyncedAt: 10_000,
|
|
79
|
+
},
|
|
80
|
+
5000,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
84
|
+
limitBehavior: 'reject',
|
|
85
|
+
limitInfoExtractor: mockExtractor,
|
|
86
|
+
clock,
|
|
87
|
+
store,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const task = vi.fn();
|
|
91
|
+
|
|
92
|
+
await expect(limiter.run(task)).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
|
|
93
|
+
expect(task).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should reject when network responds with limit exceeded (HTTP 429)', async () => {
|
|
97
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
98
|
+
limitBehavior: 'reject',
|
|
99
|
+
limitInfoExtractor: mockExtractor,
|
|
100
|
+
clock,
|
|
101
|
+
store,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const task = vi.fn().mockReturnValue({
|
|
105
|
+
headers: { limit: 5, remaining: 0, resetAt: 15_000, statusCode: 429 } satisfies HttpLimitInfo,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await expect(limiter.run(task)).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
|
|
109
|
+
expect(task).toHaveBeenCalledOnce();
|
|
110
|
+
|
|
111
|
+
const status = await limiter.getStatus();
|
|
112
|
+
expect(status.lastKnownRemaining).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should sync state and re-throw error if task throws but provides limit headers', async () => {
|
|
116
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
117
|
+
limitBehavior: 'reject',
|
|
118
|
+
limitInfoExtractor: (res, err) => (err as any)?.response?.headers || null,
|
|
119
|
+
clock,
|
|
120
|
+
store,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const customError = new Error('Bad Request');
|
|
124
|
+
(customError as any).response = {
|
|
125
|
+
headers: { limit: 10, remaining: 5, resetAt: 15_000, statusCode: 400 } satisfies HttpLimitInfo,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const task = vi.fn().mockRejectedValue(customError);
|
|
129
|
+
|
|
130
|
+
await expect(limiter.run(task)).rejects.toThrow(customError);
|
|
131
|
+
|
|
132
|
+
const status = await limiter.getStatus();
|
|
133
|
+
expect(status.lastKnownRemaining).toBe(5);
|
|
134
|
+
expect(status.lastKnownResetAt).toBe(15_000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should fallback to default delay if server does not provide reset time', async () => {
|
|
138
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
139
|
+
limitBehavior: 'reject',
|
|
140
|
+
limitInfoExtractor: mockExtractor,
|
|
141
|
+
fallbackResetDelayMs: 45_000,
|
|
142
|
+
clock,
|
|
143
|
+
store,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const task = vi.fn().mockReturnValue({
|
|
147
|
+
headers: {
|
|
148
|
+
limit: 10,
|
|
149
|
+
remaining: 9,
|
|
150
|
+
resetAt: undefined,
|
|
151
|
+
statusCode: 200,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await limiter.run(task);
|
|
156
|
+
|
|
157
|
+
const status = await limiter.getStatus();
|
|
158
|
+
expect(status.lastKnownResetAt).toBe(55_000);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('Queueing execution (enqueue mode)', () => {
|
|
163
|
+
it('should requeue and retry request if network responds with HTTP 429', async () => {
|
|
164
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
165
|
+
limitBehavior: 'enqueue',
|
|
166
|
+
limitInfoExtractor: mockExtractor,
|
|
167
|
+
clock,
|
|
168
|
+
store,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
let calls = 0;
|
|
172
|
+
const task = vi.fn(() => {
|
|
173
|
+
calls++;
|
|
174
|
+
|
|
175
|
+
if (calls === 1) {
|
|
176
|
+
return {
|
|
177
|
+
headers: { limit: 5, remaining: 0, resetAt: 11_000, statusCode: 429 } satisfies HttpLimitInfo,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
headers: {
|
|
182
|
+
limit: 5,
|
|
183
|
+
remaining: 4,
|
|
184
|
+
resetAt: 12_000,
|
|
185
|
+
statusCode: 200,
|
|
186
|
+
} satisfies HttpLimitInfo,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const promise = limiter.run(task);
|
|
191
|
+
|
|
192
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
193
|
+
// task hit 429 and enqueued
|
|
194
|
+
expect(calls).toBe(1);
|
|
195
|
+
|
|
196
|
+
// wait for resetAt (11_000)
|
|
197
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
198
|
+
await expect(promise).resolves.toBeDefined();
|
|
199
|
+
expect(calls).toBe(2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should delay request locally without network call if state is exhausted', async () => {
|
|
203
|
+
await store.set(
|
|
204
|
+
'limiter',
|
|
205
|
+
{
|
|
206
|
+
isProbing: false,
|
|
207
|
+
isUnlimited: false,
|
|
208
|
+
lastKnownLimit: 5,
|
|
209
|
+
lastKnownRemaining: 0,
|
|
210
|
+
lastKnownResetAt: 15_000,
|
|
211
|
+
lastSyncedAt: 10_000,
|
|
212
|
+
},
|
|
213
|
+
5000,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
217
|
+
limitBehavior: 'enqueue',
|
|
218
|
+
limitInfoExtractor: mockExtractor,
|
|
219
|
+
clock,
|
|
220
|
+
store,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const task = vi.fn().mockReturnValue({
|
|
224
|
+
headers: { limit: 5, remaining: 4, resetAt: 20_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const promise = limiter.run(task);
|
|
228
|
+
|
|
229
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
230
|
+
expect(task).not.toHaveBeenCalled();
|
|
231
|
+
|
|
232
|
+
// Reach 15_000
|
|
233
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
234
|
+
await expect(promise).resolves.toBeDefined();
|
|
235
|
+
expect(task).toHaveBeenCalledOnce();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should reject with Expired when TTL is reached while waiting in queue', async () => {
|
|
239
|
+
await store.set(
|
|
240
|
+
'limiter',
|
|
241
|
+
{
|
|
242
|
+
isProbing: false,
|
|
243
|
+
isUnlimited: false,
|
|
244
|
+
lastKnownLimit: 5,
|
|
245
|
+
lastKnownRemaining: 0,
|
|
246
|
+
lastKnownResetAt: 15_000,
|
|
247
|
+
lastSyncedAt: 10_000,
|
|
248
|
+
},
|
|
249
|
+
5000,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
253
|
+
limitBehavior: 'enqueue',
|
|
254
|
+
limitInfoExtractor: mockExtractor,
|
|
255
|
+
queue: { maxWaitMs: 1000 },
|
|
256
|
+
clock,
|
|
257
|
+
store,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const promise = limiter.run(vi.fn());
|
|
261
|
+
promise.catch(() => {});
|
|
262
|
+
|
|
263
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
264
|
+
await expect(promise).rejects.toMatchObject({ code: RateLimitErrorCode.Expired });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should abort queued tasks when signal is aborted', async () => {
|
|
268
|
+
await store.set(
|
|
269
|
+
'limiter',
|
|
270
|
+
{
|
|
271
|
+
isProbing: false,
|
|
272
|
+
isUnlimited: false,
|
|
273
|
+
lastKnownLimit: 5,
|
|
274
|
+
lastKnownRemaining: 0,
|
|
275
|
+
lastKnownResetAt: 15_000,
|
|
276
|
+
lastSyncedAt: 10_000,
|
|
277
|
+
},
|
|
278
|
+
5000,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
282
|
+
limitBehavior: 'enqueue',
|
|
283
|
+
limitInfoExtractor: mockExtractor,
|
|
284
|
+
clock,
|
|
285
|
+
store,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const controller = new AbortController();
|
|
289
|
+
const promise = limiter.run(vi.fn(), { signal: controller.signal });
|
|
290
|
+
|
|
291
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
292
|
+
controller.abort();
|
|
293
|
+
|
|
294
|
+
await expect(promise).rejects.toMatchObject({ code: RateLimitErrorCode.Cancelled });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('Concurrency & Syncing', () => {
|
|
299
|
+
it('should gracefully queue followers while probe is resolving initial state', async () => {
|
|
300
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
301
|
+
limitBehavior: 'reject',
|
|
302
|
+
limitInfoExtractor: mockExtractor,
|
|
303
|
+
clock,
|
|
304
|
+
store,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const taskA = vi.fn().mockImplementation(
|
|
308
|
+
() =>
|
|
309
|
+
new Promise(resolve =>
|
|
310
|
+
setTimeout(
|
|
311
|
+
() =>
|
|
312
|
+
resolve({
|
|
313
|
+
headers: {
|
|
314
|
+
limit: 10,
|
|
315
|
+
remaining: 9,
|
|
316
|
+
resetAt: 15_000,
|
|
317
|
+
statusCode: 200,
|
|
318
|
+
} satisfies HttpLimitInfo,
|
|
319
|
+
}),
|
|
320
|
+
500,
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const taskB = vi.fn().mockReturnValue({
|
|
326
|
+
headers: { limit: 10, remaining: 8, resetAt: 15_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const promiseA = limiter.run(taskA);
|
|
330
|
+
|
|
331
|
+
// advance time to allow taskA to become the probe and lock
|
|
332
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
333
|
+
|
|
334
|
+
// should wait, not reject
|
|
335
|
+
const promiseB = limiter.run(taskB);
|
|
336
|
+
|
|
337
|
+
expect(taskB).not.toHaveBeenCalled();
|
|
338
|
+
|
|
339
|
+
// taskA completes
|
|
340
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
341
|
+
|
|
342
|
+
await expect(promiseA).resolves.toBeDefined();
|
|
343
|
+
await expect(promiseB).resolves.toBeDefined();
|
|
344
|
+
|
|
345
|
+
expect(taskA).toHaveBeenCalledOnce();
|
|
346
|
+
expect(taskB).toHaveBeenCalledOnce();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should rollback probing state and allow next request to become probe if initial probe fails without headers', async () => {
|
|
350
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
351
|
+
limitBehavior: 'reject',
|
|
352
|
+
limitInfoExtractor: mockExtractor,
|
|
353
|
+
clock,
|
|
354
|
+
store,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const taskA = vi
|
|
358
|
+
.fn()
|
|
359
|
+
.mockImplementation(
|
|
360
|
+
() => new Promise((_, reject) => setTimeout(() => reject(new Error('Network Failure')), 500)),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const taskB = vi.fn().mockImplementation(
|
|
364
|
+
() =>
|
|
365
|
+
new Promise(resolve =>
|
|
366
|
+
setTimeout(
|
|
367
|
+
() =>
|
|
368
|
+
resolve({
|
|
369
|
+
headers: {
|
|
370
|
+
limit: 10,
|
|
371
|
+
remaining: 9,
|
|
372
|
+
resetAt: 15_000,
|
|
373
|
+
statusCode: 200,
|
|
374
|
+
} satisfies HttpLimitInfo,
|
|
375
|
+
}),
|
|
376
|
+
100,
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const pA = limiter.run(taskA);
|
|
382
|
+
pA.catch(() => {});
|
|
383
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
384
|
+
|
|
385
|
+
// should wait taskA
|
|
386
|
+
const pB = limiter.run(taskB);
|
|
387
|
+
|
|
388
|
+
// taskA rejects
|
|
389
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
390
|
+
await expect(pA).rejects.toThrow('Network Failure');
|
|
391
|
+
|
|
392
|
+
// taskB becomes the new probe
|
|
393
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
394
|
+
await expect(pB).resolves.toBeDefined();
|
|
395
|
+
|
|
396
|
+
const status = await limiter.getStatus();
|
|
397
|
+
expect(status.lastKnownRemaining).toBe(9);
|
|
398
|
+
expect(taskB).toHaveBeenCalledOnce();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should abort follower waiting for probe to finish', async () => {
|
|
402
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
403
|
+
limitBehavior: 'reject',
|
|
404
|
+
limitInfoExtractor: mockExtractor,
|
|
405
|
+
clock,
|
|
406
|
+
store,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const taskA = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 500)));
|
|
410
|
+
|
|
411
|
+
const pA = limiter.run(taskA);
|
|
412
|
+
// taskA becomes probe
|
|
413
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
414
|
+
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
const pB = limiter.run(vi.fn(), { signal: controller.signal });
|
|
417
|
+
|
|
418
|
+
controller.abort();
|
|
419
|
+
await expect(pB).rejects.toMatchObject({ code: RateLimitErrorCode.Cancelled });
|
|
420
|
+
|
|
421
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
422
|
+
await pA.catch(() => {});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should optimistically debit tokens for concurrent requests', async () => {
|
|
426
|
+
await store.set(
|
|
427
|
+
'limiter',
|
|
428
|
+
{
|
|
429
|
+
isProbing: false,
|
|
430
|
+
isUnlimited: false,
|
|
431
|
+
lastKnownLimit: 10,
|
|
432
|
+
lastKnownRemaining: 5,
|
|
433
|
+
lastKnownResetAt: 15_000,
|
|
434
|
+
lastSyncedAt: 10_000,
|
|
435
|
+
},
|
|
436
|
+
5000,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
440
|
+
limitBehavior: 'reject',
|
|
441
|
+
limitInfoExtractor: mockExtractor,
|
|
442
|
+
clock,
|
|
443
|
+
store,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const task = vi.fn().mockImplementation(
|
|
447
|
+
() =>
|
|
448
|
+
new Promise(resolve =>
|
|
449
|
+
setTimeout(
|
|
450
|
+
() =>
|
|
451
|
+
resolve({
|
|
452
|
+
headers: {
|
|
453
|
+
limit: 10,
|
|
454
|
+
remaining: 4,
|
|
455
|
+
resetAt: 15_000,
|
|
456
|
+
statusCode: 200,
|
|
457
|
+
} satisfies HttpLimitInfo,
|
|
458
|
+
}),
|
|
459
|
+
200,
|
|
460
|
+
),
|
|
461
|
+
),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const p1 = limiter.run(task);
|
|
465
|
+
const p2 = limiter.run(task);
|
|
466
|
+
|
|
467
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
468
|
+
|
|
469
|
+
const interimState = await store.get('limiter');
|
|
470
|
+
expect(interimState?.lastKnownRemaining).toBe(3);
|
|
471
|
+
|
|
472
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
473
|
+
await Promise.all([p1, p2]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should ignore stale headers if a newer request already synced the state', async () => {
|
|
477
|
+
await store.set(
|
|
478
|
+
'limiter',
|
|
479
|
+
{
|
|
480
|
+
isProbing: false,
|
|
481
|
+
isUnlimited: false,
|
|
482
|
+
lastKnownLimit: 10,
|
|
483
|
+
lastKnownRemaining: 10,
|
|
484
|
+
lastKnownResetAt: 15_000,
|
|
485
|
+
lastSyncedAt: 9000,
|
|
486
|
+
},
|
|
487
|
+
10_000,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
491
|
+
limitBehavior: 'reject',
|
|
492
|
+
limitInfoExtractor: mockExtractor,
|
|
493
|
+
clock,
|
|
494
|
+
store,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const slowTask = vi.fn().mockImplementation(
|
|
498
|
+
() =>
|
|
499
|
+
new Promise(resolve =>
|
|
500
|
+
setTimeout(
|
|
501
|
+
() =>
|
|
502
|
+
resolve({
|
|
503
|
+
headers: {
|
|
504
|
+
limit: 10,
|
|
505
|
+
remaining: 9,
|
|
506
|
+
resetAt: 15_000,
|
|
507
|
+
statusCode: 200,
|
|
508
|
+
} satisfies HttpLimitInfo,
|
|
509
|
+
}),
|
|
510
|
+
500,
|
|
511
|
+
),
|
|
512
|
+
),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const fastTask = vi.fn().mockImplementation(
|
|
516
|
+
() =>
|
|
517
|
+
new Promise(resolve =>
|
|
518
|
+
setTimeout(
|
|
519
|
+
() =>
|
|
520
|
+
resolve({
|
|
521
|
+
headers: {
|
|
522
|
+
limit: 10,
|
|
523
|
+
remaining: 8,
|
|
524
|
+
resetAt: 15_000,
|
|
525
|
+
statusCode: 200,
|
|
526
|
+
} satisfies HttpLimitInfo,
|
|
527
|
+
}),
|
|
528
|
+
100,
|
|
529
|
+
),
|
|
530
|
+
),
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const pSlow = limiter.run(slowTask);
|
|
534
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
535
|
+
|
|
536
|
+
const pFast = limiter.run(fastTask);
|
|
537
|
+
|
|
538
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
539
|
+
await pFast;
|
|
540
|
+
|
|
541
|
+
const interimStatus = await limiter.getStatus();
|
|
542
|
+
expect(interimStatus.lastKnownRemaining).toBe(8);
|
|
543
|
+
|
|
544
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
545
|
+
await pSlow;
|
|
546
|
+
|
|
547
|
+
const finalStatus = await limiter.getStatus();
|
|
548
|
+
expect(finalStatus.lastKnownRemaining).toBe(8);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('Unlimited mode', () => {
|
|
553
|
+
it('should cache unlimited state if server responds without rate limit headers', async () => {
|
|
554
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
555
|
+
limitBehavior: 'reject',
|
|
556
|
+
limitInfoExtractor: vi.fn().mockReturnValue(null),
|
|
557
|
+
clock,
|
|
558
|
+
store,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
await limiter.run(vi.fn().mockResolvedValue('ok'));
|
|
562
|
+
|
|
563
|
+
const status = await limiter.getStatus();
|
|
564
|
+
expect(status.lastKnownLimit).toBe(null);
|
|
565
|
+
expect(status.lastKnownRemaining).toBe(null);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should bypass local blocking if state is unlimited', async () => {
|
|
569
|
+
await store.set(
|
|
570
|
+
'limiter',
|
|
571
|
+
{
|
|
572
|
+
isProbing: false,
|
|
573
|
+
isUnlimited: true,
|
|
574
|
+
lastKnownLimit: Number.MAX_SAFE_INTEGER,
|
|
575
|
+
lastKnownRemaining: Number.MAX_SAFE_INTEGER,
|
|
576
|
+
lastKnownResetAt: 70_000,
|
|
577
|
+
lastSyncedAt: 10_000,
|
|
578
|
+
},
|
|
579
|
+
60_000,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
583
|
+
limitBehavior: 'reject',
|
|
584
|
+
limitInfoExtractor: vi.fn().mockReturnValue(null),
|
|
585
|
+
clock,
|
|
586
|
+
store,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const task = vi.fn().mockResolvedValue('ok');
|
|
590
|
+
await expect(limiter.run(task)).resolves.toBe('ok');
|
|
591
|
+
expect(task).toHaveBeenCalledOnce();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('should ignore unlimited state cache if a newer request already synced actual limits', async () => {
|
|
595
|
+
await store.set(
|
|
596
|
+
'limiter',
|
|
597
|
+
{
|
|
598
|
+
isProbing: false,
|
|
599
|
+
isUnlimited: false,
|
|
600
|
+
lastKnownLimit: 10,
|
|
601
|
+
lastKnownRemaining: 10,
|
|
602
|
+
lastKnownResetAt: 15_000,
|
|
603
|
+
lastSyncedAt: 9000,
|
|
604
|
+
},
|
|
605
|
+
10_000,
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
609
|
+
limitBehavior: 'reject',
|
|
610
|
+
limitInfoExtractor: mockExtractor,
|
|
611
|
+
clock,
|
|
612
|
+
store,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const slowTask = vi
|
|
616
|
+
.fn()
|
|
617
|
+
.mockImplementation(
|
|
618
|
+
() => new Promise((_, reject) => setTimeout(() => reject(new Error('Network Error')), 500)),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const fastTask = vi.fn().mockImplementation(
|
|
622
|
+
() =>
|
|
623
|
+
new Promise(resolve =>
|
|
624
|
+
setTimeout(
|
|
625
|
+
() =>
|
|
626
|
+
resolve({
|
|
627
|
+
headers: {
|
|
628
|
+
limit: 10,
|
|
629
|
+
remaining: 8,
|
|
630
|
+
resetAt: 15_000,
|
|
631
|
+
statusCode: 200,
|
|
632
|
+
} satisfies HttpLimitInfo,
|
|
633
|
+
}),
|
|
634
|
+
100,
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const pSlow = limiter.run(slowTask);
|
|
640
|
+
pSlow.catch(() => {});
|
|
641
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
642
|
+
|
|
643
|
+
const pFast = limiter.run(fastTask);
|
|
644
|
+
|
|
645
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
646
|
+
await pFast;
|
|
647
|
+
|
|
648
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
649
|
+
await pSlow.catch(() => {});
|
|
650
|
+
|
|
651
|
+
const finalStatus = await limiter.getStatus();
|
|
652
|
+
expect(finalStatus.lastKnownRemaining).toBe(8);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('should strictly bypass local limit checks and remain unlimited if server continues to omit headers', async () => {
|
|
656
|
+
await store.set(
|
|
657
|
+
'limiter',
|
|
658
|
+
{
|
|
659
|
+
isProbing: false,
|
|
660
|
+
isUnlimited: true,
|
|
661
|
+
lastKnownLimit: null,
|
|
662
|
+
lastKnownRemaining: null,
|
|
663
|
+
lastKnownResetAt: 70_000,
|
|
664
|
+
lastSyncedAt: 10_000,
|
|
665
|
+
},
|
|
666
|
+
60_000,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
670
|
+
limitBehavior: 'reject',
|
|
671
|
+
limitInfoExtractor: vi.fn().mockReturnValue(null),
|
|
672
|
+
clock,
|
|
673
|
+
store,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const task = vi.fn().mockResolvedValue('ok');
|
|
677
|
+
|
|
678
|
+
await expect(limiter.run(task)).resolves.toBe('ok');
|
|
679
|
+
expect(task).toHaveBeenCalledOnce();
|
|
680
|
+
|
|
681
|
+
const status = await limiter.getStatus();
|
|
682
|
+
expect(status.isUnlimited).toBe(true);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should instantly transition from unlimited to limited if server suddenly sends headers', async () => {
|
|
686
|
+
await store.set(
|
|
687
|
+
'limiter',
|
|
688
|
+
{
|
|
689
|
+
isProbing: false,
|
|
690
|
+
isUnlimited: true,
|
|
691
|
+
lastKnownLimit: null,
|
|
692
|
+
lastKnownRemaining: null,
|
|
693
|
+
lastKnownResetAt: 70_000,
|
|
694
|
+
lastSyncedAt: 10_000,
|
|
695
|
+
},
|
|
696
|
+
60_000,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
700
|
+
limitBehavior: 'reject',
|
|
701
|
+
limitInfoExtractor: mockExtractor,
|
|
702
|
+
clock,
|
|
703
|
+
store,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const task = vi.fn().mockReturnValue({
|
|
707
|
+
headers: { limit: 10, remaining: 9, resetAt: 15_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
await expect(limiter.run(task)).resolves.toBeDefined();
|
|
711
|
+
|
|
712
|
+
const status = await limiter.getStatus();
|
|
713
|
+
expect(status.isUnlimited).toBe(false);
|
|
714
|
+
expect(status.lastKnownLimit).toBe(10);
|
|
715
|
+
expect(status.lastKnownRemaining).toBe(9);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe('State & Lifecycle', () => {
|
|
720
|
+
it('should clear limits and reset state', async () => {
|
|
721
|
+
await store.set(
|
|
722
|
+
'limiter',
|
|
723
|
+
{
|
|
724
|
+
isProbing: false,
|
|
725
|
+
isUnlimited: false,
|
|
726
|
+
lastKnownLimit: 5,
|
|
727
|
+
lastKnownRemaining: 0,
|
|
728
|
+
lastKnownResetAt: 15_000,
|
|
729
|
+
lastSyncedAt: 10_000,
|
|
730
|
+
},
|
|
731
|
+
5000,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
735
|
+
limitInfoExtractor: mockExtractor,
|
|
736
|
+
clock,
|
|
737
|
+
store,
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await limiter.clear();
|
|
741
|
+
|
|
742
|
+
const status = await limiter.getStatus();
|
|
743
|
+
expect(status.lastKnownLimit).toBe(null);
|
|
744
|
+
expect(status.lastKnownRemaining).toBe(null);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('should throw RateLimiterDestroyedError after destroy() is called', async () => {
|
|
748
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
749
|
+
limitInfoExtractor: mockExtractor,
|
|
750
|
+
clock,
|
|
751
|
+
store,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
await limiter.destroy();
|
|
755
|
+
|
|
756
|
+
await expect(limiter.run(vi.fn())).rejects.toThrow(RateLimiterDestroyedError);
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe('Fault Tolerance & Edge Cases', () => {
|
|
761
|
+
it('should reject immediately if another remote instance is probing and limit is reached', async () => {
|
|
762
|
+
await store.set(
|
|
763
|
+
'limiter',
|
|
764
|
+
{
|
|
765
|
+
isProbing: true,
|
|
766
|
+
isUnlimited: false,
|
|
767
|
+
lastKnownLimit: 1,
|
|
768
|
+
lastKnownRemaining: 0,
|
|
769
|
+
lastKnownResetAt: 15_000,
|
|
770
|
+
lastSyncedAt: 10_000,
|
|
771
|
+
},
|
|
772
|
+
5000,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
776
|
+
limitBehavior: 'reject',
|
|
777
|
+
limitInfoExtractor: mockExtractor,
|
|
778
|
+
clock,
|
|
779
|
+
store,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const task = vi.fn();
|
|
783
|
+
|
|
784
|
+
await expect(limiter.run(task)).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
|
|
785
|
+
expect(task).not.toHaveBeenCalled();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should recover from an orphaned soft lock after its logical TTL expires', async () => {
|
|
789
|
+
// simulate a lock left by a process that crashed before syncing
|
|
790
|
+
await store.set(
|
|
791
|
+
'limiter',
|
|
792
|
+
{
|
|
793
|
+
isProbing: true,
|
|
794
|
+
isUnlimited: false,
|
|
795
|
+
lastKnownLimit: 1,
|
|
796
|
+
lastKnownRemaining: 0,
|
|
797
|
+
lastKnownResetAt: 12_000,
|
|
798
|
+
lastSyncedAt: 10_000,
|
|
799
|
+
},
|
|
800
|
+
2000,
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const limiter = new HttpResponseBasedLimiter({
|
|
804
|
+
limitBehavior: 'reject',
|
|
805
|
+
limitInfoExtractor: mockExtractor,
|
|
806
|
+
clock,
|
|
807
|
+
store,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const task = vi.fn().mockReturnValue({
|
|
811
|
+
headers: { limit: 10, remaining: 9, resetAt: 15_000, statusCode: 200 } satisfies HttpLimitInfo,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// lock is active (t=11_000)
|
|
815
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
816
|
+
await expect(limiter.run(task)).rejects.toMatchObject({ code: RateLimitErrorCode.LimitExceeded });
|
|
817
|
+
expect(task).not.toHaveBeenCalled();
|
|
818
|
+
|
|
819
|
+
// lock expires (t=12_100)
|
|
820
|
+
await vi.advanceTimersByTimeAsync(1100);
|
|
821
|
+
|
|
822
|
+
// self-heal
|
|
823
|
+
await expect(limiter.run(task)).resolves.toBeDefined();
|
|
824
|
+
expect(task).toHaveBeenCalledOnce();
|
|
825
|
+
|
|
826
|
+
// state recovered with server limits
|
|
827
|
+
const status = await limiter.getStatus();
|
|
828
|
+
expect(status.isUnlimited).toBe(false);
|
|
829
|
+
expect(status.lastKnownRemaining).toBe(9);
|
|
830
|
+
expect(status.lastKnownResetAt).toBe(15_000);
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
});
|