@adaptive-concurrency/redis 0.1.0 → 0.1.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/dist/RedisTokenBucket.d.ts +76 -0
- package/dist/RedisTokenBucket.d.ts.map +1 -0
- package/dist/RedisTokenBucket.js +249 -0
- package/dist/RedisTokenBucketStrategy.d.ts +100 -0
- package/dist/RedisTokenBucketStrategy.d.ts.map +1 -0
- package/dist/RedisTokenBucketStrategy.js +104 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +8 -7
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Redis client surface used for token-bucket operations.
|
|
3
|
+
*
|
|
4
|
+
* Implementations only need to expose `SCRIPT LOAD` + `EVALSHA`, which the
|
|
5
|
+
* standard `redis` (node-redis) client already provides via `scriptLoad` and
|
|
6
|
+
* `evalSha` with the same signatures used here. This minimal interface lets
|
|
7
|
+
* this package work with any Redis client without a hard dependency on a
|
|
8
|
+
* specific one.
|
|
9
|
+
*/
|
|
10
|
+
export type RedisTokenBucketClient = {
|
|
11
|
+
scriptLoad(script: string): Promise<string>;
|
|
12
|
+
evalSha(sha: string, options: {
|
|
13
|
+
keys: string[];
|
|
14
|
+
arguments: string[];
|
|
15
|
+
}): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type RedisTokenBucketConfig = {
|
|
18
|
+
/** Redis key prefix, e.g., "emr-ratelimit:axiscare-api". */
|
|
19
|
+
keyPrefix: string;
|
|
20
|
+
/** Max tokens in the bucket. */
|
|
21
|
+
maxTokens: number;
|
|
22
|
+
/** Refill interval in ms (time to refill an empty bucket back to maxTokens). */
|
|
23
|
+
refillIntervalMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* Optional callback invoked when a Redis call fails. The bucket degrades
|
|
26
|
+
* gracefully on Redis failures (allowing the request through), so this is
|
|
27
|
+
* how callers can plug in their own logger/metrics for visibility.
|
|
28
|
+
*
|
|
29
|
+
* `tryAcquire` and `refund` do not await the callback before returning,
|
|
30
|
+
* so a slow async logger can't add latency to the request path.
|
|
31
|
+
* Synchronous throws and rejected promises returned from the callback
|
|
32
|
+
* are both swallowed — the callback is observability-only and must not
|
|
33
|
+
* be able to break the bucket's "always degrades gracefully" contract.
|
|
34
|
+
*/
|
|
35
|
+
onError?: (info: {
|
|
36
|
+
keyPrefix: string;
|
|
37
|
+
key: string;
|
|
38
|
+
error: unknown;
|
|
39
|
+
}) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Source of the current time, in epoch milliseconds, passed to the acquire
|
|
42
|
+
* script as `nowMs`. Defaults to `Date.now`.
|
|
43
|
+
*
|
|
44
|
+
* This MUST be a wall-clock source (not a monotonic one like
|
|
45
|
+
* `performance.now`): the bucket is shared across processes, so every
|
|
46
|
+
* participant has to agree on what "now" means for the refill math to line
|
|
47
|
+
* up. The seam exists primarily so tests can drive refill/wait-time logic
|
|
48
|
+
* deterministically.
|
|
49
|
+
*/
|
|
50
|
+
clock?: () => number;
|
|
51
|
+
};
|
|
52
|
+
export type AcquireResult = {
|
|
53
|
+
acquired: true;
|
|
54
|
+
} | {
|
|
55
|
+
acquired: false;
|
|
56
|
+
waitMs: number;
|
|
57
|
+
};
|
|
58
|
+
export declare class RedisTokenBucket {
|
|
59
|
+
#private;
|
|
60
|
+
constructor(redisClient: RedisTokenBucketClient, config: RedisTokenBucketConfig);
|
|
61
|
+
/**
|
|
62
|
+
* Attempt to acquire a token for the given key.
|
|
63
|
+
* If Redis is unavailable, degrades gracefully by allowing the request.
|
|
64
|
+
*/
|
|
65
|
+
tryAcquire(key: string): Promise<AcquireResult>;
|
|
66
|
+
/**
|
|
67
|
+
* Refund a previously-acquired token back to the bucket. Use this when a
|
|
68
|
+
* downstream gate rejects a request after `tryAcquire` already consumed a
|
|
69
|
+
* token, so the configured rate is preserved.
|
|
70
|
+
*
|
|
71
|
+
* If Redis is unavailable, degrades gracefully: the refund is silently
|
|
72
|
+
* dropped (the token will refill naturally over `refillIntervalMs`).
|
|
73
|
+
*/
|
|
74
|
+
refund(key: string): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=RedisTokenBucket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RedisTokenBucket.d.ts","sourceRoot":"","sources":["../src/RedisTokenBucket.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,OAAO,CACL,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAA;KAAE,GAC/C,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB,CAAC;AAqHF,MAAM,MAAM,sBAAsB,GAAG;IACnC,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E;;;;;;;;;OASG;IACH,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAClB;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC,qBAAa,gBAAgB;;gBAOzB,WAAW,EAAE,sBAAsB,EACnC,MAAM,EAAE,sBAAsB;IAsBhC;;;OAGG;IACG,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAyBrD;;;;;;;OAOG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAqCzC"}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lua script for atomic token bucket acquire.
|
|
3
|
+
*
|
|
4
|
+
* Loaded once per process via SCRIPT LOAD; executions use EVALSHA so the
|
|
5
|
+
* script source is not sent on every tryAcquire (see RedisTokenBucket).
|
|
6
|
+
*
|
|
7
|
+
* KEYS[1] = bucket key (e.g., "myratelimit:tenant-1234")
|
|
8
|
+
* ARGV[1] = maxTokens
|
|
9
|
+
* ARGV[2] = refillIntervalMs
|
|
10
|
+
* ARGV[3] = current time in ms
|
|
11
|
+
*
|
|
12
|
+
* Note: We expire the key after refillIntervalMs. Because refillIntervalMs is
|
|
13
|
+
* the time it takes to refill an empty bucket to maxTokens, so we KNOW that a
|
|
14
|
+
* bucket that's been untouched for refillIntervalMs is full. Therefore, it's
|
|
15
|
+
* safe to expire the key because it'll get recreated with a full bucket.
|
|
16
|
+
*
|
|
17
|
+
* Allowing the caller to provide an expiry time less than refillIntervalMs
|
|
18
|
+
* would risk the following scenario: a key untouched for expiry time ms has
|
|
19
|
+
* _fewer_ than a full bucket worth of tokens, then expires, then gets recreated
|
|
20
|
+
* with a full bucket, allowing the caller to exceed the intended rate limit.
|
|
21
|
+
*
|
|
22
|
+
* The code could compute a _dynamic_ expiry time based on the number of tokens
|
|
23
|
+
* in the bucket (i.e., at current token count, how long until the bucket is
|
|
24
|
+
* full?). This isn't worth the complexity though, nor is it an unambiguous win:
|
|
25
|
+
* it reclaims Redis memory faster, but slows down some acquires as the bucket
|
|
26
|
+
* key needs to be recreated.
|
|
27
|
+
*
|
|
28
|
+
* Returns:
|
|
29
|
+
* - [1, 0] if token acquired successfully
|
|
30
|
+
* - [0, waitTimeMs] if no tokens available, caller should wait waitTimeMs
|
|
31
|
+
*/
|
|
32
|
+
const TOKEN_BUCKET_LUA = `
|
|
33
|
+
local key = KEYS[1]
|
|
34
|
+
local maxTokens = tonumber(ARGV[1])
|
|
35
|
+
local refillIntervalMs = tonumber(ARGV[2])
|
|
36
|
+
local nowMs = tonumber(ARGV[3])
|
|
37
|
+
|
|
38
|
+
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefillMs')
|
|
39
|
+
local tokens = tonumber(bucket[1])
|
|
40
|
+
local lastRefillMs = tonumber(bucket[2])
|
|
41
|
+
|
|
42
|
+
-- Initialize bucket if it doesn't exist
|
|
43
|
+
if tokens == nil or lastRefillMs == nil then
|
|
44
|
+
tokens = maxTokens - 1
|
|
45
|
+
redis.call('HMSET', key, 'tokens', tokens, 'lastRefillMs', nowMs)
|
|
46
|
+
redis.call('PEXPIRE', key, refillIntervalMs)
|
|
47
|
+
return {1, 0}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
-- Calculate refill: proportional tokens based on elapsed time
|
|
51
|
+
local elapsedMs = nowMs - lastRefillMs
|
|
52
|
+
local tokensToAdd = math.floor(elapsedMs * maxTokens / refillIntervalMs)
|
|
53
|
+
|
|
54
|
+
if tokensToAdd > 0 then
|
|
55
|
+
tokens = math.min(maxTokens, tokens + tokensToAdd)
|
|
56
|
+
lastRefillMs = lastRefillMs + math.floor(tokensToAdd * refillIntervalMs / maxTokens)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Try to consume a token
|
|
60
|
+
if tokens >= 1 then
|
|
61
|
+
tokens = tokens - 1
|
|
62
|
+
redis.call('HMSET', key, 'tokens', tokens, 'lastRefillMs', lastRefillMs)
|
|
63
|
+
redis.call('PEXPIRE', key, refillIntervalMs)
|
|
64
|
+
return {1, 0}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
-- No tokens: compute wait time until next token refills
|
|
68
|
+
local msPerToken = refillIntervalMs / maxTokens
|
|
69
|
+
local waitMs = math.ceil(msPerToken - (nowMs - lastRefillMs))
|
|
70
|
+
if waitMs < 0 then waitMs = 0 end
|
|
71
|
+
|
|
72
|
+
redis.call('PEXPIRE', key, refillIntervalMs)
|
|
73
|
+
return {0, waitMs}
|
|
74
|
+
`;
|
|
75
|
+
/**
|
|
76
|
+
* Lua script that refunds a previously-acquired token to the bucket. Used
|
|
77
|
+
* when a downstream gate rejects a request whose token has already been
|
|
78
|
+
* consumed (see RedisTokenBucketStrategy's bucket-first fallback path), so
|
|
79
|
+
* that the token isn't lost and the configured rate is preserved.
|
|
80
|
+
*
|
|
81
|
+
* Refund increments the token count by 1, capped at maxTokens. If the bucket
|
|
82
|
+
* key doesn't exist, the script is a no-op: an absent key implicitly
|
|
83
|
+
* represents a full bucket on next access (see TOKEN_BUCKET_LUA), so there's
|
|
84
|
+
* nothing to put back. We refresh the PEXPIRE on a successful refund so the
|
|
85
|
+
* key continues to live at least one full refill interval — same invariant
|
|
86
|
+
* the acquire script relies on.
|
|
87
|
+
*
|
|
88
|
+
* KEYS[1] = bucket key
|
|
89
|
+
* ARGV[1] = maxTokens
|
|
90
|
+
* ARGV[2] = refillIntervalMs
|
|
91
|
+
*
|
|
92
|
+
* Returns:
|
|
93
|
+
* - 1 if a token was refunded
|
|
94
|
+
* - 0 if the bucket was already at maxTokens or didn't exist
|
|
95
|
+
*/
|
|
96
|
+
const TOKEN_BUCKET_REFUND_LUA = `
|
|
97
|
+
local key = KEYS[1]
|
|
98
|
+
local maxTokens = tonumber(ARGV[1])
|
|
99
|
+
local refillIntervalMs = tonumber(ARGV[2])
|
|
100
|
+
|
|
101
|
+
local tokens = tonumber(redis.call('HGET', key, 'tokens'))
|
|
102
|
+
if tokens == nil then
|
|
103
|
+
return 0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if tokens < maxTokens then
|
|
107
|
+
redis.call('HINCRBY', key, 'tokens', 1)
|
|
108
|
+
redis.call('PEXPIRE', key, refillIntervalMs)
|
|
109
|
+
return 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return 0
|
|
113
|
+
`;
|
|
114
|
+
export class RedisTokenBucket {
|
|
115
|
+
#config;
|
|
116
|
+
#clock;
|
|
117
|
+
#acquireTokenScript;
|
|
118
|
+
#refundTokenScript;
|
|
119
|
+
constructor(redisClient, config) {
|
|
120
|
+
if (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0) {
|
|
121
|
+
throw new RangeError("maxTokens must be a positive safe integer");
|
|
122
|
+
}
|
|
123
|
+
if (!Number.isSafeInteger(config.refillIntervalMs) ||
|
|
124
|
+
config.refillIntervalMs <= 0) {
|
|
125
|
+
throw new RangeError("refillIntervalMs must be a positive safe integer");
|
|
126
|
+
}
|
|
127
|
+
this.#config = config;
|
|
128
|
+
this.#clock = config.clock ?? Date.now;
|
|
129
|
+
this.#acquireTokenScript = new RedisScript(redisClient, TOKEN_BUCKET_LUA);
|
|
130
|
+
this.#refundTokenScript = new RedisScript(redisClient, TOKEN_BUCKET_REFUND_LUA);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Attempt to acquire a token for the given key.
|
|
134
|
+
* If Redis is unavailable, degrades gracefully by allowing the request.
|
|
135
|
+
*/
|
|
136
|
+
async tryAcquire(key) {
|
|
137
|
+
const redisKey = `${this.#config.keyPrefix}:${key}`;
|
|
138
|
+
try {
|
|
139
|
+
const result = await this.#acquireTokenScript.evaluate({
|
|
140
|
+
keys: [redisKey],
|
|
141
|
+
arguments: [
|
|
142
|
+
String(this.#config.maxTokens),
|
|
143
|
+
String(this.#config.refillIntervalMs),
|
|
144
|
+
String(this.#clock()),
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
if (result[0] === 1) {
|
|
148
|
+
return { acquired: true };
|
|
149
|
+
}
|
|
150
|
+
return { acquired: false, waitMs: result[1] ?? 0 };
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
this.#fireOnError(key, error);
|
|
154
|
+
// Degrade gracefully: allow the request through.
|
|
155
|
+
return { acquired: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Refund a previously-acquired token back to the bucket. Use this when a
|
|
160
|
+
* downstream gate rejects a request after `tryAcquire` already consumed a
|
|
161
|
+
* token, so the configured rate is preserved.
|
|
162
|
+
*
|
|
163
|
+
* If Redis is unavailable, degrades gracefully: the refund is silently
|
|
164
|
+
* dropped (the token will refill naturally over `refillIntervalMs`).
|
|
165
|
+
*/
|
|
166
|
+
async refund(key) {
|
|
167
|
+
const redisKey = `${this.#config.keyPrefix}:${key}`;
|
|
168
|
+
try {
|
|
169
|
+
await this.#refundTokenScript.evaluate({
|
|
170
|
+
keys: [redisKey],
|
|
171
|
+
arguments: [
|
|
172
|
+
String(this.#config.maxTokens),
|
|
173
|
+
String(this.#config.refillIntervalMs),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
this.#fireOnError(key, error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Invoke the user-supplied `onError` callback fire-and-forget style:
|
|
183
|
+
*
|
|
184
|
+
* - We do **not** await it, so a slow async logger can't extend the
|
|
185
|
+
* latency of the request path. Synchronous side effects of the
|
|
186
|
+
* callback (the typical `errors.push(...)` shape) still happen before
|
|
187
|
+
* this method returns, since the call itself is synchronous.
|
|
188
|
+
* - If the callback throws synchronously OR returns a rejected promise,
|
|
189
|
+
* the resulting rejection is swallowed inside `fireAndForget` so it
|
|
190
|
+
* can't surface as an unhandled rejection.
|
|
191
|
+
*
|
|
192
|
+
* Both guarantees are contractually important: the bucket promises to
|
|
193
|
+
* degrade gracefully on Redis errors, and a misbehaving observer must
|
|
194
|
+
* not be able to break that promise.
|
|
195
|
+
*/
|
|
196
|
+
#fireOnError(key, error) {
|
|
197
|
+
const onError = this.#config.onError;
|
|
198
|
+
if (!onError)
|
|
199
|
+
return;
|
|
200
|
+
const keyPrefix = this.#config.keyPrefix;
|
|
201
|
+
void fireAndForget(() => onError({ keyPrefix, key, error }));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
class RedisScript {
|
|
205
|
+
#shaPromise; // undefined until first evaluate
|
|
206
|
+
#redisClient;
|
|
207
|
+
#lua;
|
|
208
|
+
constructor(redisClient, lua) {
|
|
209
|
+
this.#redisClient = redisClient;
|
|
210
|
+
this.#lua = lua;
|
|
211
|
+
}
|
|
212
|
+
async #ensureLoadedAndGetSha() {
|
|
213
|
+
if (this.#shaPromise === undefined) {
|
|
214
|
+
this.#shaPromise = this.#redisClient
|
|
215
|
+
.scriptLoad(this.#lua)
|
|
216
|
+
.catch((error) => {
|
|
217
|
+
this.#shaPromise = undefined;
|
|
218
|
+
throw error;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return await this.#shaPromise;
|
|
222
|
+
}
|
|
223
|
+
async evaluate(options) {
|
|
224
|
+
const sha = await this.#ensureLoadedAndGetSha();
|
|
225
|
+
try {
|
|
226
|
+
return (await this.#redisClient.evalSha(sha, options));
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
// Reload the script if deleted or removed from redis cache.
|
|
230
|
+
if (isNoScriptError(error)) {
|
|
231
|
+
this.#shaPromise = undefined;
|
|
232
|
+
const newSha = await this.#ensureLoadedAndGetSha();
|
|
233
|
+
return (await this.#redisClient.evalSha(newSha, options));
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function isNoScriptError(error) {
|
|
240
|
+
return error instanceof Error && error.message.startsWith("NOSCRIPT");
|
|
241
|
+
}
|
|
242
|
+
async function fireAndForget(fn) {
|
|
243
|
+
try {
|
|
244
|
+
await fn();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Ignore errors deliberately.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { AllotmentReservation, type AcquireStrategy, type LimiterState } from "adaptive-concurrency";
|
|
2
|
+
import type { RedisTokenBucket } from "./RedisTokenBucket.js";
|
|
3
|
+
/**
|
|
4
|
+
* Higher-level acquire strategy that layers a distributed Redis token-bucket
|
|
5
|
+
* limit on top of any inner {@link AcquireStrategy} (e.g. `SemaphoreStrategy`,
|
|
6
|
+
* `PartitionedStrategy`). A request is admitted only when **both**:
|
|
7
|
+
*
|
|
8
|
+
* 1. The inner strategy reserves an allotment (its own local rules), and
|
|
9
|
+
* 2. The Redis token bucket grants a token (a fleet-wide rate limit of tokens
|
|
10
|
+
* per `refillIntervalMs` shared across processes).
|
|
11
|
+
*
|
|
12
|
+
* **Ordering.** The inner runs first. Reservation is contractually
|
|
13
|
+
* side-effect free beyond admission bookkeeping, so a bucket denial cancels
|
|
14
|
+
* the inner reservation cleanly with no observable trace (no metric pollution,
|
|
15
|
+
* no waiter notifications). Reservation is also typically synchronous and
|
|
16
|
+
* cheap, so cheap-rejecting locally before paying the Redis round trip is a
|
|
17
|
+
* win on the rejection path.
|
|
18
|
+
*
|
|
19
|
+
* **Caveats.** Between the inner reserve and the bucket reply the inner
|
|
20
|
+
* strategy is briefly "more full" than it really is. Concurrent acquires can
|
|
21
|
+
* be falsely rejected by the inner during that ~RTT window — most visible at
|
|
22
|
+
* small inner limits. This is inherent to the inner-first ordering.
|
|
23
|
+
*
|
|
24
|
+
* **Failure handling.**
|
|
25
|
+
*
|
|
26
|
+
* - If Redis is unavailable, the underlying {@link RedisTokenBucket} degrades
|
|
27
|
+
* gracefully (treats `tryAcquire` as granted, silently drops `refund`), so
|
|
28
|
+
* this strategy effectively falls back to inner-only behavior.
|
|
29
|
+
*
|
|
30
|
+
* - **Inner `cancel()` throws** (after the bucket has denied, or the outer
|
|
31
|
+
* reservation is later cancelled): the error is swallowed and surfaced via
|
|
32
|
+
* `onReservationError({ phase: "cancel", ... })`. The strategy still
|
|
33
|
+
* returns `undefined` (or completes the cancel) — matching `Limiter`'s
|
|
34
|
+
* defensive release-error handling so a flaky inner can't turn a bucket
|
|
35
|
+
* denial into a thrown `acquire()`. The inner's reservation is leaked
|
|
36
|
+
* (decremented permits/inflight that never get restored).
|
|
37
|
+
*
|
|
38
|
+
* - **Inner `commit()` throws** (during the outer reservation's `commit()`):
|
|
39
|
+
* the bucket token is refunded (always safe — bucket is rate-based), the
|
|
40
|
+
* error is surfaced via `onReservationError({ phase: "commit", ... })`,
|
|
41
|
+
* and the error is **re-thrown**. Unlike a cancel-throw, we hadn't yet
|
|
42
|
+
* decided to reject, so quietly swallowing would hide a genuine
|
|
43
|
+
* inner-strategy fault; propagation makes it visible to the caller. The
|
|
44
|
+
* inner's reservation is in an indeterminate state and may be leaked.
|
|
45
|
+
*
|
|
46
|
+
* Limit change and release events are forwarded to the inner strategy.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Information passed to {@link RedisTokenBucketStrategyOptions.onReservationError}
|
|
50
|
+
* when the inner's reservation transition fails.
|
|
51
|
+
*/
|
|
52
|
+
export type ReservationErrorInfo<ContextT> = {
|
|
53
|
+
context: ContextT;
|
|
54
|
+
/**
|
|
55
|
+
* Which transition threw:
|
|
56
|
+
* - `"cancel"`: the inner's `reservation.cancel()` threw while undoing the
|
|
57
|
+
* speculative reservation (because the bucket denied, or because the
|
|
58
|
+
* outer reservation was cancelled). The strategy continues; the inner's
|
|
59
|
+
* reservation is leaked.
|
|
60
|
+
* - `"commit"`: the inner's `reservation.commit()` threw while finalizing
|
|
61
|
+
* the outer reservation. The bucket token has been refunded; the error
|
|
62
|
+
* is re-thrown to the caller after this hook returns.
|
|
63
|
+
*/
|
|
64
|
+
phase: "cancel" | "commit";
|
|
65
|
+
error: unknown;
|
|
66
|
+
};
|
|
67
|
+
export declare class RedisTokenBucketStrategy<ContextT> {
|
|
68
|
+
#private;
|
|
69
|
+
constructor(options: {
|
|
70
|
+
/** Token bucket used as the second gate. */
|
|
71
|
+
bucket: RedisTokenBucket;
|
|
72
|
+
/**
|
|
73
|
+
* Inner acquire strategy. Consulted *before* the bucket: its reservation
|
|
74
|
+
* is committed when the bucket grants and cancelled when the bucket
|
|
75
|
+
* denies. Must be a two-phase {@link AcquireStrategy}.
|
|
76
|
+
*/
|
|
77
|
+
inner: AcquireStrategy<ContextT>;
|
|
78
|
+
/**
|
|
79
|
+
* Derives the bucket sub-key from the request context. Use this to maintain
|
|
80
|
+
* separate buckets per tenant, route, etc. When omitted, all acquires share
|
|
81
|
+
* a single `"default"` bucket.
|
|
82
|
+
*/
|
|
83
|
+
keyResolver?: (context: ContextT) => string;
|
|
84
|
+
/**
|
|
85
|
+
* Invoked when the inner's `reservation.cancel()` or `reservation.commit()`
|
|
86
|
+
* throws. Lets callers wire up logging/metrics. Behavior per phase:
|
|
87
|
+
*
|
|
88
|
+
* - `"cancel"`: error is swallowed; the strategy continues with its
|
|
89
|
+
* rejection (the bucket already denied, or the outer cancelled). The
|
|
90
|
+
* inner's reservation is leaked.
|
|
91
|
+
* - `"commit"`: bucket token is refunded, then the error is re-thrown so
|
|
92
|
+
* the caller can see the strategy fault.
|
|
93
|
+
*/
|
|
94
|
+
onReservationError?: (info: ReservationErrorInfo<ContextT>) => void;
|
|
95
|
+
});
|
|
96
|
+
tryReserveAllotment(context: ContextT, state: LimiterState): Promise<AllotmentReservation | undefined>;
|
|
97
|
+
onAllotmentReleased(context: ContextT): ReturnType<AcquireStrategy<ContextT>["onAllotmentReleased"]>;
|
|
98
|
+
onLimitChanged(oldLimit: number, newLimit: number): void;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=RedisTokenBucketStrategy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RedisTokenBucketStrategy.d.ts","sourceRoot":"","sources":["../src/RedisTokenBucketStrategy.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,KAAK,eAAe,EACpB,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH;;;GAGG;AACH,MAAM,MAAM,oBAAoB,CAAC,QAAQ,IAAI;IAC3C,OAAO,EAAE,QAAQ,CAAC;IAClB;;;;;;;;;OASG;IACH,KAAK,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC3B,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,qBAAa,wBAAwB,CAAC,QAAQ;;gBAShC,OAAO,EAAE;QACnB,4CAA4C;QAC5C,MAAM,EAAE,gBAAgB,CAAC;QACzB;;;;WAIG;QACH,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAC;QACjC;;;;WAIG;QACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,KAAK,MAAM,CAAC;QAC5C;;;;;;;;;WASG;QACH,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,oBAAoB,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;KACrE;IAOK,mBAAmB,CACvB,OAAO,EAAE,QAAQ,EACjB,KAAK,EAAE,YAAY,GAClB,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IAsD5C,mBAAmB,CACjB,OAAO,EAAE,QAAQ,GAChB,UAAU,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,qBAAqB,CAAC,CAAC;IAI/D,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;CAqCzD"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AllotmentReservation, } from "adaptive-concurrency";
|
|
2
|
+
export class RedisTokenBucketStrategy {
|
|
3
|
+
#bucket;
|
|
4
|
+
#keyResolver;
|
|
5
|
+
#onReservationError;
|
|
6
|
+
#inner;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.#bucket = options.bucket;
|
|
9
|
+
this.#inner = options.inner;
|
|
10
|
+
this.#keyResolver = options.keyResolver ?? defaultKeyResolver;
|
|
11
|
+
this.#onReservationError = options.onReservationError;
|
|
12
|
+
}
|
|
13
|
+
async tryReserveAllotment(context, state) {
|
|
14
|
+
const key = this.#keyResolver(context);
|
|
15
|
+
const reservation = await this.#inner.tryReserveAllotment(context, state);
|
|
16
|
+
if (!reservation) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const result = await this.#bucket.tryAcquire(key);
|
|
20
|
+
if (!result.acquired) {
|
|
21
|
+
try {
|
|
22
|
+
await reservation.cancel();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
await this.#fireReservationError({ context, phase: "cancel", error });
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
// Both gates granted. Return a reservation that, on commit, commits the
|
|
30
|
+
// inner; on cancel, cancels the inner AND refunds the bucket token. The
|
|
31
|
+
// bucket-refund branch matters for nested composition (some outer
|
|
32
|
+
// strategy that wraps this one and may call cancel after both gates have
|
|
33
|
+
// already speculatively granted).
|
|
34
|
+
return new AllotmentReservation(async () => {
|
|
35
|
+
try {
|
|
36
|
+
await reservation.commit();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// Inner is in an indeterminate state per the AllotmentReservation
|
|
40
|
+
// contract. The bucket token is purely ours to manage and refund is
|
|
41
|
+
// always safe (rate-based, not absolute), so refund it. We propagate
|
|
42
|
+
// the error rather than swallowing it because (unlike a cancel-throw
|
|
43
|
+
// on bucket-denied) the strategy genuinely failed to finalize and
|
|
44
|
+
// that's worth surfacing.
|
|
45
|
+
//
|
|
46
|
+
// Both side effects below are best-effort and isolated so a throw
|
|
47
|
+
// from either can't shadow `error` — the inner-commit failure is what
|
|
48
|
+
// the caller asked us to surface and we must guarantee it propagates.
|
|
49
|
+
await this.#refundQuietly(key);
|
|
50
|
+
await this.#fireReservationError({ context, phase: "commit", error });
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}, async () => {
|
|
54
|
+
try {
|
|
55
|
+
await reservation.cancel();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
await this.#fireReservationError({ context, phase: "cancel", error });
|
|
59
|
+
}
|
|
60
|
+
await this.#refundQuietly(key);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
onAllotmentReleased(context) {
|
|
64
|
+
return this.#inner.onAllotmentReleased(context);
|
|
65
|
+
}
|
|
66
|
+
onLimitChanged(oldLimit, newLimit) {
|
|
67
|
+
this.#inner.onLimitChanged?.(oldLimit, newLimit);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Fire {@link #onReservationError} without ever throwing. The hook is
|
|
71
|
+
* observability-only; a throw from a misbehaving observer would otherwise
|
|
72
|
+
* shadow the genuinely interesting error (a bucket-denial cancel-throw,
|
|
73
|
+
* or an inner-commit failure that the commit-throw branch is contractually
|
|
74
|
+
* required to re-throw).
|
|
75
|
+
*/
|
|
76
|
+
async #fireReservationError(info) {
|
|
77
|
+
try {
|
|
78
|
+
// wait in case the callback is async
|
|
79
|
+
await this.#onReservationError?.(info);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Swallowed deliberately.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Refund the bucket token without ever throwing. {@link RedisTokenBucket}
|
|
87
|
+
* already degrades gracefully on Redis errors, but we still isolate the
|
|
88
|
+
* call here so neither a future change in the bucket's contract nor an
|
|
89
|
+
* unexpected synchronous throw can leak out of the cancel/commit-failure
|
|
90
|
+
* paths and shadow more important errors.
|
|
91
|
+
*/
|
|
92
|
+
async #refundQuietly(key) {
|
|
93
|
+
try {
|
|
94
|
+
await this.#bucket.refund(key);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// The un-refunded token will be restored by the next natural
|
|
98
|
+
// refill, so the long-run rate is preserved.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function defaultKeyResolver() {
|
|
103
|
+
return "default";
|
|
104
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { RedisTokenBucket, type AcquireResult, type RedisTokenBucketClient, type RedisTokenBucketConfig, } from "./RedisTokenBucket.js";
|
|
2
|
+
export { RedisTokenBucketStrategy, type ReservationErrorInfo, } from "./RedisTokenBucketStrategy.js";
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,wBAAwB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,+BAA+B,CAAC"}
|
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adaptive-concurrency/redis",
|
|
3
3
|
"description": "Redis-backed strategies for adaptive-concurrency (e.g. distributed token-bucket rate limiting).",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -12,16 +12,17 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"dist"
|
|
14
14
|
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
17
|
-
"test": "node --import tsx --test \"src/**/*.test.ts\""
|
|
18
|
-
},
|
|
19
15
|
"peerDependencies": {
|
|
20
|
-
"adaptive-concurrency": "
|
|
16
|
+
"adaptive-concurrency": "0.13.0"
|
|
21
17
|
},
|
|
22
18
|
"devDependencies": {
|
|
23
19
|
"@types/node": "^22.0.0",
|
|
20
|
+
"ioredis-mock": "^8.13.1",
|
|
24
21
|
"tsx": "^4.19.0",
|
|
25
22
|
"typescript": "^6.0.2"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"test": "node --import tsx --test \"src/**/*.test.ts\""
|
|
26
27
|
}
|
|
27
|
-
}
|
|
28
|
+
}
|