@ayepi/rate 0.2.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
@@ -49,6 +49,9 @@ retryAfter }`); on exceeded requests the chain short-circuits with the 429.
49
49
  - `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
50
50
  move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
51
51
  message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
52
+ The rule fields — `limit`, `window`, `algorithm`, `countRejected` — each accept a fixed
53
+ value **or** a per-request `(io) => value`, so limits can differ by caller (e.g.
54
+ `limit: (io) => io.ctx.user.plan === 'pro' ? 1000 : 100`).
52
55
 
53
56
  ## Standalone (without middleware)
54
57
 
@@ -94,6 +97,16 @@ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-buc
94
97
  const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
95
98
  ```
96
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
+
97
110
  ## Algorithms
98
111
 
99
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`
@@ -115,7 +119,8 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
115
119
  | Export | Kind | Purpose |
116
120
  | --- | --- | --- |
117
121
  | `rateLimit` | function | Same name, **augmented with `.server(def, opts)`** to bind the policy. |
118
- | `RateLimitServerOptions` | interface | The policy options for `.server` (extends `LimiterOptions`). |
122
+ | `RateLimitServerOptions` | interface | The policy options for `.server` (rule fields accept a static value or a per-request `(io) => T`). |
123
+ | `RateKeyFn` / `Dynamic` | type | Helpers: `RateKeyFn<M, T> = (io) => T`; `Dynamic<M, T> = T \| RateKeyFn<M, T>` — the shape of every per-request option. |
119
124
 
120
125
  ### Redis subpath `@ayepi/rate/redis`
121
126
 
@@ -158,10 +163,10 @@ it in another middleware's `requires` (see `ayepi-core-middleware.md`).
158
163
  ### The impl (`@ayepi/rate/server`)
159
164
 
160
165
  ```ts
161
- rateLimit.server: <const R extends readonly AnyMiddleware[]>(
162
- def: RateLimitDef<{ ratelimit: RateLimitInfo }, R>,
163
- opts: RateLimitServerOptions<StackCtx<R>, R>,
164
- ) => BoundMiddleware // pass to implement(api).middleware(...)
166
+ rateLimit.server: <M extends AnyMiddleware>(
167
+ def: M, // a RateLimitDef — its `requires` types `io.ctx`
168
+ opts: RateLimitServerOptions<M>,
169
+ ) => BoundMiddleware<M> // pass to implement(api).middleware(...)
165
170
  ```
166
171
 
167
172
  `.server` binds the policy and returns the bound pair. It composes with the chainable
@@ -170,20 +175,35 @@ rateLimit.server: <const R extends readonly AnyMiddleware[]>(
170
175
  ### `RateLimitServerOptions`
171
176
 
172
177
  ```ts
173
- interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddleware[]>
174
- extends LimiterOptions {
178
+ // A rule field is either a fixed value or resolved per request from the key `io`.
179
+ type RateKeyFn<M, T> = (io: RateKeyIO<StackCtx<ReqOf<M>>>) => T;
180
+ type Dynamic<M, T> = T | RateKeyFn<M, T>;
181
+
182
+ interface RateLimitServerOptions<M extends AnyMiddleware> {
175
183
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
176
- readonly key: (io: RateKeyIO<Ctx>) => string;
184
+ readonly key: RateKeyFn<M, string>;
185
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
186
+ readonly limit: Dynamic<M, number>;
187
+ /** Window length in ms (also the token refill period) — fixed, or per request. */
188
+ readonly window: Dynamic<M, number>;
189
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
190
+ readonly algorithm?: Dynamic<M, Algorithm>;
191
+ /** Count rejected (over-limit) requests against the limit (default `false`) — fixed, or per request. */
192
+ readonly countRejected?: Dynamic<M, boolean>;
193
+ /** Backend store (default an in-process `memoryStore`). */
194
+ readonly store?: RateLimitStore;
195
+ /** Key prefix/namespace (default `'rl:'`). */
196
+ readonly prefix?: string;
177
197
  /** Over-limit status code (default `429`). */
178
198
  readonly status?: number;
179
199
  /** Over-limit body — string, JSON value, or a function of (info, io). */
180
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<Ctx>) => string | Json);
200
+ readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
181
201
  /** Response headers (see `RateLimitResponseOptions`). */
182
202
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
183
203
  /** Also emit the `RateLimit-*` headers on allowed/skipped responses (default `false`). */
184
204
  readonly alwaysHeaders?: boolean;
185
205
  /** Bypass the limiter for some requests (e.g. an allow-list). */
186
- readonly skip?: (io: RateKeyIO<Ctx>) => boolean;
206
+ readonly skip?: RateKeyFn<M, boolean>;
187
207
  /** Serve through (as allowed) when the **store** errors, instead of failing the request. Default `false` (fail-closed). */
188
208
  readonly failOpen?: boolean;
189
209
  /** Observe a store error (e.g. Redis down). Fires regardless of `failOpen`. Off by default; must not throw. */
@@ -191,30 +211,24 @@ interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddl
191
211
  }
192
212
  ```
193
213
 
214
+ > **Dynamic policy.** `limit`, `window`, `algorithm`, and `countRejected` each accept a plain
215
+ > value **or** a `(io) => value` resolved per request — so different callers get different
216
+ > limits (e.g. a higher `limit` for a premium plan, a longer `window` for a trusted key). `io`
217
+ > is the same `{ req, ctx }` `key`/`skip` receive, with `io.ctx` typed from the def's `requires`.
218
+ > Resolve `window`/`algorithm` from a **stable** property of the identity (a given key should
219
+ > always map to the same window/algorithm) — the store buckets its per-key state by them, so
220
+ > varying them for one key across requests gives inconsistent accounting. Varying only `limit`
221
+ > is always safe.
222
+
194
223
  > **Store errors.** By default the limiter is **fail-closed**: if the store (e.g. a Redis
195
224
  > outage) throws, the error propagates and the request is rejected — a store outage doesn't
196
225
  > silently lift the limit. Set `failOpen: true` to serve such requests through instead, and/or
197
226
  > `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
198
227
  > too: a store error there is reported and admission retried — it never strands pending tasks.
199
228
 
200
- …plus everything from `LimiterOptions`:
201
-
202
- ```ts
203
- interface LimiterOptions {
204
- /** Max requests (or token-bucket capacity) per window. */
205
- readonly limit: number;
206
- /** Window length in milliseconds (also the token refill period). */
207
- readonly window: number;
208
- /** Algorithm (default `'fixed-window'`). */
209
- readonly algorithm?: Algorithm;
210
- /** Backend store (default an in-process `memoryStore`). */
211
- readonly store?: RateLimitStore;
212
- /** Key prefix/namespace (default `'rl:'`). */
213
- readonly prefix?: string;
214
- /** Count requests that are themselves rejected (over-limit) against the limit (default `false`). */
215
- readonly countRejected?: boolean;
216
- }
217
- ```
229
+ > The standalone `limiter()` primitive (and `rateLimitedDoer`) takes a **static**
230
+ > `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
231
+ > resolution is a middleware feature, since only the request path carries an `io`.
218
232
 
219
233
  Notes grounded in the source:
220
234
 
@@ -304,6 +318,24 @@ const app = implement(api)
304
318
  .server()
305
319
  ```
306
320
 
321
+ ### Example — per-caller limits (dynamic `limit`)
322
+
323
+ ```ts
324
+ // shared.ts — auth provides { user: { id, plan } }
325
+ const limit = rateLimit({ requires: [auth] })
326
+ // server.ts
327
+ rateLimit.server(limit, {
328
+ key: (io) => io.ctx.user.id,
329
+ limit: (io) => (io.ctx.user.plan === 'pro' ? 1_000 : 100), // premium callers get more
330
+ window: 60_000, // keep window/algorithm stable per key
331
+ algorithm: 'sliding-window',
332
+ })
333
+ ```
334
+
335
+ Any of `limit` / `window` / `algorithm` / `countRejected` can be a `(io) => value`. Derive
336
+ `window`/`algorithm` from a stable identity attribute (plan, tenant) — not from time or the
337
+ request path — so a given key always maps to the same bucketing.
338
+
307
339
  ### Example — per-IP limiting without `requires`
308
340
 
309
341
  ```ts
package/dist/index.cjs CHANGED
@@ -66,10 +66,10 @@ function limiter(opts) {
66
66
  const rule = {
67
67
  limit: opts.limit,
68
68
  window: opts.window,
69
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
69
+ algorithm: opts.algorithm ?? "fixed-window",
70
70
  countRejected: opts.countRejected ?? false
71
71
  };
72
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
72
+ const prefix = opts.prefix ?? "rl:";
73
73
  return {
74
74
  rule,
75
75
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -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) {
@@ -329,6 +424,8 @@ function memoryStore() {
329
424
  };
330
425
  }
331
426
  //#endregion
427
+ exports.DEFAULT_ALGORITHM = DEFAULT_ALGORITHM;
428
+ exports.DEFAULT_PREFIX = DEFAULT_PREFIX;
332
429
  exports.limiter = limiter;
333
430
  exports.memoryStore = memoryStore;
334
431
  exports.rateLimit = rateLimit;