@ayepi/rate 0.3.0 → 0.4.0

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/README.md CHANGED
@@ -97,6 +97,16 @@ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-buc
97
97
  const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
98
98
  ```
99
99
 
100
+ **Two-tier limiting.** Add a `groupLimit` (a default, or per task) and a task must clear both
101
+ the global limit **and** its `group`'s limit — e.g. a global API cap plus a per-user/per-model
102
+ cap. A hot group is skipped (not blocking others), groups needn't be known up front (buckets
103
+ auto-evict when idle), and a shared `store`/`groupStore` (Redis) enforces both tiers across a fleet:
104
+
105
+ ```ts
106
+ rateLimitedDoer({ limit: 1000, window: 60_000, groupLimit: 50 })
107
+ .do(() => callModel(req), { group: `${userId}:${modelId}`, groupLimit: 200 }) // per-request override
108
+ ```
109
+
100
110
  ## Algorithms
101
111
 
102
112
  - `fixed-window` (default) — simple counter per window.
@@ -49,15 +49,26 @@ Memcached, …); only `consume` is required.
49
49
  ### `memoryStore` (default, bundled)
50
50
 
51
51
  ```ts
52
- function memoryStore(): RateLimitStore
52
+ function memoryStore(opts?: MemoryStoreOptions): RateLimitStore
53
+
54
+ interface MemoryStoreOptions {
55
+ /** Sweep expired/idle entries once every this many `consume` calls (default 1000). */
56
+ readonly sweepEvery?: number;
57
+ /** Drop an idle token-bucket after this long with no activity (ms, default 10 min). */
58
+ readonly idleMs?: number;
59
+ }
53
60
  ```
54
61
 
55
62
  An in-process store implementing all three algorithms, zero dependencies. It is the
56
- default when no `store` is passed. Expired counters and idle token buckets are swept
57
- lazily (amortized: a sweep runs once per ~1000 `consume` calls; idle buckets are dropped
58
- after ~10 minutes of inactivity since they refill to full anyway). **Single process only**
59
- — two server instances each get their own independent budget. Use the Redis store to share
60
- a limit across pods.
63
+ default when no `store` is passed. **Single process only** — two server instances each get
64
+ their own independent budget; use the Redis store to share a limit across pods.
65
+
66
+ **Bounded memory for dynamic keys.** Entries are swept lazily, so an unbounded, not-known-in-advance
67
+ key space — e.g. per-user/per-model rate-limit **groups** (see `rateLimitedDoer` below) — doesn't grow
68
+ without bound: a windowed counter is dropped once its window has elapsed, and an idle `token-bucket`
69
+ after `idleMs` (default 10 min; they refill to full anyway). Active keys live long enough to enforce
70
+ the limit; idle ones go away. For high churn, lower `sweepEvery` (tighter memory, a little more scan
71
+ cost) and/or `idleMs`: `memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
61
72
 
62
73
  ### `redisStore` — `@ayepi/rate/redis`
63
74
 
@@ -111,11 +122,28 @@ distributed store (verified by the integration test). `reset(key)` issues a `DEL
111
122
  ## `rateLimitedDoer` — gating task start rate
112
123
 
113
124
  ```ts
114
- function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer
125
+ function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer
115
126
 
116
127
  interface RateLimitedDoerOptions extends LimiterOptions {
117
- /** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
128
+ /** Limit key for the GLOBAL bucket — a single shared bucket by default (`'doer'`), or derived per task. */
118
129
  readonly key?: string | ((opts: DoerTaskOptions) => string);
130
+
131
+ // ── two-tier: an optional per-`group` limit on top of the global one ──
132
+ /** Default group limit (a second gate bucketed by a task's `group`). Per-task `groupLimit` overrides it; omit for global-only. */
133
+ readonly groupLimit?: number;
134
+ /** Derive a task's group bucket key (default `opts.group`). Return `undefined` to skip the group gate for a task. */
135
+ readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
136
+ /** Store for the group buckets (default: the global `store`). A distributed store limits groups across a fleet. */
137
+ readonly groupStore?: RateLimitStore;
138
+
139
+ // ── sustained-backlog watch (observational) ──
140
+ /** Fire while the pending queue stays continuously non-empty past `backlogAfterMs`. Must not throw. */
141
+ readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
142
+ /** How long the queue must stay non-empty before `onBacklog` first fires (ms). */
143
+ readonly backlogAfterMs?: number;
144
+ /** Re-fire `onBacklog` every this many ms while still backed up (omit to fire once). */
145
+ readonly backlogEveryMs?: number;
146
+
119
147
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
120
148
  readonly retryFloor?: number;
121
149
  /** Clock injection (default `Date.now`). */
@@ -123,6 +151,23 @@ interface RateLimitedDoerOptions extends LimiterOptions {
123
151
  /** The doer that actually runs admitted tasks (default `unlimitedDoer`). Compose policies. */
124
152
  readonly doer?: Doer;
125
153
  }
154
+
155
+ /** Per-task options — DoerTaskOptions (`group`/`priority`/`createdAt`) plus a per-request group-limit override. */
156
+ interface RateTaskOptions extends DoerTaskOptions {
157
+ readonly groupLimit?: number; // overrides the configured default; enables the group gate for this task
158
+ readonly groupWindow?: number; // default: the global `window`
159
+ readonly groupAlgorithm?: Algorithm; // default: the global `algorithm`
160
+ }
161
+
162
+ /** A Doer whose `do` accepts RateTaskOptions (assignable to Doer). */
163
+ interface RateLimitedDoer extends Doer {
164
+ do(task: () => Promise<void>, opts?: RateTaskOptions): void;
165
+ }
166
+
167
+ interface RateLimitedBacklogInfo {
168
+ readonly pending: number; // tasks waiting on the rate limit
169
+ readonly nonEmptyForMs: number; // how long the queue has been continuously non-empty
170
+ }
126
171
  ```
127
172
 
128
173
  (`RateLimitedDoerOptions` extends `LimiterOptions` — see `ayepi-rate.md` for
@@ -135,8 +180,50 @@ A `Doer` (from `@ayepi/core/doer`) that caps the **start rate** of tasks using t
135
180
  policy. Excess tasks wait, **oldest-first**; with a distributed store this rate-limits
136
181
  **across a fleet**.
137
182
 
138
- A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section of
139
- the core docs. `rateLimitedDoer`'s `available()` is `min(limit − pending, inner.available())`.
183
+ A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section
184
+ below. `rateLimitedDoer`'s `available()` is `min(limit − pending, inner.available())`.
185
+
186
+ ### Two-tier: a global limit + a per-group limit
187
+
188
+ Set a `groupLimit` (a configured default, or per task via `RateTaskOptions`) and a task must
189
+ clear **both** the global limit and its `group`'s limit to be admitted — e.g. a global API cap
190
+ plus a per-user / per-model / per-model-family cap. `group` and `groupLimit` are read at
191
+ **admission time**, so groups need not be known up front.
192
+
193
+ ```ts
194
+ import { rateLimitedDoer } from '@ayepi/rate'
195
+
196
+ const doer = rateLimitedDoer({
197
+ limit: 1000, window: 60_000, algorithm: 'token-bucket', // global cap
198
+ groupLimit: 50, // default per-group cap
199
+ })
200
+
201
+ // per-request override — the limit known at request time (e.g. a plan tier):
202
+ doer.do(() => callModel(req), { group: `${userId}:${modelId}`, groupLimit: tier === 'pro' ? 200 : 20 })
203
+ ```
204
+
205
+ Semantics:
206
+
207
+ - **Both gates, group first.** The group gate is checked before the global. A denied check
208
+ consumes nothing (the group rule forces `countRejected: false`), so a task whose group is at
209
+ its limit is **skipped**, not admitted — and no global token is spent on it.
210
+ - **Head-of-line avoidance.** A skipped (group-capped) task is left pending while **other
211
+ groups' tasks proceed**, so a hot user/model can't block everyone. Within a group, oldest-first.
212
+ - **Global exhaustion stops the round.** When the shared global bucket is out, nothing more is
213
+ admitted this pass. Edge case: a task that cleared its group but then hits an exhausted global
214
+ had its group token spent (bounded, self-corrects on retry) — only when global is the bottleneck.
215
+ - **Global-only when no group.** A task with no `group`, or no group limit in scope, passes straight to the global gate.
216
+ - **Fleet-shared, bounded memory.** Point `groupStore` (and `store`) at `@ayepi/rate/redis` to
217
+ share both tiers across pods. The dynamic group key space is auto-evicted (memory sweep / Redis
218
+ TTL), so it doesn't grow without bound — see `memoryStore` above.
219
+
220
+ ### Sustained-backlog watch
221
+
222
+ `onBacklog` fires with `{ pending, nonEmptyForMs }` once the pending queue has stayed
223
+ continuously non-empty past `backlogAfterMs` (repeating every `backlogEveryMs`, or once if
224
+ omitted) — a signal that the rate limit can't admit fast enough. Purely observational
225
+ (alerting/autoscaling), a throw is ignored, and the timer is `unref`'d and cleared when the
226
+ queue drains.
140
227
 
141
228
  ### Example — cap an `@ayepi/work` engine
142
229
 
@@ -177,6 +264,33 @@ With a static `key` string (or the default `'doer'`) all tasks share one bucket.
177
264
 
178
265
  ---
179
266
 
267
+ ## The core `Doer` (`@ayepi/core/doer`)
268
+
269
+ `rateLimitedDoer` builds on the runtime-agnostic `Doer` primitive:
270
+
271
+ ```ts
272
+ interface Doer {
273
+ available(): number; // how many tasks it will accept right now
274
+ do(task: () => Promise<void>, opts?: DoerTaskOptions): void; // fire-and-forget (swallows errors)
275
+ done(): Promise<void>; // resolves when all accepted tasks settle
276
+ }
277
+ interface DoerTaskOptions { group?: string; priority?: number; createdAt?: number }
278
+ ```
279
+
280
+ Bundled policies: **`unlimitedDoer`** (no cap), **`balancedDoer`** (cap N; fair-share across
281
+ `group`s, then priority, then age), **`priorityDoer`** (cap N; highest priority first), **`ageDoer`**
282
+ (cap N; oldest first). The three bounded doers share `BoundedDoerOptions` (`max`, `buffer?`, `now?`).
283
+
284
+ - **`doWith(doer, fn, opts?)`** — submit a task and get its **result** back (`Promise<T>`), the
285
+ result-returning counterpart to the fire-and-forget `do` (which returns void and swallows the
286
+ error). Use it when a doer governs request-scoped work: `const user = await doWith(apiDoer, () => fetchUser(id), { group: tenantId })`.
287
+ - **Sustained-backlog watch** — the bounded doers accept `onBacklog: (info) => void` with
288
+ `backlogAfterMs` / `backlogEveryMs`, firing `{ pending, running, nonEmptyForMs }` once the pending
289
+ queue stays continuously non-empty past the threshold (the doer analogue of `rateLimitedDoer`'s
290
+ `onBacklog`). Purely observational; one `unref`'d timer, only while backed up.
291
+
292
+ ---
293
+
180
294
  ## How it works under the hood
181
295
 
182
296
  ### The three algorithms
@@ -240,18 +354,21 @@ limiter's dependency middleware run first and their context is available — see
240
354
 
241
355
  ### The doer abstraction
242
356
 
243
- `rateLimitedDoer` keeps a `pending` queue. On each `drain`:
244
-
245
- - if the inner doer has no capacity (`inner.available() <= 0`), it arms a short re-check
246
- timer and stops;
247
- - otherwise it picks the **oldest** pending task (by `createdAt`, then submission `seq`),
248
- calls `limiter.check(keyOf(task), now())`;
249
- - if denied, it arms a timer for `max(retryFloor, retryAfter)` and stops (drains again when
250
- the limiter would allow);
251
- - if admitted, it removes the task and calls `inner.do(task.run, task.opts)`.
252
-
253
- `done()` resolves once `pending` is empty **and** the inner doer's `done()` resolves. The
254
- re-check timer is `unref`'d, so it won't keep a process alive on its own.
357
+ `rateLimitedDoer` keeps a `pending` queue. Each `drain` scans it **oldest-first** and admits
358
+ every task that clears its gates, skipping (not blocking on) group-capped ones:
359
+
360
+ - if the inner doer has no capacity (`inner.available() <= 0`), it records a short re-check and stops;
361
+ - for the oldest not-yet-skipped task, it checks the **group gate first** (when a `group` + group
362
+ limit apply): a denied group check consumes nothing, so the task is **skipped** (left pending) and
363
+ the scan continues to other groups — recording that group's `retryAfter`;
364
+ - then the **global gate** (`limiter.check(key, now)`): if denied, the shared bucket is out, so no
365
+ further task can be admitted this round and the scan stops (recording the global `retryAfter`);
366
+ - if both allow, it removes the task and calls `inner.do(task.run, task.opts)`.
367
+
368
+ After the scan, if tasks remain it arms **one** timer for the **soonest** recorded re-check
369
+ (`max(retryFloor, soonest)`), so it drains again exactly when the earliest gate would allow. A
370
+ store error on either gate is reported to `onError` and backs the round off by `retryFloor`. `done()`
371
+ resolves once `pending` is empty **and** the inner doer's `done()` resolves. All timers are `unref`'d.
255
372
 
256
373
  ---
257
374
 
package/ayepi-rate.md CHANGED
@@ -96,8 +96,8 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
96
96
  | `limiter` | function | Standalone limiter primitive (`check`/`reset`/`rule`). |
97
97
  | `rateLimitResponse` | function | Build a 429 `Response` from limiter info. |
98
98
  | `rateLimitHeaders` | function | Compute the `RateLimit-*` header map from limiter info. |
99
- | `memoryStore` | function | The bundled in-process store (all three algorithms). |
100
- | `rateLimitedDoer` | function | A `Doer` that caps task **start rate**. |
99
+ | `memoryStore` | function | The bundled in-process store (all three algorithms); `memoryStore(opts?)` — tunable eviction (`sweepEvery`/`idleMs`). |
100
+ | `rateLimitedDoer` | function | A `Doer` that caps task **start rate** — global + optional **per-group** limit, plus a backlog watch. |
101
101
  | `Algorithm` | type | `'fixed-window' \| 'sliding-window' \| 'token-bucket'`. |
102
102
  | `RateLimitInfo` | interface | Limiter state exposed to handlers + headers. |
103
103
  | `RateLimitResult` | interface | `RateLimitInfo` + `allowed`. |
@@ -107,7 +107,11 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
107
107
  | `LimiterOptions` | interface | Base config (`limit`/`window`/`algorithm`/`store`/`prefix`). |
108
108
  | `RateLimitResponseOptions` | interface | `status`/`message`/`headers` for `rateLimitResponse`. |
109
109
  | `RateLimitDefOptions` | interface | Options for the `rateLimit` def (`name`/`requires`). |
110
- | `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer`. |
110
+ | `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer` (global + group limits, backlog watch). |
111
+ | `RateLimitedDoer` | interface | The doer `rateLimitedDoer` returns (a `Doer` whose `do` takes `RateTaskOptions`). |
112
+ | `RateTaskOptions` | interface | Per-task options — `DoerTaskOptions` + `groupLimit`/`groupWindow`/`groupAlgorithm`. |
113
+ | `RateLimitedBacklogInfo` | interface | `{ pending, nonEmptyForMs }` passed to the doer's `onBacklog`. |
114
+ | `MemoryStoreOptions` | interface | `{ sweepEvery?, idleMs? }` — tune `memoryStore` eviction. |
111
115
  | `Limiter` | interface | The object `limiter()` returns. |
112
116
 
113
117
  ### Server subpath `@ayepi/rate/server`
@@ -222,7 +226,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
222
226
  > `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
223
227
  > too: a store error there is reported and admission retried — it never strands pending tasks.
224
228
 
225
- > The standalone `limiter()` primitive (and `rateLimitedDoer`) still takes a **static**
229
+ > The standalone `limiter()` primitive (and `rateLimitedDoer`) takes a **static**
226
230
  > `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
227
231
  > resolution is a middleware feature, since only the request path carries an `io`.
228
232
 
package/dist/index.cjs CHANGED
@@ -151,18 +151,78 @@ const DOER_RETRY_FLOOR = 50;
151
151
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
152
152
  * const w = createWork({ work: [sendEmail] as const, doer })
153
153
  * ```
154
+ *
155
+ * **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
156
+ * task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
157
+ * per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
158
+ * so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
159
+ * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
154
160
  */
155
161
  function rateLimitedDoer(opts) {
156
- const lim = limiter(opts);
162
+ const store = opts.store ?? memoryStore();
163
+ const lim = limiter({
164
+ ...opts,
165
+ store
166
+ });
157
167
  const inner = opts.doer ?? (0, _ayepi_core_doer.unlimitedDoer)();
158
168
  const now = opts.now ?? Date.now;
159
169
  const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
160
170
  const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
171
+ const groupStore = opts.groupStore ?? store;
172
+ const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
173
+ const groupKeyOf = (o) => opts.groupKey ? opts.groupKey(o ?? {}) : o?.group;
174
+ const groupRuleFor = (o) => {
175
+ const limit = o?.groupLimit ?? opts.groupLimit;
176
+ if (limit === void 0) return;
177
+ return {
178
+ limit,
179
+ window: o?.groupWindow ?? opts.window,
180
+ algorithm: o?.groupAlgorithm ?? opts.algorithm ?? "fixed-window",
181
+ countRejected: false
182
+ };
183
+ };
184
+ const report = (err) => {
185
+ try {
186
+ opts.onError?.(err);
187
+ } catch {}
188
+ };
161
189
  const pending = [];
162
190
  const idle = [];
163
191
  let seq = 0;
164
192
  let draining = false;
165
193
  let timer = null;
194
+ const backlogWatch = opts.onBacklog !== void 0 && opts.backlogAfterMs !== void 0;
195
+ let nonEmptySince = null;
196
+ let backlogTimer = null;
197
+ const armBacklog = (ms) => {
198
+ backlogTimer = setTimeout(fireBacklog, ms);
199
+ backlogTimer.unref?.();
200
+ };
201
+ function fireBacklog() {
202
+ try {
203
+ opts.onBacklog({
204
+ pending: pending.length,
205
+ nonEmptyForMs: now() - nonEmptySince
206
+ });
207
+ } catch {}
208
+ if (opts.backlogEveryMs !== void 0) armBacklog(opts.backlogEveryMs);
209
+ else backlogTimer = null;
210
+ }
211
+ const syncBacklog = () => {
212
+ if (!backlogWatch) return;
213
+ if (pending.length > 0) {
214
+ if (nonEmptySince === null) {
215
+ nonEmptySince = now();
216
+ armBacklog(opts.backlogAfterMs);
217
+ }
218
+ } else {
219
+ nonEmptySince = null;
220
+ if (backlogTimer) {
221
+ clearTimeout(backlogTimer);
222
+ backlogTimer = null;
223
+ }
224
+ }
225
+ };
166
226
  const arm = (ms) => {
167
227
  if (timer) return;
168
228
  timer = setTimeout(() => {
@@ -171,40 +231,67 @@ function rateLimitedDoer(opts) {
171
231
  }, Math.max(floor, ms));
172
232
  timer.unref?.();
173
233
  };
234
+ /**
235
+ * The oldest pending task not skipped this round (a fresh scan, so tasks queued mid-drain are
236
+ * seen). `pending` is in insertion (seq) order, so equal `createdAt` ties keep the earliest.
237
+ */
238
+ const nextPending = (skipped) => {
239
+ let best;
240
+ for (const t of pending) {
241
+ if (skipped.has(t)) continue;
242
+ if (!best || t.createdAt < best.createdAt) best = t;
243
+ }
244
+ return best;
245
+ };
174
246
  const drain = async () => {
175
247
  if (draining) return;
176
248
  draining = true;
177
249
  try {
178
- while (pending.length > 0) {
250
+ const skipped = /* @__PURE__ */ new Set();
251
+ let soonest = Infinity;
252
+ for (;;) {
179
253
  if (inner.available() <= 0) {
180
- arm(floor);
254
+ soonest = Math.min(soonest, floor);
181
255
  break;
182
256
  }
183
- let best = 0;
184
- for (let i = 1; i < pending.length; i++) {
185
- const a = pending[i];
186
- const b = pending[best];
187
- if (a.createdAt < b.createdAt || a.createdAt === b.createdAt && a.seq < b.seq) best = i;
257
+ const task = nextPending(skipped);
258
+ if (!task) break;
259
+ const o = task.opts;
260
+ const groupRule = groupRuleFor(o);
261
+ const gk = groupKeyOf(o);
262
+ if (groupRule && gk !== void 0) {
263
+ let gr;
264
+ try {
265
+ gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
266
+ } catch (err) {
267
+ report(err);
268
+ soonest = Math.min(soonest, floor);
269
+ break;
270
+ }
271
+ if (!gr.allowed) {
272
+ soonest = Math.min(soonest, gr.retryAfter);
273
+ skipped.add(task);
274
+ continue;
275
+ }
188
276
  }
189
- const task = pending[best];
190
277
  let res;
191
278
  try {
192
- res = await lim.check(keyOf(task.opts), now());
279
+ res = await lim.check(keyOf(o), now());
193
280
  } catch (err) {
194
- try {
195
- opts.onError?.(err);
196
- } catch {}
197
- arm(floor);
281
+ report(err);
282
+ soonest = Math.min(soonest, floor);
198
283
  break;
199
284
  }
200
285
  if (!res.allowed) {
201
- arm(res.retryAfter);
286
+ soonest = Math.min(soonest, res.retryAfter);
202
287
  break;
203
288
  }
204
- pending.splice(best, 1);
289
+ pending.splice(pending.indexOf(task), 1);
205
290
  inner.do(task.run, task.opts);
206
291
  }
207
292
  if (pending.length === 0) for (const r of idle.splice(0)) r();
293
+ else arm(soonest);
294
+ syncBacklog();
208
295
  } finally {
209
296
  draining = false;
210
297
  }
@@ -218,6 +305,7 @@ function rateLimitedDoer(opts) {
218
305
  createdAt: o?.createdAt ?? now(),
219
306
  seq: seq++
220
307
  });
308
+ syncBacklog();
221
309
  drain();
222
310
  },
223
311
  done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
@@ -298,19 +386,26 @@ function tokenBucket(buckets, key, rule, now) {
298
386
  };
299
387
  }
300
388
  /**
301
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
302
- * default store — fine for a single instance; use a distributed store (e.g.
303
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
304
- * lazily.
389
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
390
+ * for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
391
+ * share limits across pods.
392
+ *
393
+ * **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
394
+ * key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
395
+ * is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
396
+ * Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
397
+ * for high churn.
305
398
  */
306
- function memoryStore() {
399
+ function memoryStore(opts = {}) {
400
+ const sweepEvery = opts.sweepEvery ?? SWEEP_EVERY;
401
+ const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
307
402
  const counters = /* @__PURE__ */ new Map();
308
403
  const buckets = /* @__PURE__ */ new Map();
309
404
  let ops = 0;
310
405
  const sweep = (now) => {
311
- if (++ops % SWEEP_EVERY !== 0) return;
406
+ if (++ops % sweepEvery !== 0) return;
312
407
  for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
313
- for (const [k, b] of buckets) if (now - b.ts > BUCKET_IDLE_MS) buckets.delete(k);
408
+ for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
314
409
  };
315
410
  return {
316
411
  consume(key, rule, now) {
package/dist/index.d.cts CHANGED
@@ -148,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
148
148
  type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
149
149
  ratelimit: RateLimitInfo;
150
150
  }, R>;
151
+ /** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
152
+ interface RateLimitedBacklogInfo {
153
+ /** Tasks waiting on the rate limit to admit them (the queue depth). */
154
+ readonly pending: number;
155
+ /** How long the queue has been *continuously* non-empty (ms). */
156
+ readonly nonEmptyForMs: number;
157
+ }
158
+ /**
159
+ * Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
160
+ * `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
161
+ * present, the task must clear both the global limit **and** its group's limit to be admitted.
162
+ */
163
+ interface RateTaskOptions extends DoerTaskOptions {
164
+ /** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
165
+ readonly groupLimit?: number;
166
+ /** Window for the group limit (ms; default: the global `window`). */
167
+ readonly groupWindow?: number;
168
+ /** Algorithm for the group limit (default: the global `algorithm`). */
169
+ readonly groupAlgorithm?: Algorithm;
170
+ }
171
+ /** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
172
+ interface RateLimitedDoer extends Doer {
173
+ /** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
174
+ do(task: () => Promise<void>, opts?: RateTaskOptions): void;
175
+ }
151
176
  /** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
152
177
  interface RateLimitedDoerOptions extends LimiterOptions {
153
- /** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
178
+ /** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
154
179
  readonly key?: string | ((opts: DoerTaskOptions) => string);
180
+ /**
181
+ * Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
182
+ * A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
183
+ * The group window/algorithm default to the global ones unless overridden per task.
184
+ */
185
+ readonly groupLimit?: number;
186
+ /** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
187
+ readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
188
+ /**
189
+ * Store for the group buckets (default: the global `store`). A distributed store limits groups
190
+ * across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
191
+ * when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
192
+ * an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
193
+ * pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
194
+ */
195
+ readonly groupStore?: RateLimitStore;
155
196
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
156
197
  readonly retryFloor?: number;
157
198
  /** Clock injection (default `Date.now`). */
158
199
  readonly now?: () => number;
159
200
  /** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
160
201
  readonly doer?: Doer;
202
+ /**
203
+ * Notified when the pending queue stays **continuously non-empty** past
204
+ * {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
205
+ * admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
206
+ * does, the throw is ignored. Requires `backlogAfterMs`.
207
+ */
208
+ readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
209
+ /** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
210
+ readonly backlogAfterMs?: number;
211
+ /** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
212
+ readonly backlogEveryMs?: number;
161
213
  /**
162
214
  * Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
163
215
  * never crashes on it — the task stays pending and admission is retried shortly. Off by
@@ -180,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
180
232
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
181
233
  * const w = createWork({ work: [sendEmail] as const, doer })
182
234
  * ```
235
+ *
236
+ * **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
237
+ * task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
238
+ * per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
239
+ * so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
240
+ * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
183
241
  */
184
- declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
242
+ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
243
+ /** Options for {@link memoryStore}. */
244
+ interface MemoryStoreOptions {
245
+ /**
246
+ * Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
247
+ * memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
248
+ * that aren't known up front — at a little more periodic scan cost.
249
+ */
250
+ readonly sweepEvery?: number;
251
+ /**
252
+ * Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
253
+ * Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
254
+ * elapsed, regardless of this.
255
+ */
256
+ readonly idleMs?: number;
257
+ }
185
258
  /**
186
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
187
- * default store — fine for a single instance; use a distributed store (e.g.
188
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
189
- * lazily.
259
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
260
+ * for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
261
+ * share limits across pods.
262
+ *
263
+ * **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
264
+ * key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
265
+ * is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
266
+ * Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
267
+ * for high churn.
190
268
  */
191
- declare function memoryStore(): RateLimitStore;
269
+ declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
192
270
  //#endregion
193
- export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
271
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.d.ts CHANGED
@@ -148,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
148
148
  type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
149
149
  ratelimit: RateLimitInfo;
150
150
  }, R>;
151
+ /** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
152
+ interface RateLimitedBacklogInfo {
153
+ /** Tasks waiting on the rate limit to admit them (the queue depth). */
154
+ readonly pending: number;
155
+ /** How long the queue has been *continuously* non-empty (ms). */
156
+ readonly nonEmptyForMs: number;
157
+ }
158
+ /**
159
+ * Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
160
+ * `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
161
+ * present, the task must clear both the global limit **and** its group's limit to be admitted.
162
+ */
163
+ interface RateTaskOptions extends DoerTaskOptions {
164
+ /** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
165
+ readonly groupLimit?: number;
166
+ /** Window for the group limit (ms; default: the global `window`). */
167
+ readonly groupWindow?: number;
168
+ /** Algorithm for the group limit (default: the global `algorithm`). */
169
+ readonly groupAlgorithm?: Algorithm;
170
+ }
171
+ /** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
172
+ interface RateLimitedDoer extends Doer {
173
+ /** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
174
+ do(task: () => Promise<void>, opts?: RateTaskOptions): void;
175
+ }
151
176
  /** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
152
177
  interface RateLimitedDoerOptions extends LimiterOptions {
153
- /** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
178
+ /** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
154
179
  readonly key?: string | ((opts: DoerTaskOptions) => string);
180
+ /**
181
+ * Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
182
+ * A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
183
+ * The group window/algorithm default to the global ones unless overridden per task.
184
+ */
185
+ readonly groupLimit?: number;
186
+ /** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
187
+ readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
188
+ /**
189
+ * Store for the group buckets (default: the global `store`). A distributed store limits groups
190
+ * across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
191
+ * when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
192
+ * an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
193
+ * pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
194
+ */
195
+ readonly groupStore?: RateLimitStore;
155
196
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
156
197
  readonly retryFloor?: number;
157
198
  /** Clock injection (default `Date.now`). */
158
199
  readonly now?: () => number;
159
200
  /** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
160
201
  readonly doer?: Doer;
202
+ /**
203
+ * Notified when the pending queue stays **continuously non-empty** past
204
+ * {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
205
+ * admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
206
+ * does, the throw is ignored. Requires `backlogAfterMs`.
207
+ */
208
+ readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
209
+ /** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
210
+ readonly backlogAfterMs?: number;
211
+ /** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
212
+ readonly backlogEveryMs?: number;
161
213
  /**
162
214
  * Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
163
215
  * never crashes on it — the task stays pending and admission is retried shortly. Off by
@@ -180,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
180
232
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
181
233
  * const w = createWork({ work: [sendEmail] as const, doer })
182
234
  * ```
235
+ *
236
+ * **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
237
+ * task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
238
+ * per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
239
+ * so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
240
+ * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
183
241
  */
184
- declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
242
+ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
243
+ /** Options for {@link memoryStore}. */
244
+ interface MemoryStoreOptions {
245
+ /**
246
+ * Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
247
+ * memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
248
+ * that aren't known up front — at a little more periodic scan cost.
249
+ */
250
+ readonly sweepEvery?: number;
251
+ /**
252
+ * Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
253
+ * Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
254
+ * elapsed, regardless of this.
255
+ */
256
+ readonly idleMs?: number;
257
+ }
185
258
  /**
186
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
187
- * default store — fine for a single instance; use a distributed store (e.g.
188
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
189
- * lazily.
259
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
260
+ * for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
261
+ * share limits across pods.
262
+ *
263
+ * **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
264
+ * key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
265
+ * is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
266
+ * Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
267
+ * for high churn.
190
268
  */
191
- declare function memoryStore(): RateLimitStore;
269
+ declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
192
270
  //#endregion
193
- export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
271
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.js CHANGED
@@ -150,18 +150,78 @@ const DOER_RETRY_FLOOR = 50;
150
150
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
151
151
  * const w = createWork({ work: [sendEmail] as const, doer })
152
152
  * ```
153
+ *
154
+ * **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
155
+ * task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
156
+ * per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
157
+ * so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
158
+ * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
153
159
  */
154
160
  function rateLimitedDoer(opts) {
155
- const lim = limiter(opts);
161
+ const store = opts.store ?? memoryStore();
162
+ const lim = limiter({
163
+ ...opts,
164
+ store
165
+ });
156
166
  const inner = opts.doer ?? unlimitedDoer();
157
167
  const now = opts.now ?? Date.now;
158
168
  const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
159
169
  const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
170
+ const groupStore = opts.groupStore ?? store;
171
+ const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
172
+ const groupKeyOf = (o) => opts.groupKey ? opts.groupKey(o ?? {}) : o?.group;
173
+ const groupRuleFor = (o) => {
174
+ const limit = o?.groupLimit ?? opts.groupLimit;
175
+ if (limit === void 0) return;
176
+ return {
177
+ limit,
178
+ window: o?.groupWindow ?? opts.window,
179
+ algorithm: o?.groupAlgorithm ?? opts.algorithm ?? "fixed-window",
180
+ countRejected: false
181
+ };
182
+ };
183
+ const report = (err) => {
184
+ try {
185
+ opts.onError?.(err);
186
+ } catch {}
187
+ };
160
188
  const pending = [];
161
189
  const idle = [];
162
190
  let seq = 0;
163
191
  let draining = false;
164
192
  let timer = null;
193
+ const backlogWatch = opts.onBacklog !== void 0 && opts.backlogAfterMs !== void 0;
194
+ let nonEmptySince = null;
195
+ let backlogTimer = null;
196
+ const armBacklog = (ms) => {
197
+ backlogTimer = setTimeout(fireBacklog, ms);
198
+ backlogTimer.unref?.();
199
+ };
200
+ function fireBacklog() {
201
+ try {
202
+ opts.onBacklog({
203
+ pending: pending.length,
204
+ nonEmptyForMs: now() - nonEmptySince
205
+ });
206
+ } catch {}
207
+ if (opts.backlogEveryMs !== void 0) armBacklog(opts.backlogEveryMs);
208
+ else backlogTimer = null;
209
+ }
210
+ const syncBacklog = () => {
211
+ if (!backlogWatch) return;
212
+ if (pending.length > 0) {
213
+ if (nonEmptySince === null) {
214
+ nonEmptySince = now();
215
+ armBacklog(opts.backlogAfterMs);
216
+ }
217
+ } else {
218
+ nonEmptySince = null;
219
+ if (backlogTimer) {
220
+ clearTimeout(backlogTimer);
221
+ backlogTimer = null;
222
+ }
223
+ }
224
+ };
165
225
  const arm = (ms) => {
166
226
  if (timer) return;
167
227
  timer = setTimeout(() => {
@@ -170,40 +230,67 @@ function rateLimitedDoer(opts) {
170
230
  }, Math.max(floor, ms));
171
231
  timer.unref?.();
172
232
  };
233
+ /**
234
+ * The oldest pending task not skipped this round (a fresh scan, so tasks queued mid-drain are
235
+ * seen). `pending` is in insertion (seq) order, so equal `createdAt` ties keep the earliest.
236
+ */
237
+ const nextPending = (skipped) => {
238
+ let best;
239
+ for (const t of pending) {
240
+ if (skipped.has(t)) continue;
241
+ if (!best || t.createdAt < best.createdAt) best = t;
242
+ }
243
+ return best;
244
+ };
173
245
  const drain = async () => {
174
246
  if (draining) return;
175
247
  draining = true;
176
248
  try {
177
- while (pending.length > 0) {
249
+ const skipped = /* @__PURE__ */ new Set();
250
+ let soonest = Infinity;
251
+ for (;;) {
178
252
  if (inner.available() <= 0) {
179
- arm(floor);
253
+ soonest = Math.min(soonest, floor);
180
254
  break;
181
255
  }
182
- let best = 0;
183
- for (let i = 1; i < pending.length; i++) {
184
- const a = pending[i];
185
- const b = pending[best];
186
- if (a.createdAt < b.createdAt || a.createdAt === b.createdAt && a.seq < b.seq) best = i;
256
+ const task = nextPending(skipped);
257
+ if (!task) break;
258
+ const o = task.opts;
259
+ const groupRule = groupRuleFor(o);
260
+ const gk = groupKeyOf(o);
261
+ if (groupRule && gk !== void 0) {
262
+ let gr;
263
+ try {
264
+ gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
265
+ } catch (err) {
266
+ report(err);
267
+ soonest = Math.min(soonest, floor);
268
+ break;
269
+ }
270
+ if (!gr.allowed) {
271
+ soonest = Math.min(soonest, gr.retryAfter);
272
+ skipped.add(task);
273
+ continue;
274
+ }
187
275
  }
188
- const task = pending[best];
189
276
  let res;
190
277
  try {
191
- res = await lim.check(keyOf(task.opts), now());
278
+ res = await lim.check(keyOf(o), now());
192
279
  } catch (err) {
193
- try {
194
- opts.onError?.(err);
195
- } catch {}
196
- arm(floor);
280
+ report(err);
281
+ soonest = Math.min(soonest, floor);
197
282
  break;
198
283
  }
199
284
  if (!res.allowed) {
200
- arm(res.retryAfter);
285
+ soonest = Math.min(soonest, res.retryAfter);
201
286
  break;
202
287
  }
203
- pending.splice(best, 1);
288
+ pending.splice(pending.indexOf(task), 1);
204
289
  inner.do(task.run, task.opts);
205
290
  }
206
291
  if (pending.length === 0) for (const r of idle.splice(0)) r();
292
+ else arm(soonest);
293
+ syncBacklog();
207
294
  } finally {
208
295
  draining = false;
209
296
  }
@@ -217,6 +304,7 @@ function rateLimitedDoer(opts) {
217
304
  createdAt: o?.createdAt ?? now(),
218
305
  seq: seq++
219
306
  });
307
+ syncBacklog();
220
308
  drain();
221
309
  },
222
310
  done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
@@ -297,19 +385,26 @@ function tokenBucket(buckets, key, rule, now) {
297
385
  };
298
386
  }
299
387
  /**
300
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
301
- * default store — fine for a single instance; use a distributed store (e.g.
302
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
303
- * lazily.
388
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
389
+ * for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
390
+ * share limits across pods.
391
+ *
392
+ * **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
393
+ * key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
394
+ * is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
395
+ * Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
396
+ * for high churn.
304
397
  */
305
- function memoryStore() {
398
+ function memoryStore(opts = {}) {
399
+ const sweepEvery = opts.sweepEvery ?? SWEEP_EVERY;
400
+ const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
306
401
  const counters = /* @__PURE__ */ new Map();
307
402
  const buckets = /* @__PURE__ */ new Map();
308
403
  let ops = 0;
309
404
  const sweep = (now) => {
310
- if (++ops % SWEEP_EVERY !== 0) return;
405
+ if (++ops % sweepEvery !== 0) return;
311
406
  for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
312
- for (const [k, b] of buckets) if (now - b.ts > BUCKET_IDLE_MS) buckets.delete(k);
407
+ for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
313
408
  };
314
409
  return {
315
410
  consume(key, rule, now) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/rate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Rate-limiting middleware for @ayepi/core — pluggable stores, multiple algorithms, customizable 429 responses",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "ioredis": "^5",
62
- "@ayepi/core": "^0.3.0"
62
+ "@ayepi/core": "^0.4.0"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "ioredis": {
@@ -74,7 +74,7 @@
74
74
  "tsdown": "^0.12.0",
75
75
  "vitest": "^2.1.8",
76
76
  "zod": "^4.4.3",
77
- "@ayepi/core": "0.3.0"
77
+ "@ayepi/core": "0.4.0"
78
78
  },
79
79
  "keywords": [
80
80
  "ayepi",