@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/dist/index.d.cts CHANGED
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -144,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
144
148
  type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
145
149
  ratelimit: RateLimitInfo;
146
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
+ }
147
176
  /** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
148
177
  interface RateLimitedDoerOptions extends LimiterOptions {
149
- /** 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. */
150
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;
151
196
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
152
197
  readonly retryFloor?: number;
153
198
  /** Clock injection (default `Date.now`). */
154
199
  readonly now?: () => number;
155
200
  /** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
156
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;
157
213
  /**
158
214
  * Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
159
215
  * never crashes on it — the task stays pending and admission is retried shortly. Off by
@@ -176,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
176
232
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
177
233
  * const w = createWork({ work: [sendEmail] as const, doer })
178
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.
179
241
  */
180
- 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
+ }
181
258
  /**
182
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
183
- * default store — fine for a single instance; use a distributed store (e.g.
184
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
185
- * 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.
186
268
  */
187
- declare function memoryStore(): RateLimitStore;
269
+ declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
188
270
  //#endregion
189
- export { Algorithm, 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
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -144,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
144
148
  type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
145
149
  ratelimit: RateLimitInfo;
146
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
+ }
147
176
  /** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
148
177
  interface RateLimitedDoerOptions extends LimiterOptions {
149
- /** 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. */
150
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;
151
196
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
152
197
  readonly retryFloor?: number;
153
198
  /** Clock injection (default `Date.now`). */
154
199
  readonly now?: () => number;
155
200
  /** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
156
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;
157
213
  /**
158
214
  * Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
159
215
  * never crashes on it — the task stays pending and admission is retried shortly. Off by
@@ -176,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
176
232
  * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
177
233
  * const w = createWork({ work: [sendEmail] as const, doer })
178
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.
179
241
  */
180
- 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
+ }
181
258
  /**
182
- * An in-process {@link RateLimitStore} implementing all three algorithms. The
183
- * default store — fine for a single instance; use a distributed store (e.g.
184
- * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
185
- * 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.
186
268
  */
187
- declare function memoryStore(): RateLimitStore;
269
+ declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
188
270
  //#endregion
189
- export { Algorithm, 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
@@ -65,10 +65,10 @@ function limiter(opts) {
65
65
  const rule = {
66
66
  limit: opts.limit,
67
67
  window: opts.window,
68
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
68
+ algorithm: opts.algorithm ?? "fixed-window",
69
69
  countRejected: opts.countRejected ?? false
70
70
  };
71
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
71
+ const prefix = opts.prefix ?? "rl:";
72
72
  return {
73
73
  rule,
74
74
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -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) {
@@ -328,4 +423,4 @@ function memoryStore() {
328
423
  };
329
424
  }
330
425
  //#endregion
331
- export { limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
426
+ export { DEFAULT_ALGORITHM, DEFAULT_PREFIX, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/server.cjs CHANGED
@@ -1,22 +1,34 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_index = require("./index.cjs");
3
3
  //#region src/server.ts
4
+ /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
5
+ function resolve(value, io) {
6
+ return typeof value === "function" ? value(io) : value;
7
+ }
4
8
  /** Bind a {@link rateLimit} def to its runtime policy. */
5
9
  function rateLimitServer(def, opts) {
6
- const lim = require_index.limiter(opts);
10
+ const store = opts.store ?? require_index.memoryStore();
11
+ const prefix = opts.prefix ?? "rl:";
7
12
  const message = opts.message;
13
+ const ruleFor = (io) => ({
14
+ limit: resolve(opts.limit, io),
15
+ window: resolve(opts.window, io),
16
+ algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
17
+ countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
18
+ });
8
19
  const run = async (io) => {
9
20
  const kio = {
10
21
  req: io.req,
11
22
  ctx: io.ctx
12
23
  };
24
+ const rule = ruleFor(kio);
13
25
  const advertise = (info) => {
14
26
  if (!opts.alwaysHeaders) return;
15
27
  for (const [name, value] of Object.entries(require_index.rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
16
28
  };
17
29
  const fullBudget = () => ({
18
- limit: lim.rule.limit,
19
- remaining: lim.rule.limit,
30
+ limit: rule.limit,
31
+ remaining: rule.limit,
20
32
  reset: 0,
21
33
  retryAfter: 0
22
34
  });
@@ -27,7 +39,7 @@ function rateLimitServer(def, opts) {
27
39
  }
28
40
  let result;
29
41
  try {
30
- result = await lim.check(opts.key(kio));
42
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now());
31
43
  } catch (err) {
32
44
  try {
33
45
  opts.onError?.(err);
package/dist/server.d.cts CHANGED
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
5
5
 
6
6
  /** The `requires` chain of a middleware def. */
7
7
  type ReqOf<M extends AnyMiddleware> = M['__req'];
8
+ /** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
9
+ type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
10
+ /**
11
+ * A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
12
+ * `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
13
+ */
14
+ type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
15
+ /**
16
+ * A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
17
+ * from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
18
+ * `limit` for a premium plan, a longer `window` for a trusted client.
19
+ */
20
+ type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
8
21
  /**
9
22
  * Server-side options for binding a {@link rateLimit} def — the limiting policy and
10
23
  * response customization, with `key`/`skip`/`message` typed against the def's
11
24
  * `requires` context.
12
25
  *
26
+ * The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
27
+ * a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
28
+ * **Resolve these from a stable property of the identity** (the same key should always map to
29
+ * the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
30
+ * `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
31
+ * `limit` is always safe. See {@link Dynamic}.
32
+ *
13
33
  * @typeParam M - the rate-limit def being bound.
14
34
  */
15
35
  interface RateLimitServerOptions<M extends AnyMiddleware> {
16
36
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
17
- readonly key: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => string;
18
- /** Max requests (or token-bucket capacity) per window. */
19
- readonly limit: number;
20
- /** Window length in milliseconds (also the token refill period). */
21
- readonly window: number;
22
- /** Algorithm (default `'fixed-window'`). */
23
- readonly algorithm?: Algorithm;
37
+ readonly key: RateKeyFn<M, string>;
38
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
39
+ readonly limit: Dynamic<M, number>;
40
+ /** Window length in milliseconds (also the token refill period) — fixed, or per request. */
41
+ readonly window: Dynamic<M, number>;
42
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
43
+ readonly algorithm?: Dynamic<M, Algorithm>;
44
+ /**
45
+ * Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
46
+ * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
+ */
48
+ readonly countRejected?: Dynamic<M, boolean>;
24
49
  /** Backend store (default an in-process memory store). */
25
50
  readonly store?: RateLimitStore;
26
51
  /** Key prefix/namespace (default `'rl:'`). */
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
28
53
  /** Over-limit status code (default `429`). */
29
54
  readonly status?: number;
30
55
  /** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
31
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
56
+ readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
32
57
  /** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
33
58
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
34
59
  /**
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
39
64
  */
40
65
  readonly alwaysHeaders?: boolean;
41
66
  /** Bypass the limiter for some requests (e.g. an allow-list). */
42
- readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
67
+ readonly skip?: RateKeyFn<M, boolean>;
43
68
  /**
44
69
  * What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
45
70
  * request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
64
89
  server: typeof rateLimitServer;
65
90
  };
66
91
  //#endregion
67
- export { RateLimitServerOptions, rateLimit };
92
+ export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };