@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ export { RedisTokenBucket, } from "./RedisTokenBucket.js";
2
+ export { RedisTokenBucketStrategy, } from "./RedisTokenBucketStrategy.js";
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.0",
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": "workspace:*"
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
+ }