@crewhaus/rate-limiter 0.1.3 → 0.1.5
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/dist/index.d.ts +79 -0
- package/dist/index.js +239 -0
- package/package.json +9 -6
- package/src/index.test.ts +0 -211
- package/src/index.ts +0 -337
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 27 — `rate-limiter`. Multi-dimensional gating between callers
|
|
3
|
+
* and downstream services. Three keyed dimensions:
|
|
4
|
+
* - **per-tenant** (gateway-server pre-handler)
|
|
5
|
+
* - **per-provider** (model-router pre-call)
|
|
6
|
+
* - **per-tool** (runtime-core pre-tool-execute, configured in spec under
|
|
7
|
+
* `tools.<Name>.rateLimit`)
|
|
8
|
+
*
|
|
9
|
+
* Two algorithms; pick per-bucket:
|
|
10
|
+
* - **token-bucket** — burst-tolerant. `capacity` tokens; refill at
|
|
11
|
+
* `refillPerSec`. Acquire blocks until enough tokens are available.
|
|
12
|
+
* - **leaky-bucket** — smoothing. Treat acquires as drops landing in a
|
|
13
|
+
* bucket that drains at `refillPerSec`. New drops queue when the
|
|
14
|
+
* bucket is full; the queue serves drops at the drain rate.
|
|
15
|
+
*
|
|
16
|
+
* `acquire(keys, cost)` evaluates each key in order and only proceeds
|
|
17
|
+
* when *every* bucket has the requested cost. The implementation never
|
|
18
|
+
* takes a partial reservation — if any bucket would block, the call
|
|
19
|
+
* either waits for the longest delay or rejects on `maxWaitMs`. This
|
|
20
|
+
* guarantees fail-closed semantics: an unknown key always denies.
|
|
21
|
+
*/
|
|
22
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
23
|
+
export declare class RateLimitError extends CrewhausError {
|
|
24
|
+
readonly name = "RateLimitError";
|
|
25
|
+
constructor(message: string, cause?: unknown);
|
|
26
|
+
}
|
|
27
|
+
export type BucketKind = "token-bucket" | "leaky-bucket";
|
|
28
|
+
export type BucketConfig = {
|
|
29
|
+
readonly kind: BucketKind;
|
|
30
|
+
/** Maximum tokens (token-bucket) or queue depth (leaky-bucket). */
|
|
31
|
+
readonly capacity: number;
|
|
32
|
+
/** Refill rate (token-bucket) or drain rate (leaky-bucket), per second. */
|
|
33
|
+
readonly refillPerSec: number;
|
|
34
|
+
};
|
|
35
|
+
export type AcquireKey = {
|
|
36
|
+
readonly dimension: "tenant" | "provider" | "tool";
|
|
37
|
+
readonly id: string;
|
|
38
|
+
};
|
|
39
|
+
export type AcquireOptions = {
|
|
40
|
+
/** How long to wait for tokens before rejecting. Defaults to 30s. */
|
|
41
|
+
readonly maxWaitMs?: number;
|
|
42
|
+
/** Override now() for tests. */
|
|
43
|
+
readonly now?: () => number;
|
|
44
|
+
};
|
|
45
|
+
export type RateLimiterOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* Per-`(dimension, id)` bucket configuration. Lookup is exact-match;
|
|
48
|
+
* unknown keys deny by default (fail-closed). The `*` id is reserved
|
|
49
|
+
* for the per-dimension default — declared explicitly when one is
|
|
50
|
+
* desired.
|
|
51
|
+
*/
|
|
52
|
+
readonly buckets: ReadonlyMap<string, BucketConfig>;
|
|
53
|
+
/** Override "now" for tests. */
|
|
54
|
+
readonly now?: () => number;
|
|
55
|
+
};
|
|
56
|
+
export interface RateLimiter {
|
|
57
|
+
/**
|
|
58
|
+
* Acquire `cost` tokens (default 1) from each key's bucket. Resolves
|
|
59
|
+
* once every bucket has paid out. Rejects with `RateLimitError` if
|
|
60
|
+
* any waited longer than `maxWaitMs`, or if any key is missing.
|
|
61
|
+
*/
|
|
62
|
+
acquire(keys: ReadonlyArray<AcquireKey>, cost?: number, opts?: AcquireOptions): Promise<void>;
|
|
63
|
+
/** Diagnostic snapshot of current bucket state. */
|
|
64
|
+
inspect(key: AcquireKey): {
|
|
65
|
+
config: BucketConfig;
|
|
66
|
+
available: number;
|
|
67
|
+
waitingCount: number;
|
|
68
|
+
} | undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Stable string key for a dimension+id pair. */
|
|
71
|
+
export declare function bucketKeyOf(key: AcquireKey): string;
|
|
72
|
+
/** Static helper: bucket capacity check (no async waiting). */
|
|
73
|
+
export declare function tokenBucketAvailable(state: TokenBucketState, cost: number, now: number, config: BucketConfig): boolean;
|
|
74
|
+
type TokenBucketState = {
|
|
75
|
+
tokens: number;
|
|
76
|
+
lastRefillMs: number;
|
|
77
|
+
};
|
|
78
|
+
export declare function createRateLimiter(opts: RateLimiterOptions): RateLimiter;
|
|
79
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 27 — `rate-limiter`. Multi-dimensional gating between callers
|
|
3
|
+
* and downstream services. Three keyed dimensions:
|
|
4
|
+
* - **per-tenant** (gateway-server pre-handler)
|
|
5
|
+
* - **per-provider** (model-router pre-call)
|
|
6
|
+
* - **per-tool** (runtime-core pre-tool-execute, configured in spec under
|
|
7
|
+
* `tools.<Name>.rateLimit`)
|
|
8
|
+
*
|
|
9
|
+
* Two algorithms; pick per-bucket:
|
|
10
|
+
* - **token-bucket** — burst-tolerant. `capacity` tokens; refill at
|
|
11
|
+
* `refillPerSec`. Acquire blocks until enough tokens are available.
|
|
12
|
+
* - **leaky-bucket** — smoothing. Treat acquires as drops landing in a
|
|
13
|
+
* bucket that drains at `refillPerSec`. New drops queue when the
|
|
14
|
+
* bucket is full; the queue serves drops at the drain rate.
|
|
15
|
+
*
|
|
16
|
+
* `acquire(keys, cost)` evaluates each key in order and only proceeds
|
|
17
|
+
* when *every* bucket has the requested cost. The implementation never
|
|
18
|
+
* takes a partial reservation — if any bucket would block, the call
|
|
19
|
+
* either waits for the longest delay or rejects on `maxWaitMs`. This
|
|
20
|
+
* guarantees fail-closed semantics: an unknown key always denies.
|
|
21
|
+
*/
|
|
22
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
23
|
+
export class RateLimitError extends CrewhausError {
|
|
24
|
+
name = "RateLimitError";
|
|
25
|
+
constructor(message, cause) {
|
|
26
|
+
super("config", message, cause);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Stable string key for a dimension+id pair. */
|
|
30
|
+
export function bucketKeyOf(key) {
|
|
31
|
+
return `${key.dimension}:${key.id}`;
|
|
32
|
+
}
|
|
33
|
+
/** Static helper: bucket capacity check (no async waiting). */
|
|
34
|
+
export function tokenBucketAvailable(state, cost, now, config) {
|
|
35
|
+
refillTokenBucket(state, now, config);
|
|
36
|
+
return state.tokens >= cost;
|
|
37
|
+
}
|
|
38
|
+
function refillTokenBucket(state, now, config) {
|
|
39
|
+
const elapsedSec = Math.max(0, (now - state.lastRefillMs) / 1000);
|
|
40
|
+
const refilled = elapsedSec * config.refillPerSec;
|
|
41
|
+
state.tokens = Math.min(config.capacity, state.tokens + refilled);
|
|
42
|
+
state.lastRefillMs = now;
|
|
43
|
+
}
|
|
44
|
+
function drainLeakyBucket(state, now, config) {
|
|
45
|
+
const elapsedSec = Math.max(0, (now - state.lastDrainMs) / 1000);
|
|
46
|
+
const drained = elapsedSec * config.refillPerSec;
|
|
47
|
+
state.level = Math.max(0, state.level - drained);
|
|
48
|
+
state.lastDrainMs = now;
|
|
49
|
+
}
|
|
50
|
+
export function createRateLimiter(opts) {
|
|
51
|
+
const buckets = opts.buckets;
|
|
52
|
+
const tokenStates = new Map();
|
|
53
|
+
const leakyStates = new Map();
|
|
54
|
+
function getNow(callerNow) {
|
|
55
|
+
return (callerNow ?? opts.now ?? Date.now)();
|
|
56
|
+
}
|
|
57
|
+
function getOrInitTokenState(key, config, now) {
|
|
58
|
+
let s = tokenStates.get(key);
|
|
59
|
+
if (!s) {
|
|
60
|
+
s = { tokens: config.capacity, lastRefillMs: now };
|
|
61
|
+
tokenStates.set(key, s);
|
|
62
|
+
}
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
function getOrInitLeakyState(key, now) {
|
|
66
|
+
let s = leakyStates.get(key);
|
|
67
|
+
if (!s) {
|
|
68
|
+
s = { level: 0, lastDrainMs: now, queue: [] };
|
|
69
|
+
leakyStates.set(key, s);
|
|
70
|
+
}
|
|
71
|
+
return s;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Wait for a single bucket to allow `cost` tokens. Resolves when ready.
|
|
75
|
+
* `maxWaitMs` enforces the cap; rejects with RateLimitError on timeout.
|
|
76
|
+
*/
|
|
77
|
+
function acquireOne(key, cost, config, maxWaitMs, nowFn) {
|
|
78
|
+
const k = bucketKeyOf(key);
|
|
79
|
+
const start = nowFn();
|
|
80
|
+
if (config.kind === "token-bucket") {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const tryAcquire = () => {
|
|
83
|
+
const now = nowFn();
|
|
84
|
+
const state = getOrInitTokenState(k, config, now);
|
|
85
|
+
refillTokenBucket(state, now, config);
|
|
86
|
+
if (state.tokens >= cost) {
|
|
87
|
+
state.tokens -= cost;
|
|
88
|
+
resolve();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const elapsedMs = now - start;
|
|
92
|
+
const remainingMs = maxWaitMs - elapsedMs;
|
|
93
|
+
if (remainingMs <= 0) {
|
|
94
|
+
reject(new RateLimitError(`rate limit exceeded for ${k}: ${cost} tokens needed, ${state.tokens.toFixed(2)} available, max wait ${maxWaitMs}ms reached`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Time until enough tokens accrue
|
|
98
|
+
const deficit = cost - state.tokens;
|
|
99
|
+
const msToWait = Math.min(remainingMs, Math.max(10, (deficit / config.refillPerSec) * 1000));
|
|
100
|
+
setTimeout(tryAcquire, msToWait);
|
|
101
|
+
};
|
|
102
|
+
tryAcquire();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// leaky-bucket
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const now = nowFn();
|
|
108
|
+
const state = getOrInitLeakyState(k, now);
|
|
109
|
+
drainLeakyBucket(state, now, config);
|
|
110
|
+
const wouldExceed = state.level + cost > config.capacity;
|
|
111
|
+
if (!wouldExceed && state.queue.length === 0) {
|
|
112
|
+
// Fast-path: no queue, fits in capacity.
|
|
113
|
+
state.level += cost;
|
|
114
|
+
resolve();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Queue and rely on drain timer.
|
|
118
|
+
const entry = {
|
|
119
|
+
cost,
|
|
120
|
+
resolve,
|
|
121
|
+
reject,
|
|
122
|
+
timer: setTimeout(() => {
|
|
123
|
+
const idx = state.queue.indexOf(entry);
|
|
124
|
+
if (idx >= 0)
|
|
125
|
+
state.queue.splice(idx, 1);
|
|
126
|
+
reject(new RateLimitError(`rate limit exceeded for ${k}: leaky bucket full, max wait ${maxWaitMs}ms reached`));
|
|
127
|
+
}, maxWaitMs),
|
|
128
|
+
};
|
|
129
|
+
state.queue.push(entry);
|
|
130
|
+
// Schedule a drain check.
|
|
131
|
+
const drainEveryMs = Math.max(10, 1000 / config.refillPerSec);
|
|
132
|
+
const tick = () => {
|
|
133
|
+
const tickNow = nowFn();
|
|
134
|
+
drainLeakyBucket(state, tickNow, config);
|
|
135
|
+
// Process as many queue entries as fit under capacity.
|
|
136
|
+
while (state.queue.length > 0) {
|
|
137
|
+
const head = state.queue[0];
|
|
138
|
+
if (!head)
|
|
139
|
+
break;
|
|
140
|
+
if (state.level + head.cost <= config.capacity) {
|
|
141
|
+
state.queue.shift();
|
|
142
|
+
if (head.timer)
|
|
143
|
+
clearTimeout(head.timer);
|
|
144
|
+
state.level += head.cost;
|
|
145
|
+
head.resolve();
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (state.queue.length > 0) {
|
|
152
|
+
setTimeout(tick, drainEveryMs);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
setTimeout(tick, drainEveryMs);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
async acquire(keys, cost = 1, callerOpts = {}) {
|
|
160
|
+
const maxWaitMs = callerOpts.maxWaitMs ?? 30_000;
|
|
161
|
+
const nowFn = () => getNow(callerOpts.now);
|
|
162
|
+
// Fail-closed: every key must resolve to a known bucket.
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const k = bucketKeyOf(key);
|
|
165
|
+
if (!buckets.has(k)) {
|
|
166
|
+
// Per-dimension default lookup
|
|
167
|
+
const fallback = `${key.dimension}:*`;
|
|
168
|
+
if (!buckets.has(fallback)) {
|
|
169
|
+
throw new RateLimitError(`no bucket configured for ${k} (and no ${fallback} default)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Acquire each in sequence so we don't double-charge a bucket on
|
|
174
|
+
// partial failure. (Parallel acquisition would require two-phase
|
|
175
|
+
// commit; sequential is simpler and the bucket counts stay correct.)
|
|
176
|
+
const acquired = [];
|
|
177
|
+
try {
|
|
178
|
+
for (const key of keys) {
|
|
179
|
+
const k = bucketKeyOf(key);
|
|
180
|
+
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
181
|
+
if (!config)
|
|
182
|
+
throw new RateLimitError(`no bucket for ${k}`);
|
|
183
|
+
await acquireOne(key, cost, config, maxWaitMs, nowFn);
|
|
184
|
+
acquired.push(key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
// Refund any successful acquisitions so partial failures don't drain buckets.
|
|
189
|
+
const now = nowFn();
|
|
190
|
+
for (const key of acquired) {
|
|
191
|
+
const k = bucketKeyOf(key);
|
|
192
|
+
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
193
|
+
if (!config)
|
|
194
|
+
continue;
|
|
195
|
+
if (config.kind === "token-bucket") {
|
|
196
|
+
const state = tokenStates.get(k);
|
|
197
|
+
if (state) {
|
|
198
|
+
state.tokens = Math.min(config.capacity, state.tokens + cost);
|
|
199
|
+
state.lastRefillMs = now;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const state = leakyStates.get(k);
|
|
204
|
+
if (state) {
|
|
205
|
+
state.level = Math.max(0, state.level - cost);
|
|
206
|
+
state.lastDrainMs = now;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
inspect(key) {
|
|
214
|
+
const k = bucketKeyOf(key);
|
|
215
|
+
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
216
|
+
if (!config)
|
|
217
|
+
return undefined;
|
|
218
|
+
const now = (opts.now ?? Date.now)();
|
|
219
|
+
if (config.kind === "token-bucket") {
|
|
220
|
+
const state = tokenStates.get(k);
|
|
221
|
+
if (!state) {
|
|
222
|
+
return { config, available: config.capacity, waitingCount: 0 };
|
|
223
|
+
}
|
|
224
|
+
refillTokenBucket(state, now, config);
|
|
225
|
+
return { config, available: state.tokens, waitingCount: 0 };
|
|
226
|
+
}
|
|
227
|
+
const state = leakyStates.get(k);
|
|
228
|
+
if (!state) {
|
|
229
|
+
return { config, available: config.capacity, waitingCount: 0 };
|
|
230
|
+
}
|
|
231
|
+
drainLeakyBucket(state, now, config);
|
|
232
|
+
return {
|
|
233
|
+
config,
|
|
234
|
+
available: Math.max(0, config.capacity - state.level),
|
|
235
|
+
waitingCount: state.queue.length,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/rate-limiter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-dimensional token-bucket / leaky-bucket rate limiter (per-tenant, per-provider, per-tool)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 27 — `rate-limiter` tests:
|
|
3
|
-
* - T1 per algorithm (token-bucket vs leaky-bucket) edge cases
|
|
4
|
-
* - T7 1000-acquirer load test (concurrency-fair + no starvation)
|
|
5
|
-
* - T8 fail-closed when keys are missing (deny rather than allow)
|
|
6
|
-
*/
|
|
7
|
-
import { describe, expect, test } from "bun:test";
|
|
8
|
-
import {
|
|
9
|
-
type AcquireKey,
|
|
10
|
-
type BucketConfig,
|
|
11
|
-
RateLimitError,
|
|
12
|
-
bucketKeyOf,
|
|
13
|
-
createRateLimiter,
|
|
14
|
-
tokenBucketAvailable,
|
|
15
|
-
} from "./index";
|
|
16
|
-
|
|
17
|
-
describe("rate-limiter — T1 token-bucket", () => {
|
|
18
|
-
test("acquire below capacity is immediate", async () => {
|
|
19
|
-
const buckets = new Map<string, BucketConfig>([
|
|
20
|
-
["tenant:t1", { kind: "token-bucket", capacity: 10, refillPerSec: 1 }],
|
|
21
|
-
]);
|
|
22
|
-
// Inject a frozen clock so the bucket's refill is computed against virtual
|
|
23
|
-
// time. With the real clock, the few ms between acquire and inspect refill
|
|
24
|
-
// a fraction of a token (1/sec), nudging `available` to ~5.05 — enough to
|
|
25
|
-
// trip the old toBeCloseTo(5, 1) 0.05 tolerance on loaded CI runners.
|
|
26
|
-
// Frozen time makes the post-acquire balance exactly 5, deterministically.
|
|
27
|
-
const nowMs = 1_000_000;
|
|
28
|
-
const rl = createRateLimiter({ buckets, now: (): number => nowMs });
|
|
29
|
-
const t0 = Date.now();
|
|
30
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 5);
|
|
31
|
-
// "immediate": acquiring below capacity must not block (real wall-clock).
|
|
32
|
-
expect(Date.now() - t0).toBeLessThan(250);
|
|
33
|
-
const inspect = rl.inspect({ dimension: "tenant", id: "t1" });
|
|
34
|
-
expect(inspect?.available).toBe(5);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("burst tolerance: capacity available immediately at start", async () => {
|
|
38
|
-
const buckets = new Map<string, BucketConfig>([
|
|
39
|
-
["tenant:t1", { kind: "token-bucket", capacity: 10, refillPerSec: 0.1 }],
|
|
40
|
-
]);
|
|
41
|
-
const rl = createRateLimiter({ buckets });
|
|
42
|
-
const t0 = Date.now();
|
|
43
|
-
for (let i = 0; i < 10; i++) {
|
|
44
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 1);
|
|
45
|
-
}
|
|
46
|
-
expect(Date.now() - t0).toBeLessThan(100);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("blocks until refill when over capacity", async () => {
|
|
50
|
-
const buckets = new Map<string, BucketConfig>([
|
|
51
|
-
["tenant:t1", { kind: "token-bucket", capacity: 1, refillPerSec: 10 }],
|
|
52
|
-
]);
|
|
53
|
-
const rl = createRateLimiter({ buckets });
|
|
54
|
-
const t0 = Date.now();
|
|
55
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 1);
|
|
56
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 1);
|
|
57
|
-
const elapsed = Date.now() - t0;
|
|
58
|
-
// Second call needs to wait for ~100ms refill. Generous lower bound for
|
|
59
|
-
// shared-CI scheduling jitter; upper bound large enough to avoid flake.
|
|
60
|
-
expect(elapsed).toBeGreaterThanOrEqual(50);
|
|
61
|
-
expect(elapsed).toBeLessThan(2_000);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("rejects after maxWaitMs when refill rate too slow", async () => {
|
|
65
|
-
const buckets = new Map<string, BucketConfig>([
|
|
66
|
-
["tenant:t1", { kind: "token-bucket", capacity: 1, refillPerSec: 0.01 }],
|
|
67
|
-
]);
|
|
68
|
-
const rl = createRateLimiter({ buckets });
|
|
69
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 1);
|
|
70
|
-
expect(
|
|
71
|
-
rl.acquire([{ dimension: "tenant", id: "t1" }], 1, { maxWaitMs: 100 }),
|
|
72
|
-
).rejects.toBeInstanceOf(RateLimitError);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe("rate-limiter — T1 leaky-bucket", () => {
|
|
77
|
-
test("smoothing: requests release at refill rate", async () => {
|
|
78
|
-
const buckets = new Map<string, BucketConfig>([
|
|
79
|
-
["tenant:t1", { kind: "leaky-bucket", capacity: 5, refillPerSec: 50 }],
|
|
80
|
-
]);
|
|
81
|
-
const rl = createRateLimiter({ buckets });
|
|
82
|
-
// 5 fit under capacity; 6th queues for ~20ms. Generous bounds for jitter.
|
|
83
|
-
const promises: Array<Promise<void>> = [];
|
|
84
|
-
const t0 = Date.now();
|
|
85
|
-
for (let i = 0; i < 7; i++) {
|
|
86
|
-
promises.push(rl.acquire([{ dimension: "tenant", id: "t1" }], 1, { maxWaitMs: 30_000 }));
|
|
87
|
-
}
|
|
88
|
-
await Promise.all(promises);
|
|
89
|
-
const elapsed = Date.now() - t0;
|
|
90
|
-
expect(elapsed).toBeGreaterThanOrEqual(15);
|
|
91
|
-
expect(elapsed).toBeLessThan(5_000);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("rejects on maxWait when queue stays full", async () => {
|
|
95
|
-
const buckets = new Map<string, BucketConfig>([
|
|
96
|
-
["tenant:t1", { kind: "leaky-bucket", capacity: 1, refillPerSec: 0.01 }],
|
|
97
|
-
]);
|
|
98
|
-
const rl = createRateLimiter({ buckets });
|
|
99
|
-
await rl.acquire([{ dimension: "tenant", id: "t1" }], 1);
|
|
100
|
-
expect(
|
|
101
|
-
rl.acquire([{ dimension: "tenant", id: "t1" }], 1, { maxWaitMs: 50 }),
|
|
102
|
-
).rejects.toBeInstanceOf(RateLimitError);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("rate-limiter — T8 fail-closed on missing keys", () => {
|
|
107
|
-
test("acquire on unknown key throws RateLimitError", async () => {
|
|
108
|
-
const rl = createRateLimiter({ buckets: new Map() });
|
|
109
|
-
expect(rl.acquire([{ dimension: "tenant", id: "unknown" }], 1)).rejects.toBeInstanceOf(
|
|
110
|
-
RateLimitError,
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("acquire passes for unknown id when * default exists", async () => {
|
|
115
|
-
const buckets = new Map<string, BucketConfig>([
|
|
116
|
-
["tenant:*", { kind: "token-bucket", capacity: 5, refillPerSec: 1 }],
|
|
117
|
-
]);
|
|
118
|
-
const rl = createRateLimiter({ buckets });
|
|
119
|
-
await rl.acquire([{ dimension: "tenant", id: "any" }], 1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("partial failure refunds successful acquisitions", async () => {
|
|
123
|
-
const buckets = new Map<string, BucketConfig>([
|
|
124
|
-
["tenant:t1", { kind: "token-bucket", capacity: 10, refillPerSec: 1 }],
|
|
125
|
-
// provider:p1 missing
|
|
126
|
-
]);
|
|
127
|
-
const rl = createRateLimiter({ buckets });
|
|
128
|
-
expect(
|
|
129
|
-
rl.acquire(
|
|
130
|
-
[
|
|
131
|
-
{ dimension: "tenant", id: "t1" },
|
|
132
|
-
{ dimension: "provider", id: "p1" },
|
|
133
|
-
],
|
|
134
|
-
1,
|
|
135
|
-
),
|
|
136
|
-
).rejects.toBeInstanceOf(RateLimitError);
|
|
137
|
-
// tenant bucket should still have full capacity after refund.
|
|
138
|
-
const inspect = rl.inspect({ dimension: "tenant", id: "t1" });
|
|
139
|
-
expect(inspect?.available).toBeCloseTo(10, 1);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("rate-limiter — multi-dimensional", () => {
|
|
144
|
-
test("acquire sums against tenant + provider + tool buckets", async () => {
|
|
145
|
-
const buckets = new Map<string, BucketConfig>([
|
|
146
|
-
["tenant:t1", { kind: "token-bucket", capacity: 10, refillPerSec: 1 }],
|
|
147
|
-
["provider:p1", { kind: "token-bucket", capacity: 10, refillPerSec: 1 }],
|
|
148
|
-
["tool:Bash", { kind: "token-bucket", capacity: 5, refillPerSec: 1 }],
|
|
149
|
-
]);
|
|
150
|
-
const rl = createRateLimiter({ buckets });
|
|
151
|
-
await rl.acquire([
|
|
152
|
-
{ dimension: "tenant", id: "t1" },
|
|
153
|
-
{ dimension: "provider", id: "p1" },
|
|
154
|
-
{ dimension: "tool", id: "Bash" },
|
|
155
|
-
]);
|
|
156
|
-
expect(rl.inspect({ dimension: "tenant", id: "t1" })?.available).toBeCloseTo(9, 1);
|
|
157
|
-
expect(rl.inspect({ dimension: "tool", id: "Bash" })?.available).toBeCloseTo(4, 1);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe("rate-limiter — T7 load: 1000 acquirers, no starvation", () => {
|
|
162
|
-
test("1000 concurrent acquires drain in expected wall-clock time", async () => {
|
|
163
|
-
const buckets = new Map<string, BucketConfig>([
|
|
164
|
-
["tenant:t1", { kind: "token-bucket", capacity: 100, refillPerSec: 5000 }],
|
|
165
|
-
]);
|
|
166
|
-
const rl = createRateLimiter({ buckets });
|
|
167
|
-
const t0 = Date.now();
|
|
168
|
-
const promises = Array.from({ length: 1000 }, () =>
|
|
169
|
-
rl.acquire([{ dimension: "tenant", id: "t1" }], 1, { maxWaitMs: 60_000 }),
|
|
170
|
-
);
|
|
171
|
-
await Promise.all(promises);
|
|
172
|
-
const elapsed = Date.now() - t0;
|
|
173
|
-
// (1000 - 100) tokens to refill at 5000/s ≈ 180ms baseline. Allow very
|
|
174
|
-
// generous headroom for parallel-CI jitter.
|
|
175
|
-
expect(elapsed).toBeLessThan(15_000);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("rate-limiter — bucketKeyOf", () => {
|
|
180
|
-
test("formats dimension + id stably", () => {
|
|
181
|
-
const k: AcquireKey = { dimension: "provider", id: "anthropic" };
|
|
182
|
-
expect(bucketKeyOf(k)).toBe("provider:anthropic");
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
describe("rate-limiter — tokenBucketAvailable static helper", () => {
|
|
187
|
-
const config: BucketConfig = { kind: "token-bucket", capacity: 10, refillPerSec: 1 };
|
|
188
|
-
|
|
189
|
-
test("returns true when enough tokens are present without refill", () => {
|
|
190
|
-
// Fresh state at full capacity, now == lastRefill so no time elapses.
|
|
191
|
-
const state = { tokens: 5, lastRefillMs: 1_000 };
|
|
192
|
-
expect(tokenBucketAvailable(state, 5, 1_000, config)).toBe(true);
|
|
193
|
-
// Pure capacity check must not mutate the balance when no time passes.
|
|
194
|
-
expect(state.tokens).toBe(5);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("returns false when tokens are insufficient even after available refill", () => {
|
|
198
|
-
const state = { tokens: 0, lastRefillMs: 1_000 };
|
|
199
|
-
// 500ms later → 0.5 token refilled at 1/sec, still < cost of 5.
|
|
200
|
-
expect(tokenBucketAvailable(state, 5, 1_500, config)).toBe(false);
|
|
201
|
-
expect(state.tokens).toBeCloseTo(0.5, 5);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("refills (and mutates state) based on elapsed virtual time before comparing", () => {
|
|
205
|
-
const state = { tokens: 0, lastRefillMs: 1_000 };
|
|
206
|
-
// 6s later at 1/sec → 6 tokens refilled, now >= cost of 5.
|
|
207
|
-
expect(tokenBucketAvailable(state, 5, 7_000, config)).toBe(true);
|
|
208
|
-
expect(state.tokens).toBeCloseTo(6, 5);
|
|
209
|
-
expect(state.lastRefillMs).toBe(7_000);
|
|
210
|
-
});
|
|
211
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 27 — `rate-limiter`. Multi-dimensional gating between callers
|
|
3
|
-
* and downstream services. Three keyed dimensions:
|
|
4
|
-
* - **per-tenant** (gateway-server pre-handler)
|
|
5
|
-
* - **per-provider** (model-router pre-call)
|
|
6
|
-
* - **per-tool** (runtime-core pre-tool-execute, configured in spec under
|
|
7
|
-
* `tools.<Name>.rateLimit`)
|
|
8
|
-
*
|
|
9
|
-
* Two algorithms; pick per-bucket:
|
|
10
|
-
* - **token-bucket** — burst-tolerant. `capacity` tokens; refill at
|
|
11
|
-
* `refillPerSec`. Acquire blocks until enough tokens are available.
|
|
12
|
-
* - **leaky-bucket** — smoothing. Treat acquires as drops landing in a
|
|
13
|
-
* bucket that drains at `refillPerSec`. New drops queue when the
|
|
14
|
-
* bucket is full; the queue serves drops at the drain rate.
|
|
15
|
-
*
|
|
16
|
-
* `acquire(keys, cost)` evaluates each key in order and only proceeds
|
|
17
|
-
* when *every* bucket has the requested cost. The implementation never
|
|
18
|
-
* takes a partial reservation — if any bucket would block, the call
|
|
19
|
-
* either waits for the longest delay or rejects on `maxWaitMs`. This
|
|
20
|
-
* guarantees fail-closed semantics: an unknown key always denies.
|
|
21
|
-
*/
|
|
22
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
23
|
-
|
|
24
|
-
export class RateLimitError extends CrewhausError {
|
|
25
|
-
override readonly name = "RateLimitError";
|
|
26
|
-
constructor(message: string, cause?: unknown) {
|
|
27
|
-
super("config", message, cause);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type BucketKind = "token-bucket" | "leaky-bucket";
|
|
32
|
-
|
|
33
|
-
export type BucketConfig = {
|
|
34
|
-
readonly kind: BucketKind;
|
|
35
|
-
/** Maximum tokens (token-bucket) or queue depth (leaky-bucket). */
|
|
36
|
-
readonly capacity: number;
|
|
37
|
-
/** Refill rate (token-bucket) or drain rate (leaky-bucket), per second. */
|
|
38
|
-
readonly refillPerSec: number;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type AcquireKey = {
|
|
42
|
-
readonly dimension: "tenant" | "provider" | "tool";
|
|
43
|
-
readonly id: string;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export type AcquireOptions = {
|
|
47
|
-
/** How long to wait for tokens before rejecting. Defaults to 30s. */
|
|
48
|
-
readonly maxWaitMs?: number;
|
|
49
|
-
/** Override now() for tests. */
|
|
50
|
-
readonly now?: () => number;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type RateLimiterOptions = {
|
|
54
|
-
/**
|
|
55
|
-
* Per-`(dimension, id)` bucket configuration. Lookup is exact-match;
|
|
56
|
-
* unknown keys deny by default (fail-closed). The `*` id is reserved
|
|
57
|
-
* for the per-dimension default — declared explicitly when one is
|
|
58
|
-
* desired.
|
|
59
|
-
*/
|
|
60
|
-
readonly buckets: ReadonlyMap<string, BucketConfig>;
|
|
61
|
-
/** Override "now" for tests. */
|
|
62
|
-
readonly now?: () => number;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export interface RateLimiter {
|
|
66
|
-
/**
|
|
67
|
-
* Acquire `cost` tokens (default 1) from each key's bucket. Resolves
|
|
68
|
-
* once every bucket has paid out. Rejects with `RateLimitError` if
|
|
69
|
-
* any waited longer than `maxWaitMs`, or if any key is missing.
|
|
70
|
-
*/
|
|
71
|
-
acquire(keys: ReadonlyArray<AcquireKey>, cost?: number, opts?: AcquireOptions): Promise<void>;
|
|
72
|
-
/** Diagnostic snapshot of current bucket state. */
|
|
73
|
-
inspect(key: AcquireKey):
|
|
74
|
-
| {
|
|
75
|
-
config: BucketConfig;
|
|
76
|
-
available: number;
|
|
77
|
-
waitingCount: number;
|
|
78
|
-
}
|
|
79
|
-
| undefined;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Stable string key for a dimension+id pair. */
|
|
83
|
-
export function bucketKeyOf(key: AcquireKey): string {
|
|
84
|
-
return `${key.dimension}:${key.id}`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Static helper: bucket capacity check (no async waiting). */
|
|
88
|
-
export function tokenBucketAvailable(
|
|
89
|
-
state: TokenBucketState,
|
|
90
|
-
cost: number,
|
|
91
|
-
now: number,
|
|
92
|
-
config: BucketConfig,
|
|
93
|
-
): boolean {
|
|
94
|
-
refillTokenBucket(state, now, config);
|
|
95
|
-
return state.tokens >= cost;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
type TokenBucketState = {
|
|
99
|
-
tokens: number;
|
|
100
|
-
lastRefillMs: number;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
type LeakyBucketState = {
|
|
104
|
-
/** Number of tokens currently in the bucket (queued). */
|
|
105
|
-
level: number;
|
|
106
|
-
lastDrainMs: number;
|
|
107
|
-
/** FIFO queue of pending acquirers awaiting drain. */
|
|
108
|
-
queue: Array<{
|
|
109
|
-
cost: number;
|
|
110
|
-
resolve: () => void;
|
|
111
|
-
reject: (err: Error) => void;
|
|
112
|
-
timer?: ReturnType<typeof setTimeout>;
|
|
113
|
-
}>;
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
function refillTokenBucket(state: TokenBucketState, now: number, config: BucketConfig): void {
|
|
117
|
-
const elapsedSec = Math.max(0, (now - state.lastRefillMs) / 1000);
|
|
118
|
-
const refilled = elapsedSec * config.refillPerSec;
|
|
119
|
-
state.tokens = Math.min(config.capacity, state.tokens + refilled);
|
|
120
|
-
state.lastRefillMs = now;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function drainLeakyBucket(state: LeakyBucketState, now: number, config: BucketConfig): void {
|
|
124
|
-
const elapsedSec = Math.max(0, (now - state.lastDrainMs) / 1000);
|
|
125
|
-
const drained = elapsedSec * config.refillPerSec;
|
|
126
|
-
state.level = Math.max(0, state.level - drained);
|
|
127
|
-
state.lastDrainMs = now;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function createRateLimiter(opts: RateLimiterOptions): RateLimiter {
|
|
131
|
-
const buckets = opts.buckets;
|
|
132
|
-
const tokenStates = new Map<string, TokenBucketState>();
|
|
133
|
-
const leakyStates = new Map<string, LeakyBucketState>();
|
|
134
|
-
|
|
135
|
-
function getNow(callerNow?: () => number): number {
|
|
136
|
-
return (callerNow ?? opts.now ?? Date.now)();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function getOrInitTokenState(key: string, config: BucketConfig, now: number): TokenBucketState {
|
|
140
|
-
let s = tokenStates.get(key);
|
|
141
|
-
if (!s) {
|
|
142
|
-
s = { tokens: config.capacity, lastRefillMs: now };
|
|
143
|
-
tokenStates.set(key, s);
|
|
144
|
-
}
|
|
145
|
-
return s;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function getOrInitLeakyState(key: string, now: number): LeakyBucketState {
|
|
149
|
-
let s = leakyStates.get(key);
|
|
150
|
-
if (!s) {
|
|
151
|
-
s = { level: 0, lastDrainMs: now, queue: [] };
|
|
152
|
-
leakyStates.set(key, s);
|
|
153
|
-
}
|
|
154
|
-
return s;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Wait for a single bucket to allow `cost` tokens. Resolves when ready.
|
|
159
|
-
* `maxWaitMs` enforces the cap; rejects with RateLimitError on timeout.
|
|
160
|
-
*/
|
|
161
|
-
function acquireOne(
|
|
162
|
-
key: AcquireKey,
|
|
163
|
-
cost: number,
|
|
164
|
-
config: BucketConfig,
|
|
165
|
-
maxWaitMs: number,
|
|
166
|
-
nowFn: () => number,
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
const k = bucketKeyOf(key);
|
|
169
|
-
const start = nowFn();
|
|
170
|
-
|
|
171
|
-
if (config.kind === "token-bucket") {
|
|
172
|
-
return new Promise<void>((resolve, reject) => {
|
|
173
|
-
const tryAcquire = (): void => {
|
|
174
|
-
const now = nowFn();
|
|
175
|
-
const state = getOrInitTokenState(k, config, now);
|
|
176
|
-
refillTokenBucket(state, now, config);
|
|
177
|
-
if (state.tokens >= cost) {
|
|
178
|
-
state.tokens -= cost;
|
|
179
|
-
resolve();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const elapsedMs = now - start;
|
|
183
|
-
const remainingMs = maxWaitMs - elapsedMs;
|
|
184
|
-
if (remainingMs <= 0) {
|
|
185
|
-
reject(
|
|
186
|
-
new RateLimitError(
|
|
187
|
-
`rate limit exceeded for ${k}: ${cost} tokens needed, ${state.tokens.toFixed(2)} available, max wait ${maxWaitMs}ms reached`,
|
|
188
|
-
),
|
|
189
|
-
);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
// Time until enough tokens accrue
|
|
193
|
-
const deficit = cost - state.tokens;
|
|
194
|
-
const msToWait = Math.min(
|
|
195
|
-
remainingMs,
|
|
196
|
-
Math.max(10, (deficit / config.refillPerSec) * 1000),
|
|
197
|
-
);
|
|
198
|
-
setTimeout(tryAcquire, msToWait);
|
|
199
|
-
};
|
|
200
|
-
tryAcquire();
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// leaky-bucket
|
|
205
|
-
return new Promise<void>((resolve, reject) => {
|
|
206
|
-
const now = nowFn();
|
|
207
|
-
const state = getOrInitLeakyState(k, now);
|
|
208
|
-
drainLeakyBucket(state, now, config);
|
|
209
|
-
const wouldExceed = state.level + cost > config.capacity;
|
|
210
|
-
if (!wouldExceed && state.queue.length === 0) {
|
|
211
|
-
// Fast-path: no queue, fits in capacity.
|
|
212
|
-
state.level += cost;
|
|
213
|
-
resolve();
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
// Queue and rely on drain timer.
|
|
217
|
-
const entry = {
|
|
218
|
-
cost,
|
|
219
|
-
resolve,
|
|
220
|
-
reject,
|
|
221
|
-
timer: setTimeout(() => {
|
|
222
|
-
const idx = state.queue.indexOf(entry);
|
|
223
|
-
if (idx >= 0) state.queue.splice(idx, 1);
|
|
224
|
-
reject(
|
|
225
|
-
new RateLimitError(
|
|
226
|
-
`rate limit exceeded for ${k}: leaky bucket full, max wait ${maxWaitMs}ms reached`,
|
|
227
|
-
),
|
|
228
|
-
);
|
|
229
|
-
}, maxWaitMs),
|
|
230
|
-
};
|
|
231
|
-
state.queue.push(entry);
|
|
232
|
-
// Schedule a drain check.
|
|
233
|
-
const drainEveryMs = Math.max(10, 1000 / config.refillPerSec);
|
|
234
|
-
const tick = (): void => {
|
|
235
|
-
const tickNow = nowFn();
|
|
236
|
-
drainLeakyBucket(state, tickNow, config);
|
|
237
|
-
// Process as many queue entries as fit under capacity.
|
|
238
|
-
while (state.queue.length > 0) {
|
|
239
|
-
const head = state.queue[0];
|
|
240
|
-
if (!head) break;
|
|
241
|
-
if (state.level + head.cost <= config.capacity) {
|
|
242
|
-
state.queue.shift();
|
|
243
|
-
if (head.timer) clearTimeout(head.timer);
|
|
244
|
-
state.level += head.cost;
|
|
245
|
-
head.resolve();
|
|
246
|
-
} else {
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
if (state.queue.length > 0) {
|
|
251
|
-
setTimeout(tick, drainEveryMs);
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
setTimeout(tick, drainEveryMs);
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
async acquire(keys, cost = 1, callerOpts = {}): Promise<void> {
|
|
260
|
-
const maxWaitMs = callerOpts.maxWaitMs ?? 30_000;
|
|
261
|
-
const nowFn = (): number => getNow(callerOpts.now);
|
|
262
|
-
|
|
263
|
-
// Fail-closed: every key must resolve to a known bucket.
|
|
264
|
-
for (const key of keys) {
|
|
265
|
-
const k = bucketKeyOf(key);
|
|
266
|
-
if (!buckets.has(k)) {
|
|
267
|
-
// Per-dimension default lookup
|
|
268
|
-
const fallback = `${key.dimension}:*`;
|
|
269
|
-
if (!buckets.has(fallback)) {
|
|
270
|
-
throw new RateLimitError(`no bucket configured for ${k} (and no ${fallback} default)`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Acquire each in sequence so we don't double-charge a bucket on
|
|
276
|
-
// partial failure. (Parallel acquisition would require two-phase
|
|
277
|
-
// commit; sequential is simpler and the bucket counts stay correct.)
|
|
278
|
-
const acquired: AcquireKey[] = [];
|
|
279
|
-
try {
|
|
280
|
-
for (const key of keys) {
|
|
281
|
-
const k = bucketKeyOf(key);
|
|
282
|
-
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
283
|
-
if (!config) throw new RateLimitError(`no bucket for ${k}`);
|
|
284
|
-
await acquireOne(key, cost, config, maxWaitMs, nowFn);
|
|
285
|
-
acquired.push(key);
|
|
286
|
-
}
|
|
287
|
-
} catch (err) {
|
|
288
|
-
// Refund any successful acquisitions so partial failures don't drain buckets.
|
|
289
|
-
const now = nowFn();
|
|
290
|
-
for (const key of acquired) {
|
|
291
|
-
const k = bucketKeyOf(key);
|
|
292
|
-
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
293
|
-
if (!config) continue;
|
|
294
|
-
if (config.kind === "token-bucket") {
|
|
295
|
-
const state = tokenStates.get(k);
|
|
296
|
-
if (state) {
|
|
297
|
-
state.tokens = Math.min(config.capacity, state.tokens + cost);
|
|
298
|
-
state.lastRefillMs = now;
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
const state = leakyStates.get(k);
|
|
302
|
-
if (state) {
|
|
303
|
-
state.level = Math.max(0, state.level - cost);
|
|
304
|
-
state.lastDrainMs = now;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
throw err;
|
|
309
|
-
}
|
|
310
|
-
},
|
|
311
|
-
|
|
312
|
-
inspect(key): { config: BucketConfig; available: number; waitingCount: number } | undefined {
|
|
313
|
-
const k = bucketKeyOf(key);
|
|
314
|
-
const config = buckets.get(k) ?? buckets.get(`${key.dimension}:*`);
|
|
315
|
-
if (!config) return undefined;
|
|
316
|
-
const now = (opts.now ?? Date.now)();
|
|
317
|
-
if (config.kind === "token-bucket") {
|
|
318
|
-
const state = tokenStates.get(k);
|
|
319
|
-
if (!state) {
|
|
320
|
-
return { config, available: config.capacity, waitingCount: 0 };
|
|
321
|
-
}
|
|
322
|
-
refillTokenBucket(state, now, config);
|
|
323
|
-
return { config, available: state.tokens, waitingCount: 0 };
|
|
324
|
-
}
|
|
325
|
-
const state = leakyStates.get(k);
|
|
326
|
-
if (!state) {
|
|
327
|
-
return { config, available: config.capacity, waitingCount: 0 };
|
|
328
|
-
}
|
|
329
|
-
drainLeakyBucket(state, now, config);
|
|
330
|
-
return {
|
|
331
|
-
config,
|
|
332
|
-
available: Math.max(0, config.capacity - state.level),
|
|
333
|
-
waitingCount: state.queue.length,
|
|
334
|
-
};
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
}
|