@async-kit/ratelimitx 0.1.2
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/CHANGELOG.md +9 -0
- package/README.md +385 -0
- package/dist/index.d.mts +167 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +284 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +278 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Constructor options for `TokenBucket`. */
|
|
2
|
+
interface TokenBucketOptions {
|
|
3
|
+
/** Maximum token capacity (burst size). */
|
|
4
|
+
capacity: number;
|
|
5
|
+
/** Tokens added per `refillInterval`. */
|
|
6
|
+
refillRate: number;
|
|
7
|
+
/** Interval in ms at which tokens are added. Default: `1000`. */
|
|
8
|
+
refillInterval?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Constructor options for `SlidingWindow`. */
|
|
11
|
+
interface SlidingWindowOptions {
|
|
12
|
+
/** Rolling window duration in ms. */
|
|
13
|
+
windowMs: number;
|
|
14
|
+
/** Maximum requests allowed per window. */
|
|
15
|
+
maxRequests: number;
|
|
16
|
+
}
|
|
17
|
+
/** Constructor options for `FixedWindow`. */
|
|
18
|
+
interface FixedWindowOptions {
|
|
19
|
+
/** Window duration in ms. */
|
|
20
|
+
windowMs: number;
|
|
21
|
+
/** Maximum requests per window. */
|
|
22
|
+
maxRequests: number;
|
|
23
|
+
/** Called each time the window resets. */
|
|
24
|
+
onWindowReset?: (windowStart: number) => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Common interface implemented by all three rate-limiter classes
|
|
28
|
+
* and accepted by `CompositeLimiter`.
|
|
29
|
+
*/
|
|
30
|
+
interface Limiter {
|
|
31
|
+
tryAcquire(): boolean;
|
|
32
|
+
acquire(): void;
|
|
33
|
+
waitAndAcquire(signal?: AbortSignal): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** Algorithm tag used in `RateLimitError`. */
|
|
36
|
+
type RateLimitAlgorithm = 'token-bucket' | 'sliding-window' | 'fixed-window';
|
|
37
|
+
|
|
38
|
+
declare class RateLimitError extends Error {
|
|
39
|
+
readonly retryAfterMs: number;
|
|
40
|
+
readonly algorithm: 'token-bucket' | 'sliding-window' | 'fixed-window';
|
|
41
|
+
readonly limit: number;
|
|
42
|
+
readonly current: number;
|
|
43
|
+
constructor(message: string, retryAfterMs: number, algorithm: 'token-bucket' | 'sliding-window' | 'fixed-window', limit: number, current: number);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Token Bucket rate limiter.
|
|
47
|
+
*
|
|
48
|
+
* Tokens accumulate over time up to `capacity`. Burst-friendly — allows up
|
|
49
|
+
* to `capacity` back-to-back calls as long as the bucket is full.
|
|
50
|
+
*
|
|
51
|
+
* - `tryConsume()` — non-throwing, returns `true` if tokens available.
|
|
52
|
+
* - `consume()` — async, **blocks** until tokens are available.
|
|
53
|
+
* - `available` — current token count (after refill).
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const bucket = new TokenBucket({ capacity: 10, refillRate: 2, refillInterval: 1000 });
|
|
57
|
+
* await bucket.consume(); // waits until a token is available
|
|
58
|
+
*/
|
|
59
|
+
declare class TokenBucket {
|
|
60
|
+
private tokens;
|
|
61
|
+
private lastRefillTime;
|
|
62
|
+
private capacity;
|
|
63
|
+
private readonly refillRate;
|
|
64
|
+
private readonly refillInterval;
|
|
65
|
+
constructor(options: TokenBucketOptions);
|
|
66
|
+
private refill;
|
|
67
|
+
get available(): number;
|
|
68
|
+
tryConsume(count?: number): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Waits until `count` tokens are available, then consumes them.
|
|
71
|
+
* Throws `RateLimitError` only if the request can never be satisfied
|
|
72
|
+
* (i.e. `count > capacity`). Cancellable via `AbortSignal`.
|
|
73
|
+
*/
|
|
74
|
+
consume(count?: number, signal?: AbortSignal): Promise<void>;
|
|
75
|
+
/** Alias for `acquireOrThrow()` — satisfies the `Limiter` interface. */
|
|
76
|
+
acquire(count?: number): void;
|
|
77
|
+
/** Satisfies the `Limiter` interface — alias for `tryConsume()`. */
|
|
78
|
+
tryAcquire(): boolean;
|
|
79
|
+
/** Satisfies the `Limiter` interface — alias for `consume()`. */
|
|
80
|
+
waitAndAcquire(signal?: AbortSignal): Promise<void>;
|
|
81
|
+
/** Throws immediately if tokens are unavailable (non-blocking equivalent of `consume`). */
|
|
82
|
+
acquireOrThrow(count?: number): void;
|
|
83
|
+
/** Refill to full capacity and reset the refill clock. */
|
|
84
|
+
reset(): void;
|
|
85
|
+
/** Hot-resize capacity. Clamps current tokens if the new capacity is lower. */
|
|
86
|
+
setCapacity(capacity: number): void;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Sliding Window rate limiter.
|
|
90
|
+
*
|
|
91
|
+
* Tracks exact request timestamps in a ring buffer. Strict enforcement —
|
|
92
|
+
* no burst beyond `maxRequests` regardless of spacing.
|
|
93
|
+
*
|
|
94
|
+
* - `tryAcquire()` — non-throwing.
|
|
95
|
+
* - `acquire()` — throws `RateLimitError` immediately.
|
|
96
|
+
* - `waitAndAcquire()` — async, blocks until a slot is available.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const window = new SlidingWindow({ windowMs: 60_000, maxRequests: 100 });
|
|
100
|
+
* await window.waitAndAcquire(); // queues caller until a slot opens
|
|
101
|
+
*/
|
|
102
|
+
declare class SlidingWindow {
|
|
103
|
+
private readonly ring;
|
|
104
|
+
private head;
|
|
105
|
+
private count;
|
|
106
|
+
private readonly windowMs;
|
|
107
|
+
private readonly maxRequests;
|
|
108
|
+
constructor(options: SlidingWindowOptions);
|
|
109
|
+
private prune;
|
|
110
|
+
get currentCount(): number;
|
|
111
|
+
tryAcquire(): boolean;
|
|
112
|
+
acquire(): void;
|
|
113
|
+
/** Blocks until a slot is available, then acquires it. Cancellable via AbortSignal. */
|
|
114
|
+
waitAndAcquire(signal?: AbortSignal): Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Fixed Window rate limiter.
|
|
118
|
+
*
|
|
119
|
+
* Simplest algorithm — counts requests in a fixed time bucket that resets
|
|
120
|
+
* every `windowMs`. Easiest to reason about; susceptible to boundary spikes.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* const fw = new FixedWindow({ windowMs: 60_000, maxRequests: 100 });
|
|
124
|
+
* fw.acquire(); // throws if over limit in current window
|
|
125
|
+
*/
|
|
126
|
+
declare class FixedWindow {
|
|
127
|
+
private count;
|
|
128
|
+
private windowStart;
|
|
129
|
+
private readonly windowMs;
|
|
130
|
+
private readonly maxRequests;
|
|
131
|
+
private readonly onWindowReset?;
|
|
132
|
+
constructor(options: FixedWindowOptions);
|
|
133
|
+
private tick;
|
|
134
|
+
get currentCount(): number;
|
|
135
|
+
/** Ms until the current window resets. */
|
|
136
|
+
get windowResetMs(): number;
|
|
137
|
+
tryAcquire(): boolean;
|
|
138
|
+
acquire(): void;
|
|
139
|
+
waitAndAcquire(signal?: AbortSignal): Promise<void>;
|
|
140
|
+
/** Manually reset the window (useful in tests). */
|
|
141
|
+
reset(): void;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Composite rate limiter — enforces multiple limits simultaneously.
|
|
145
|
+
*
|
|
146
|
+
* All limiters must pass for a call to be allowed. Useful when APIs enforce
|
|
147
|
+
* multiple tiers (e.g. 10/second AND 500/minute AND 5000/hour).
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* const limiter = new CompositeLimiter([
|
|
151
|
+
* new TokenBucket({ capacity: 10, refillRate: 10, refillInterval: 1000 }),
|
|
152
|
+
* new SlidingWindow({ windowMs: 60_000, maxRequests: 500 }),
|
|
153
|
+
* ]);
|
|
154
|
+
* await limiter.waitAndAcquire();
|
|
155
|
+
*/
|
|
156
|
+
declare class CompositeLimiter {
|
|
157
|
+
private readonly limiters;
|
|
158
|
+
constructor(limiters: Limiter[]);
|
|
159
|
+
/** Returns `true` only if ALL limiters can acquire. Rolls back on partial failure. */
|
|
160
|
+
tryAcquire(): boolean;
|
|
161
|
+
/** Throws `RateLimitError` with the most restrictive `retryAfterMs`. */
|
|
162
|
+
acquire(): void;
|
|
163
|
+
/** Waits for ALL limiters to have capacity, then acquires atomically. */
|
|
164
|
+
waitAndAcquire(signal?: AbortSignal): Promise<void>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { CompositeLimiter, FixedWindow, type FixedWindowOptions, type Limiter, type RateLimitAlgorithm, RateLimitError, SlidingWindow, type SlidingWindowOptions, TokenBucket, type TokenBucketOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/ratelimitx.ts
|
|
4
|
+
function abortableDelay(ms, signal) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
if (signal?.aborted) {
|
|
7
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const timer = setTimeout(resolve, ms);
|
|
11
|
+
signal?.addEventListener("abort", () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
14
|
+
}, { once: true });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
var RateLimitError = class extends Error {
|
|
18
|
+
constructor(message, retryAfterMs, algorithm, limit, current) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.retryAfterMs = retryAfterMs;
|
|
21
|
+
this.algorithm = algorithm;
|
|
22
|
+
this.limit = limit;
|
|
23
|
+
this.current = current;
|
|
24
|
+
this.name = "RateLimitError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var TokenBucket = class {
|
|
28
|
+
tokens;
|
|
29
|
+
lastRefillTime;
|
|
30
|
+
capacity;
|
|
31
|
+
refillRate;
|
|
32
|
+
refillInterval;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.capacity = options.capacity;
|
|
35
|
+
this.refillRate = options.refillRate;
|
|
36
|
+
this.refillInterval = options.refillInterval ?? 1e3;
|
|
37
|
+
this.tokens = options.capacity;
|
|
38
|
+
this.lastRefillTime = Date.now();
|
|
39
|
+
}
|
|
40
|
+
refill() {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const elapsed = now - this.lastRefillTime;
|
|
43
|
+
if (elapsed <= 0) return;
|
|
44
|
+
const newTokens = elapsed / this.refillInterval * this.refillRate;
|
|
45
|
+
if (newTokens >= 1) {
|
|
46
|
+
const consumed = Math.floor(newTokens);
|
|
47
|
+
this.tokens = Math.min(this.capacity, this.tokens + consumed);
|
|
48
|
+
this.lastRefillTime += Math.floor(consumed * (this.refillInterval / this.refillRate));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
get available() {
|
|
52
|
+
this.refill();
|
|
53
|
+
return this.tokens;
|
|
54
|
+
}
|
|
55
|
+
tryConsume(count = 1) {
|
|
56
|
+
this.refill();
|
|
57
|
+
if (this.tokens >= count) {
|
|
58
|
+
this.tokens -= count;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Waits until `count` tokens are available, then consumes them.
|
|
65
|
+
* Throws `RateLimitError` only if the request can never be satisfied
|
|
66
|
+
* (i.e. `count > capacity`). Cancellable via `AbortSignal`.
|
|
67
|
+
*/
|
|
68
|
+
async consume(count = 1, signal) {
|
|
69
|
+
if (count > this.capacity) {
|
|
70
|
+
throw new RateLimitError(
|
|
71
|
+
`Requested ${count} tokens exceeds capacity ${this.capacity}`,
|
|
72
|
+
Infinity,
|
|
73
|
+
"token-bucket",
|
|
74
|
+
this.capacity,
|
|
75
|
+
count
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
while (true) {
|
|
79
|
+
this.refill();
|
|
80
|
+
if (this.tokens >= count) {
|
|
81
|
+
this.tokens -= count;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const deficit = count - this.tokens;
|
|
85
|
+
const waitMs = Math.ceil(deficit / this.refillRate * this.refillInterval);
|
|
86
|
+
await abortableDelay(waitMs, signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Alias for `acquireOrThrow()` — satisfies the `Limiter` interface. */
|
|
90
|
+
acquire(count = 1) {
|
|
91
|
+
this.acquireOrThrow(count);
|
|
92
|
+
}
|
|
93
|
+
/** Satisfies the `Limiter` interface — alias for `tryConsume()`. */
|
|
94
|
+
tryAcquire() {
|
|
95
|
+
return this.tryConsume();
|
|
96
|
+
}
|
|
97
|
+
/** Satisfies the `Limiter` interface — alias for `consume()`. */
|
|
98
|
+
async waitAndAcquire(signal) {
|
|
99
|
+
return this.consume(1, signal);
|
|
100
|
+
}
|
|
101
|
+
/** Throws immediately if tokens are unavailable (non-blocking equivalent of `consume`). */
|
|
102
|
+
acquireOrThrow(count = 1) {
|
|
103
|
+
this.refill();
|
|
104
|
+
if (this.tokens >= count) {
|
|
105
|
+
this.tokens -= count;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const deficit = count - this.tokens;
|
|
109
|
+
const retryAfterMs = Math.ceil(deficit / this.refillRate * this.refillInterval);
|
|
110
|
+
throw new RateLimitError(
|
|
111
|
+
`Rate limit exceeded. Retry after ${retryAfterMs}ms`,
|
|
112
|
+
retryAfterMs,
|
|
113
|
+
"token-bucket",
|
|
114
|
+
this.capacity,
|
|
115
|
+
Math.floor(this.tokens)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
/** Refill to full capacity and reset the refill clock. */
|
|
119
|
+
reset() {
|
|
120
|
+
this.tokens = this.capacity;
|
|
121
|
+
this.lastRefillTime = Date.now();
|
|
122
|
+
}
|
|
123
|
+
/** Hot-resize capacity. Clamps current tokens if the new capacity is lower. */
|
|
124
|
+
setCapacity(capacity) {
|
|
125
|
+
if (capacity < 1) throw new RangeError("capacity must be >= 1");
|
|
126
|
+
this.capacity = capacity;
|
|
127
|
+
this.tokens = Math.min(this.tokens, capacity);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var SlidingWindow = class {
|
|
131
|
+
// Ring buffer: pre-allocated array + head index avoids O(n) Array.shift()
|
|
132
|
+
ring;
|
|
133
|
+
head = 0;
|
|
134
|
+
count = 0;
|
|
135
|
+
windowMs;
|
|
136
|
+
maxRequests;
|
|
137
|
+
constructor(options) {
|
|
138
|
+
this.windowMs = options.windowMs;
|
|
139
|
+
this.maxRequests = options.maxRequests;
|
|
140
|
+
this.ring = new Float64Array(options.maxRequests);
|
|
141
|
+
}
|
|
142
|
+
prune() {
|
|
143
|
+
const cutoff = Date.now() - this.windowMs;
|
|
144
|
+
while (this.count > 0 && this.ring[this.head % this.maxRequests] < cutoff) {
|
|
145
|
+
this.head++;
|
|
146
|
+
this.count--;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
get currentCount() {
|
|
150
|
+
this.prune();
|
|
151
|
+
return this.count;
|
|
152
|
+
}
|
|
153
|
+
tryAcquire() {
|
|
154
|
+
this.prune();
|
|
155
|
+
if (this.count < this.maxRequests) {
|
|
156
|
+
this.ring[(this.head + this.count) % this.maxRequests] = Date.now();
|
|
157
|
+
this.count++;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
acquire() {
|
|
163
|
+
this.prune();
|
|
164
|
+
if (this.count < this.maxRequests) {
|
|
165
|
+
this.ring[(this.head + this.count) % this.maxRequests] = Date.now();
|
|
166
|
+
this.count++;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const oldest = this.ring[this.head % this.maxRequests];
|
|
170
|
+
const retryAfterMs = Math.max(0, oldest + this.windowMs - Date.now());
|
|
171
|
+
throw new RateLimitError(
|
|
172
|
+
`Rate limit exceeded. ${this.maxRequests} requests per ${this.windowMs}ms window.`,
|
|
173
|
+
retryAfterMs,
|
|
174
|
+
"sliding-window",
|
|
175
|
+
this.maxRequests,
|
|
176
|
+
this.count
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
/** Blocks until a slot is available, then acquires it. Cancellable via AbortSignal. */
|
|
180
|
+
async waitAndAcquire(signal) {
|
|
181
|
+
while (!this.tryAcquire()) {
|
|
182
|
+
const oldest = this.ring[this.head % this.maxRequests];
|
|
183
|
+
const waitMs = Math.max(1, oldest + this.windowMs - Date.now());
|
|
184
|
+
await abortableDelay(waitMs, signal);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
var FixedWindow = class {
|
|
189
|
+
count = 0;
|
|
190
|
+
windowStart;
|
|
191
|
+
windowMs;
|
|
192
|
+
maxRequests;
|
|
193
|
+
onWindowReset;
|
|
194
|
+
constructor(options) {
|
|
195
|
+
this.windowMs = options.windowMs;
|
|
196
|
+
this.maxRequests = options.maxRequests;
|
|
197
|
+
this.onWindowReset = options.onWindowReset;
|
|
198
|
+
this.windowStart = Date.now();
|
|
199
|
+
}
|
|
200
|
+
tick() {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
if (now - this.windowStart >= this.windowMs) {
|
|
203
|
+
this.count = 0;
|
|
204
|
+
this.windowStart = now;
|
|
205
|
+
this.onWindowReset?.(this.windowStart);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
get currentCount() {
|
|
209
|
+
this.tick();
|
|
210
|
+
return this.count;
|
|
211
|
+
}
|
|
212
|
+
/** Ms until the current window resets. */
|
|
213
|
+
get windowResetMs() {
|
|
214
|
+
return Math.max(0, this.windowStart + this.windowMs - Date.now());
|
|
215
|
+
}
|
|
216
|
+
tryAcquire() {
|
|
217
|
+
this.tick();
|
|
218
|
+
if (this.count < this.maxRequests) {
|
|
219
|
+
this.count++;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
acquire() {
|
|
225
|
+
this.tick();
|
|
226
|
+
if (this.count < this.maxRequests) {
|
|
227
|
+
this.count++;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
throw new RateLimitError(
|
|
231
|
+
`Fixed window rate limit exceeded. Retry after ${this.windowResetMs}ms`,
|
|
232
|
+
this.windowResetMs,
|
|
233
|
+
"fixed-window",
|
|
234
|
+
this.maxRequests,
|
|
235
|
+
this.count
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
async waitAndAcquire(signal) {
|
|
239
|
+
while (!this.tryAcquire()) {
|
|
240
|
+
await abortableDelay(this.windowResetMs || 1, signal);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Manually reset the window (useful in tests). */
|
|
244
|
+
reset() {
|
|
245
|
+
this.count = 0;
|
|
246
|
+
this.windowStart = Date.now();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
var CompositeLimiter = class {
|
|
250
|
+
constructor(limiters) {
|
|
251
|
+
this.limiters = limiters;
|
|
252
|
+
if (limiters.length === 0) throw new RangeError("CompositeLimiter requires at least one limiter");
|
|
253
|
+
}
|
|
254
|
+
/** Returns `true` only if ALL limiters can acquire. Rolls back on partial failure. */
|
|
255
|
+
tryAcquire() {
|
|
256
|
+
for (const l of this.limiters) {
|
|
257
|
+
if (l.tryAcquire()) ; else {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
/** Throws `RateLimitError` with the most restrictive `retryAfterMs`. */
|
|
264
|
+
acquire() {
|
|
265
|
+
for (const l of this.limiters) {
|
|
266
|
+
l.acquire();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Waits for ALL limiters to have capacity, then acquires atomically. */
|
|
270
|
+
async waitAndAcquire(signal) {
|
|
271
|
+
while (!this.tryAcquire()) {
|
|
272
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
273
|
+
await abortableDelay(1, signal);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
exports.CompositeLimiter = CompositeLimiter;
|
|
279
|
+
exports.FixedWindow = FixedWindow;
|
|
280
|
+
exports.RateLimitError = RateLimitError;
|
|
281
|
+
exports.SlidingWindow = SlidingWindow;
|
|
282
|
+
exports.TokenBucket = TokenBucket;
|
|
283
|
+
//# sourceMappingURL=index.js.map
|
|
284
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ratelimitx.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type { TokenBucketOptions, SlidingWindowOptions, FixedWindowOptions, Limiter, RateLimitAlgorithm } from './types.js';\nimport type { TokenBucketOptions, SlidingWindowOptions, FixedWindowOptions, Limiter } from './types.js';\n\nfunction abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); }, { once: true });\n });\n}\n\n// ─── Error ───────────────────────────────────────────────────────────────────\n\nexport class RateLimitError extends Error {\n constructor(\n message: string,\n public readonly retryAfterMs: number,\n public readonly algorithm: 'token-bucket' | 'sliding-window' | 'fixed-window',\n public readonly limit: number,\n public readonly current: number\n ) {\n super(message);\n this.name = 'RateLimitError';\n }\n}\n\n// ─── Shared Helpers ──────────────────────────────────────────────────────────\n\n/**\n * Token Bucket rate limiter.\n *\n * Tokens accumulate over time up to `capacity`. Burst-friendly — allows up\n * to `capacity` back-to-back calls as long as the bucket is full.\n *\n * - `tryConsume()` — non-throwing, returns `true` if tokens available.\n * - `consume()` — async, **blocks** until tokens are available.\n * - `available` — current token count (after refill).\n *\n * @example\n * const bucket = new TokenBucket({ capacity: 10, refillRate: 2, refillInterval: 1000 });\n * await bucket.consume(); // waits until a token is available\n */\nexport class TokenBucket {\n private tokens: number;\n private lastRefillTime: number;\n private capacity: number;\n private readonly refillRate: number;\n private readonly refillInterval: number;\n\n constructor(options: TokenBucketOptions) {\n this.capacity = options.capacity;\n this.refillRate = options.refillRate;\n this.refillInterval = options.refillInterval ?? 1000;\n this.tokens = options.capacity;\n this.lastRefillTime = Date.now();\n }\n\n private refill(): void {\n const now = Date.now();\n const elapsed = now - this.lastRefillTime;\n if (elapsed <= 0) return;\n const newTokens = (elapsed / this.refillInterval) * this.refillRate;\n if (newTokens >= 1) {\n // Advance lastRefillTime only by the consumed portion to preserve sub-interval leftovers\n const consumed = Math.floor(newTokens);\n this.tokens = Math.min(this.capacity, this.tokens + consumed);\n this.lastRefillTime += Math.floor(consumed * (this.refillInterval / this.refillRate));\n }\n }\n\n get available(): number {\n this.refill();\n return this.tokens;\n }\n\n tryConsume(count = 1): boolean {\n this.refill();\n if (this.tokens >= count) {\n this.tokens -= count;\n return true;\n }\n return false;\n }\n\n /**\n * Waits until `count` tokens are available, then consumes them.\n * Throws `RateLimitError` only if the request can never be satisfied\n * (i.e. `count > capacity`). Cancellable via `AbortSignal`.\n */\n async consume(count = 1, signal?: AbortSignal): Promise<void> {\n if (count > this.capacity) {\n throw new RateLimitError(\n `Requested ${count} tokens exceeds capacity ${this.capacity}`,\n Infinity,\n 'token-bucket',\n this.capacity,\n count\n );\n }\n while (true) {\n this.refill();\n if (this.tokens >= count) {\n this.tokens -= count;\n return;\n }\n const deficit = count - this.tokens;\n const waitMs = Math.ceil((deficit / this.refillRate) * this.refillInterval);\n await abortableDelay(waitMs, signal);\n }\n }\n\n /** Alias for `acquireOrThrow()` — satisfies the `Limiter` interface. */\n acquire(count = 1): void {\n this.acquireOrThrow(count);\n }\n\n /** Satisfies the `Limiter` interface — alias for `tryConsume()`. */\n tryAcquire(): boolean {\n return this.tryConsume();\n }\n\n /** Satisfies the `Limiter` interface — alias for `consume()`. */\n async waitAndAcquire(signal?: AbortSignal): Promise<void> {\n return this.consume(1, signal);\n }\n\n /** Throws immediately if tokens are unavailable (non-blocking equivalent of `consume`). */\n acquireOrThrow(count = 1): void {\n this.refill();\n if (this.tokens >= count) {\n this.tokens -= count;\n return;\n }\n const deficit = count - this.tokens;\n const retryAfterMs = Math.ceil((deficit / this.refillRate) * this.refillInterval);\n throw new RateLimitError(\n `Rate limit exceeded. Retry after ${retryAfterMs}ms`,\n retryAfterMs,\n 'token-bucket',\n this.capacity,\n Math.floor(this.tokens)\n );\n }\n\n /** Refill to full capacity and reset the refill clock. */\n reset(): void {\n this.tokens = this.capacity;\n this.lastRefillTime = Date.now();\n }\n\n /** Hot-resize capacity. Clamps current tokens if the new capacity is lower. */\n setCapacity(capacity: number): void {\n if (capacity < 1) throw new RangeError('capacity must be >= 1');\n this.capacity = capacity as typeof this.capacity;\n this.tokens = Math.min(this.tokens, capacity);\n }\n}\n\n// ─── Sliding Window ───────────────────────────────────────────────────────────\n\n/**\n * Sliding Window rate limiter.\n *\n * Tracks exact request timestamps in a ring buffer. Strict enforcement —\n * no burst beyond `maxRequests` regardless of spacing.\n *\n * - `tryAcquire()` — non-throwing.\n * - `acquire()` — throws `RateLimitError` immediately.\n * - `waitAndAcquire()` — async, blocks until a slot is available.\n *\n * @example\n * const window = new SlidingWindow({ windowMs: 60_000, maxRequests: 100 });\n * await window.waitAndAcquire(); // queues caller until a slot opens\n */\nexport class SlidingWindow {\n // Ring buffer: pre-allocated array + head index avoids O(n) Array.shift()\n private readonly ring: Float64Array;\n private head = 0;\n private count = 0;\n private readonly windowMs: number;\n private readonly maxRequests: number;\n\n constructor(options: SlidingWindowOptions) {\n this.windowMs = options.windowMs;\n this.maxRequests = options.maxRequests;\n this.ring = new Float64Array(options.maxRequests);\n }\n\n private prune(): void {\n const cutoff = Date.now() - this.windowMs;\n while (this.count > 0 && this.ring[this.head % this.maxRequests] < cutoff) {\n this.head++;\n this.count--;\n }\n }\n\n get currentCount(): number {\n this.prune();\n return this.count;\n }\n\n tryAcquire(): boolean {\n this.prune();\n if (this.count < this.maxRequests) {\n this.ring[(this.head + this.count) % this.maxRequests] = Date.now();\n this.count++;\n return true;\n }\n return false;\n }\n\n acquire(): void {\n this.prune();\n if (this.count < this.maxRequests) {\n this.ring[(this.head + this.count) % this.maxRequests] = Date.now();\n this.count++;\n return;\n }\n const oldest = this.ring[this.head % this.maxRequests];\n const retryAfterMs = Math.max(0, oldest + this.windowMs - Date.now());\n throw new RateLimitError(\n `Rate limit exceeded. ${this.maxRequests} requests per ${this.windowMs}ms window.`,\n retryAfterMs,\n 'sliding-window',\n this.maxRequests,\n this.count\n );\n }\n\n /** Blocks until a slot is available, then acquires it. Cancellable via AbortSignal. */\n async waitAndAcquire(signal?: AbortSignal): Promise<void> {\n while (!this.tryAcquire()) {\n const oldest = this.ring[this.head % this.maxRequests];\n const waitMs = Math.max(1, oldest + this.windowMs - Date.now());\n await abortableDelay(waitMs, signal);\n }\n }\n}\n\n// ─── Fixed Window ─────────────────────────────────────────────────────────────\n\n/**\n * Fixed Window rate limiter.\n *\n * Simplest algorithm — counts requests in a fixed time bucket that resets\n * every `windowMs`. Easiest to reason about; susceptible to boundary spikes.\n *\n * @example\n * const fw = new FixedWindow({ windowMs: 60_000, maxRequests: 100 });\n * fw.acquire(); // throws if over limit in current window\n */\nexport class FixedWindow {\n private count = 0;\n private windowStart: number;\n private readonly windowMs: number;\n private readonly maxRequests: number;\n private readonly onWindowReset?: (windowStart: number) => void;\n\n constructor(options: FixedWindowOptions) {\n this.windowMs = options.windowMs;\n this.maxRequests = options.maxRequests;\n this.onWindowReset = options.onWindowReset;\n this.windowStart = Date.now();\n }\n\n private tick(): void {\n const now = Date.now();\n if (now - this.windowStart >= this.windowMs) {\n this.count = 0;\n this.windowStart = now;\n this.onWindowReset?.(this.windowStart);\n }\n }\n\n get currentCount(): number {\n this.tick();\n return this.count;\n }\n\n /** Ms until the current window resets. */\n get windowResetMs(): number {\n return Math.max(0, this.windowStart + this.windowMs - Date.now());\n }\n\n tryAcquire(): boolean {\n this.tick();\n if (this.count < this.maxRequests) {\n this.count++;\n return true;\n }\n return false;\n }\n\n acquire(): void {\n this.tick();\n if (this.count < this.maxRequests) {\n this.count++;\n return;\n }\n throw new RateLimitError(\n `Fixed window rate limit exceeded. Retry after ${this.windowResetMs}ms`,\n this.windowResetMs,\n 'fixed-window',\n this.maxRequests,\n this.count\n );\n }\n\n async waitAndAcquire(signal?: AbortSignal): Promise<void> {\n while (!this.tryAcquire()) {\n await abortableDelay(this.windowResetMs || 1, signal);\n }\n }\n\n /** Manually reset the window (useful in tests). */\n reset(): void {\n this.count = 0;\n this.windowStart = Date.now();\n }\n}\n\n// ─── Composite Limiter ────────────────────────────────────────────────────────\n\n/**\n * Composite rate limiter — enforces multiple limits simultaneously.\n *\n * All limiters must pass for a call to be allowed. Useful when APIs enforce\n * multiple tiers (e.g. 10/second AND 500/minute AND 5000/hour).\n *\n * @example\n * const limiter = new CompositeLimiter([\n * new TokenBucket({ capacity: 10, refillRate: 10, refillInterval: 1000 }),\n * new SlidingWindow({ windowMs: 60_000, maxRequests: 500 }),\n * ]);\n * await limiter.waitAndAcquire();\n */\nexport class CompositeLimiter {\n constructor(private readonly limiters: Limiter[]) {\n if (limiters.length === 0) throw new RangeError('CompositeLimiter requires at least one limiter');\n }\n\n /** Returns `true` only if ALL limiters can acquire. Rolls back on partial failure. */\n tryAcquire(): boolean {\n const acquired: Limiter[] = [];\n for (const l of this.limiters) {\n if (l.tryAcquire()) {\n acquired.push(l);\n } else {\n // Rollback is not possible for SlidingWindow/FixedWindow after tryAcquire succeeds,\n // but we stop here — already-acquired counts against the window, which is acceptable.\n return false;\n }\n }\n return true;\n }\n\n /** Throws `RateLimitError` with the most restrictive `retryAfterMs`. */\n acquire(): void {\n for (const l of this.limiters) {\n l.acquire();\n }\n }\n\n /** Waits for ALL limiters to have capacity, then acquires atomically. */\n async waitAndAcquire(signal?: AbortSignal): Promise<void> {\n while (!this.tryAcquire()) {\n if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');\n await abortableDelay(1, signal);\n }\n }\n}\n"]}
|