@ayepi/rate 0.4.1 → 0.5.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
@@ -74,7 +74,7 @@ if (!allowed) {
74
74
  await lim.reset(userId) // clear a key
75
75
  ```
76
76
 
77
- - `limiter(opts)` → `{ check(key, now?), reset(key), rule }` — the actual limiting
77
+ - `limiter(opts)` → `{ check(key, { now?, cost? }?), reset(key), rule }` — the actual limiting
78
78
  (pluggable store + algorithm), no HTTP involved.
79
79
  - `rateLimitResponse(info, opts?)` → a `Response` (status/message/headers), if you
80
80
  want one.
@@ -86,8 +86,8 @@ await lim.reset(userId) // clear a key
86
86
  `rateLimitedDoer` is a [`Doer`](https://www.npmjs.com/package/@ayepi/core) (from
87
87
  `@ayepi/core/doer`) that caps the **start rate** of tasks through the same `limiter()`
88
88
  primitive — so an `@ayepi/work` engine processes work no faster than a budget allows.
89
- It also stays on the main `@ayepi/rate` entry. Excess tasks wait, oldest-first, and a
90
- distributed store limits across a fleet:
89
+ It also stays on the main `@ayepi/rate` entry. Excess tasks wait and are admitted oldest-first
90
+ (or most urgent first with `order: 'priority'`), and a distributed store limits across a fleet:
91
91
 
92
92
  ```ts
93
93
  import { rateLimitedDoer } from '@ayepi/rate'
@@ -30,20 +30,29 @@ interface RateLimitRule {
30
30
  }
31
31
 
32
32
  interface RateLimitStore {
33
- /** Record a hit for `key` under `rule` at time `now` (ms) and return the decision. */
34
- consume(key: string, rule: RateLimitRule, now: number): MaybePromise<RateLimitResult>;
33
+ /**
34
+ * Record a hit of weight `cost` for `key` under `rule` at time `now` (ms) and return the decision.
35
+ * Allowed when the window's total plus `cost` stays within `rule.limit` (token-bucket: the bucket holds
36
+ * `cost` tokens). A cost of 0 records nothing and reports the current state.
37
+ */
38
+ consume(key: string, rule: RateLimitRule, now: number, cost: number): MaybePromise<RateLimitResult>;
35
39
  /** Clear all state for `key` (optional). */
36
40
  reset?(key: string): MaybePromise<void>;
37
41
  }
38
42
  ```
39
43
 
40
- A custom store should honor `rule.countRejected`: when it is falsy (the default), a request
41
- the store **rejects** must not consume budget (don't persist its increment). The bundled
44
+ A custom store should honor `cost` and `rule.countRejected`: a hit adds `cost` (not 1) to the
45
+ window, and when `countRejected` is falsy (the default), a hit the store **rejects** must not
46
+ consume budget (don't persist its increment). The bundled
42
47
  `memoryStore` and `redisStore` both do this; `token-bucket` is naturally exempt (it never
43
48
  charges a request it can't admit).
44
49
 
45
50
  The **algorithm lives in the store**, not in the limiter — that is what keeps the limit
46
- correct across instances. Implement `RateLimitStore` for any backend (Postgres, DynamoDB,
51
+ correct across instances. A store that can read and write per-key cells atomically (a database
52
+ transaction) can reuse the bundled algorithms: `applyHit(state, key, rule, now, cost)` runs one hit
53
+ against `state = { counters, buckets }`, two Map-like tables (`get`/`set`) of `{ count, reset }`
54
+ window counters and `{ tokens, ts }` token buckets, writing every changed cell back with `set`.
55
+ `memoryStore` runs it on Maps; `@ayepi/sqlite`'s `sqliteRateStore` on rows inside one transaction. Implement `RateLimitStore` for any backend (Postgres, DynamoDB,
47
56
  Memcached, …); only `consume` is required.
48
57
 
49
58
  ### `memoryStore` (default, bundled)
@@ -144,6 +153,8 @@ interface RateLimitedDoerOptions extends LimiterOptions {
144
153
  /** Re-fire `onBacklog` every this many ms while still backed up (omit to fire once). */
145
154
  readonly backlogEveryMs?: number;
146
155
 
156
+ /** Which waiting task is admitted next: 'oldest' (default) or 'priority' (highest priority, then oldest). */
157
+ readonly order?: 'oldest' | 'priority';
147
158
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
148
159
  readonly retryFloor?: number;
149
160
  /** Clock injection (default `Date.now`). */
@@ -157,6 +168,7 @@ interface RateTaskOptions extends DoerTaskOptions {
157
168
  readonly groupLimit?: number; // overrides the configured default; enables the group gate for this task
158
169
  readonly groupWindow?: number; // default: the global `window`
159
170
  readonly groupAlgorithm?: Algorithm; // default: the global `algorithm`
171
+ readonly cost?: number; // weight against both gates (default 1)
160
172
  }
161
173
 
162
174
  /** A Doer whose `do` accepts RateTaskOptions (assignable to Doer). */
@@ -177,9 +189,22 @@ A `Doer` (from `@ayepi/core/doer`) that caps the **start rate** of tasks using t
177
189
  `limiter()` primitive (and the same pluggable store/algorithm) the middleware uses. It does
178
190
  **not** run tasks itself — when the limiter admits a task it hands it to an **inner doer**
179
191
  (default `unlimitedDoer()`), so you can compose a rate cap with a concurrency/ordering
180
- policy. Excess tasks wait, **oldest-first**; with a distributed store this rate-limits
192
+ policy. Excess tasks wait and are admitted **oldest-first**, or with `order: 'priority'` the
193
+ highest `priority` first (then the oldest); with a distributed store this rate-limits
181
194
  **across a fleet**.
182
195
 
196
+ ```ts
197
+ // urgent calls never queue behind a rate-limited backlog: priority order here, a concurrency cap inside
198
+ const llm = rateLimitedDoer({ limit: 60, window: 60_000, order: 'priority', doer: priorityDoer({ max: 4, buffer: 0 }) })
199
+ llm.do(() => answerUser(), { priority: 10 })
200
+ llm.do(() => summarizeInBackground(), { priority: 0 })
201
+ ```
202
+
203
+ The inner doer's `available()` gates admission, so a task waits here (in `order`) rather than in
204
+ the inner doer, and the rate budget is spent only when the inner doer can take it. A bounded inner
205
+ doer accepts `max + buffer` tasks; give it `buffer: 0` so tasks are admitted only into a free slot
206
+ and a later, more urgent task is never passed by one already buffered.
207
+
183
208
  A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section
184
209
  below. `rateLimitedDoer`'s `available()` is `min(limit − pending, inner.available())`.
185
210
 
@@ -208,7 +233,7 @@ Semantics:
208
233
  consumes nothing (the group rule forces `countRejected: false`), so a task whose group is at
209
234
  its limit is **skipped**, not admitted — and no global token is spent on it.
210
235
  - **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.
236
+ groups' tasks proceed**, so a hot user/model can't block everyone. Within a group, tasks go in `order`.
212
237
  - **Global exhaustion stops the round.** When the shared global bucket is out, nothing more is
213
238
  admitted this pass. Edge case: a task that cleared its group but then hits an exhausted global
214
239
  had its group token spent (bounded, self-corrects on retry) — only when global is the bottleneck.
@@ -329,7 +354,8 @@ was rejected).
329
354
  ### Store consultation
330
355
 
331
356
  `limiter()` builds a `RateLimitRule` from `{ limit, window, algorithm }`, prepends
332
- `prefix` (default `'rl:'`) to the key, and calls `store.consume(prefixedKey, rule, now)`.
357
+ `prefix` (default `'rl:'`) to the key, and calls `store.consume(prefixedKey, rule, now, cost)`
358
+ (`cost` from `check(key, { cost })`, default 1; a negative or non-finite cost throws a `RangeError`).
333
359
  The store records the hit and returns `{ allowed, limit, remaining, reset, retryAfter }`.
334
360
  The limiter is stateless beyond its rule + store reference — all per-key state lives in the
335
361
  store, which is why a distributed store gives a distributed limit.
@@ -340,7 +366,8 @@ store, which is why a distributed store gives a distributed limit.
340
366
 
341
367
  1. builds `kio = { req, ctx }`;
342
368
  2. if `skip(kio)` → calls `io.next({ ratelimit: <full budget> })` (admit, no store hit);
343
- 3. otherwise `await limiter.check(key(kio))`;
369
+ 3. otherwise resolves `cost` (default 1) and `await store.consume(prefix + key(kio), rule, now, cost)`
370
+ (a bad cost throws before the store is consulted, so `failOpen` never hides it);
344
371
  4. if **not** allowed → returns `rateLimitResponse(info, { status, headers, message })` —
345
372
  a `Response`, which `@ayepi/core` treats as a **short-circuit**: the rest of the chain
346
373
  and the handler are skipped (HTTP sends the `Response`; ws turns it into an error
@@ -354,14 +381,14 @@ limiter's dependency middleware run first and their context is available — see
354
381
 
355
382
  ### The doer abstraction
356
383
 
357
- `rateLimitedDoer` keeps a `pending` queue. Each `drain` scans it **oldest-first** and admits
384
+ `rateLimitedDoer` keeps a `pending` queue. Each `drain` scans it in `order` (**oldest-first**, or priority then age) and admits
358
385
  every task that clears its gates, skipping (not blocking on) group-capped ones:
359
386
 
360
387
  - 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
388
+ - for the first not-yet-skipped task in `order`, it checks the **group gate first** (when a `group` + group
362
389
  limit apply): a denied group check consumes nothing, so the task is **skipped** (left pending) and
363
390
  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
391
+ - then the **global gate** (`limiter.check(key, { now, cost })`): if denied, the shared bucket is out, so no
365
392
  further task can be admitted this round and the scan stops (recording the global `retryAfter`);
366
393
  - if both allow, it removes the task and calls `inner.do(task.run, task.opts)`.
367
394
 
package/ayepi-rate.md CHANGED
@@ -109,7 +109,12 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
109
109
  | `RateLimitDefOptions` | interface | Options for the `rateLimit` def (`name`/`requires`). |
110
110
  | `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer` (global + group limits, backlog watch). |
111
111
  | `RateLimitedDoer` | interface | The doer `rateLimitedDoer` returns (a `Doer` whose `do` takes `RateTaskOptions`). |
112
- | `RateTaskOptions` | interface | Per-task options — `DoerTaskOptions` + `groupLimit`/`groupWindow`/`groupAlgorithm`. |
112
+ | `RateTaskOptions` | interface | Per-task options — `DoerTaskOptions` + `groupLimit`/`groupWindow`/`groupAlgorithm`/`cost`. |
113
+ | `applyHit` | function | The single implementation of all three algorithms over a `RateState` (Map-like cells); `memoryStore` runs it on Maps, `@ayepi/sqlite`'s store on rows. |
114
+ | `RateState` / `RateCells` / `RateCounter` / `RateBucket` | interfaces | The cells `applyHit` reads and writes back: window counters `{ count, reset }` and token buckets `{ tokens, ts }`. |
115
+ | `RateHit` | interface | One hit for `limiter.check`: `{ now?, cost? }`. |
116
+ | `hitCost` | function | Validate a hit's cost (default 1); throws `RangeError` when negative or non-finite. |
117
+ | `DEFAULT_COST` | const | `1` — what a hit costs when no cost is given. |
113
118
  | `RateLimitedBacklogInfo` | interface | `{ pending, nonEmptyForMs }` passed to the doer's `onBacklog`. |
114
119
  | `MemoryStoreOptions` | interface | `{ sweepEvery?, idleMs? }` — tune `memoryStore` eviction. |
115
120
  | `Limiter` | interface | The object `limiter()` returns. |
@@ -190,6 +195,8 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
190
195
  readonly algorithm?: Dynamic<M, Algorithm>;
191
196
  /** Count rejected (over-limit) requests against the limit (default `false`) — fixed, or per request. */
192
197
  readonly countRejected?: Dynamic<M, boolean>;
198
+ /** What a request costs against the limit (default 1) — fixed, or per request. See "Weighted hits". */
199
+ readonly cost?: Dynamic<M, number>;
193
200
  /** Backend store (default an in-process `memoryStore`). */
194
201
  readonly store?: RateLimitStore;
195
202
  /** Key prefix/namespace (default `'rl:'`). */
@@ -395,8 +402,8 @@ plain handler, a worker, a CLI, or another framework.
395
402
  function limiter(opts: LimiterOptions): Limiter
396
403
 
397
404
  interface Limiter {
398
- /** Record a hit for `key` (at `now`, default `Date.now()`) and return the decision. */
399
- check(key: string, now?: number): MaybePromise<RateLimitResult>;
405
+ /** Record a hit for `key` and return the decision. */
406
+ check(key: string, hit?: RateHit): MaybePromise<RateLimitResult>;
400
407
  /** Clear all state for `key`. */
401
408
  reset(key: string): MaybePromise<void>;
402
409
  /** The rule this limiter enforces. */
@@ -420,6 +427,32 @@ await lim.reset(userId) // clear a key
420
427
  `check` may return a value or a promise depending on the store (`memoryStore` is sync,
421
428
  `redisStore` is async) — `await` it to handle both.
422
429
 
430
+ ### Weighted hits
431
+
432
+ A hit can weigh more (or less) than one request, so a limit can count LLM tokens, bytes or money
433
+ instead of calls. The limit and the costs share one unit; for money use whole small units such as
434
+ micro-dollars so totals stay exact.
435
+
436
+ ```ts
437
+ interface RateHit {
438
+ readonly now?: number; // when (ms; default Date.now())
439
+ readonly cost?: number; // weight in the limit's units (default 1); 0 = report the state, record nothing
440
+ }
441
+
442
+ const spend = limiter({ limit: 5_000_000, window: 86_400_000 }) // 5 dollars a day, in micro-dollars
443
+ const { allowed, remaining } = await spend.check(userId, { cost: 12_500 })
444
+ ```
445
+
446
+ - A hit is allowed when the window's total plus its cost fits the limit (token-bucket: when the
447
+ bucket holds that many tokens); a refused hit records nothing unless `countRejected` is set.
448
+ - A cost above the limit is never allowed. A negative or non-finite cost throws a `RangeError`
449
+ (`hitCost(cost)` is the check, exported).
450
+ - `remaining` is exact for `fixed-window` and whole units (rounded down) for `sliding-window` and
451
+ `token-bucket`.
452
+ - The middleware takes `cost` (fixed or `(io) => number`), and `rateLimitedDoer` takes a per-task
453
+ `cost` that counts against both its global and its group limit (`ayepi-rate-stores-doer.md`).
454
+ - `memoryStore` and `redisStore` both support fractional costs (Redis via `INCRBYFLOAT`).
455
+
423
456
  ### `rateLimitResponse`
424
457
 
425
458
  ```ts
package/dist/index.cjs CHANGED
@@ -42,14 +42,20 @@ const DEFAULT_STATUS = 429;
42
42
  const DEFAULT_PREFIX = "rl:";
43
43
  /** Default algorithm. */
44
44
  const DEFAULT_ALGORITHM = "fixed-window";
45
- /** Tokens consumed per request (token-bucket). */
46
- const TOKEN_COST = 1;
45
+ /** What one hit costs when no cost is given: one request. */
46
+ const DEFAULT_COST = 1;
47
47
  /** Milliseconds per second — `Retry-After` / `RateLimit-Reset` are expressed in seconds. */
48
48
  const MS_PER_SECOND = 1e3;
49
49
  /** Amortized cleanup: sweep expired in-memory entries once every this many `consume` calls. */
50
50
  const SWEEP_EVERY = 1e3;
51
51
  /** Drop an idle in-memory token bucket after this long with no activity (it refills to full anyway). */
52
52
  const BUCKET_IDLE_MS = 600 * 1e3;
53
+ /** The cost of a hit: `cost` or {@link DEFAULT_COST}. Throws a `RangeError` for a negative or non-finite cost. */
54
+ function hitCost(cost) {
55
+ const c = cost ?? 1;
56
+ if (!Number.isFinite(c) || c < 0) throw new RangeError(`rate-limit cost must be a finite number >= 0, got ${c}`);
57
+ return c;
58
+ }
53
59
  /**
54
60
  * Create a standalone {@link Limiter} — the rate-limit primitive the
55
61
  * {@link rateLimit} middleware is built on. Use it anywhere you have a key: a
@@ -59,6 +65,10 @@ const BUCKET_IDLE_MS = 600 * 1e3;
59
65
  * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
60
66
  * const { allowed, retryAfter } = await lim.check(userId)
61
67
  * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
68
+ *
69
+ * // a budget: 5 dollars a day, counted in micro-dollars
70
+ * const spend = limiter({ limit: 5_000_000, window: 86_400_000 })
71
+ * const { allowed: affordable } = await spend.check(userId, { cost: 12_500 })
62
72
  * ```
63
73
  */
64
74
  function limiter(opts) {
@@ -72,7 +82,7 @@ function limiter(opts) {
72
82
  const prefix = opts.prefix ?? "rl:";
73
83
  return {
74
84
  rule,
75
- check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
85
+ check: (key, hit) => store.consume(prefix + key, rule, hit?.now ?? Date.now(), hitCost(hit?.cost)),
76
86
  reset: (key) => Promise.resolve(store.reset?.(prefix + key))
77
87
  };
78
88
  }
@@ -136,13 +146,19 @@ function rateLimit(opts) {
136
146
  }
137
147
  /** Minimum re-check delay when a deferred task has no explicit retry hint (ms). */
138
148
  const DOER_RETRY_FLOOR = 50;
149
+ /** Whether `a` is admitted before `b` under `order`; equal ties keep insertion order (the earlier `seq`). */
150
+ const admitsBefore = {
151
+ oldest: (a, b) => a.createdAt < b.createdAt,
152
+ priority: (a, b) => a.priority !== b.priority ? a.priority > b.priority : a.createdAt < b.createdAt
153
+ };
139
154
  /**
140
155
  * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
141
156
  * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
142
157
  * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
143
158
  * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
144
159
  * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
145
- * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
160
+ * Excess tasks wait and are admitted oldest-first, or most urgent first with `order: 'priority'`; a
161
+ * distributed store rate-limits **across a fleet**.
146
162
  *
147
163
  * ```ts
148
164
  * import { rateLimitedDoer } from '@ayepi/rate'
@@ -167,6 +183,7 @@ function rateLimitedDoer(opts) {
167
183
  const inner = opts.doer ?? (0, _ayepi_core_doer.unlimitedDoer)();
168
184
  const now = opts.now ?? Date.now;
169
185
  const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
186
+ const before = admitsBefore[opts.order ?? "oldest"];
170
187
  const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
171
188
  const groupStore = opts.groupStore ?? store;
172
189
  const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
@@ -232,14 +249,14 @@ function rateLimitedDoer(opts) {
232
249
  timer.unref?.();
233
250
  };
234
251
  /**
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.
252
+ * The first pending task under `order` not skipped this round (a fresh scan, so tasks queued mid-drain are
253
+ * seen). `pending` is in insertion (seq) order, so equal ties keep the earliest.
237
254
  */
238
255
  const nextPending = (skipped) => {
239
256
  let best;
240
257
  for (const t of pending) {
241
258
  if (skipped.has(t)) continue;
242
- if (!best || t.createdAt < best.createdAt) best = t;
259
+ if (!best || before(t, best)) best = t;
243
260
  }
244
261
  return best;
245
262
  };
@@ -262,7 +279,7 @@ function rateLimitedDoer(opts) {
262
279
  if (groupRule && gk !== void 0) {
263
280
  let gr;
264
281
  try {
265
- gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
282
+ gr = await groupStore.consume(groupPrefix + gk, groupRule, now(), hitCost(o?.cost));
266
283
  } catch (err) {
267
284
  report(err);
268
285
  soonest = Math.min(soonest, floor);
@@ -276,7 +293,10 @@ function rateLimitedDoer(opts) {
276
293
  }
277
294
  let res;
278
295
  try {
279
- res = await lim.check(keyOf(o), now());
296
+ res = await lim.check(keyOf(o), {
297
+ now: now(),
298
+ cost: o?.cost
299
+ });
280
300
  } catch (err) {
281
301
  report(err);
282
302
  soonest = Math.min(soonest, floor);
@@ -302,6 +322,7 @@ function rateLimitedDoer(opts) {
302
322
  pending.push({
303
323
  run: task,
304
324
  opts: o,
325
+ priority: o?.priority ?? 0,
305
326
  createdAt: o?.createdAt ?? now(),
306
327
  seq: seq++
307
328
  });
@@ -311,43 +332,57 @@ function rateLimitedDoer(opts) {
311
332
  done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
312
333
  };
313
334
  }
314
- function fixedWindow(counters, key, rule, now) {
315
- let e = counters.get(key);
316
- if (!e || e.reset <= now) {
317
- e = {
318
- count: 0,
319
- reset: now + rule.window
320
- };
321
- counters.set(key, e);
335
+ /**
336
+ * Run one hit of weight `cost` for `key` under `rule` at `now` against `state`: the single implementation of all three
337
+ * algorithms, which {@link memoryStore} runs on Maps and a database-backed {@link RateLimitStore} can run on rows
338
+ * (inside one transaction, so the read and the write-back are atomic). Every changed cell is written back with `set`.
339
+ */
340
+ function applyHit(state, key, rule, now, cost) {
341
+ switch (rule.algorithm) {
342
+ case "sliding-window": return slidingWindow(state.counters, key, rule, now, cost);
343
+ case "token-bucket": return tokenBucket(state.buckets, key, rule, now, cost);
344
+ default: return fixedWindow(state.counters, key, rule, now, cost);
322
345
  }
323
- const allowed = e.count < rule.limit;
324
- if (allowed || rule.countRejected) e.count++;
346
+ }
347
+ function fixedWindow(counters, key, rule, now, cost) {
348
+ const found = counters.get(key);
349
+ const e = !found || found.reset <= now ? {
350
+ count: 0,
351
+ reset: now + rule.window
352
+ } : found;
353
+ const allowed = e.count + cost <= rule.limit;
354
+ const count = allowed || rule.countRejected ? e.count + cost : e.count;
355
+ counters.set(key, {
356
+ count,
357
+ reset: e.reset
358
+ });
325
359
  const reset = e.reset - now;
326
360
  return {
327
361
  allowed,
328
362
  limit: rule.limit,
329
- remaining: Math.max(0, rule.limit - e.count),
363
+ remaining: Math.max(0, rule.limit - count),
330
364
  reset,
331
365
  retryAfter: allowed ? 0 : reset
332
366
  };
333
367
  }
334
- function slidingWindow(counters, key, rule, now) {
368
+ function slidingWindow(counters, key, rule, now, cost) {
335
369
  const windowStart = Math.floor(now / rule.window) * rule.window;
336
370
  const curKey = `${key}|${windowStart}`;
337
371
  const prevKey = `${key}|${windowStart - rule.window}`;
338
- let cur = counters.get(curKey);
339
- if (!cur || cur.reset <= now) {
340
- cur = {
341
- count: 0,
342
- reset: windowStart + rule.window
343
- };
344
- counters.set(curKey, cur);
345
- }
372
+ const found = counters.get(curKey);
373
+ const cur = !found || found.reset <= now ? {
374
+ count: 0,
375
+ reset: windowStart + rule.window
376
+ } : found;
346
377
  const prevCount = counters.get(prevKey)?.count ?? 0;
347
378
  const weight = (rule.window - (now - windowStart)) / rule.window;
348
- const allowed = prevCount * weight + cur.count + 1 <= rule.limit;
349
- if (allowed || rule.countRejected) cur.count++;
350
- const weighted = prevCount * weight + cur.count;
379
+ const allowed = prevCount * weight + cur.count + cost <= rule.limit;
380
+ const count = allowed || rule.countRejected ? cur.count + cost : cur.count;
381
+ counters.set(curKey, {
382
+ count,
383
+ reset: cur.reset
384
+ });
385
+ const weighted = prevCount * weight + count;
351
386
  const reset = windowStart + rule.window - now;
352
387
  return {
353
388
  allowed,
@@ -357,31 +392,27 @@ function slidingWindow(counters, key, rule, now) {
357
392
  retryAfter: allowed ? 0 : reset
358
393
  };
359
394
  }
360
- function tokenBucket(buckets, key, rule, now) {
395
+ function tokenBucket(buckets, key, rule, now, cost) {
361
396
  const cap = rule.limit;
362
397
  const refillPerMs = rule.limit / rule.window;
363
- let b = buckets.get(key);
364
- if (!b) b = {
398
+ const b = buckets.get(key) ?? {
365
399
  tokens: cap,
366
400
  ts: now
367
401
  };
368
- b.tokens = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
369
- b.ts = now;
370
- const cost = TOKEN_COST;
371
- let allowed = false;
372
- if (b.tokens >= cost) {
373
- b.tokens -= cost;
374
- allowed = true;
375
- }
376
- buckets.set(key, b);
377
- const remaining = Math.floor(b.tokens);
378
- const retryAfter = allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerMs);
379
- const reset = Math.ceil((cap - b.tokens) / refillPerMs);
402
+ const level = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
403
+ const allowed = level >= cost;
404
+ const tokens = allowed ? level - cost : level;
405
+ buckets.set(key, {
406
+ tokens,
407
+ ts: now
408
+ });
409
+ const remaining = Math.floor(tokens);
410
+ const retryAfter = allowed ? 0 : Math.ceil((cost - tokens) / refillPerMs);
380
411
  return {
381
412
  allowed,
382
413
  limit: cap,
383
414
  remaining,
384
- reset,
415
+ reset: Math.ceil((cap - tokens) / refillPerMs),
385
416
  retryAfter
386
417
  };
387
418
  }
@@ -401,6 +432,10 @@ function memoryStore(opts = {}) {
401
432
  const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
402
433
  const counters = /* @__PURE__ */ new Map();
403
434
  const buckets = /* @__PURE__ */ new Map();
435
+ const state = {
436
+ counters,
437
+ buckets
438
+ };
404
439
  let ops = 0;
405
440
  const sweep = (now) => {
406
441
  if (++ops % sweepEvery !== 0) return;
@@ -408,13 +443,9 @@ function memoryStore(opts = {}) {
408
443
  for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
409
444
  };
410
445
  return {
411
- consume(key, rule, now) {
446
+ consume(key, rule, now, cost) {
412
447
  sweep(now);
413
- switch (rule.algorithm) {
414
- case "sliding-window": return slidingWindow(counters, key, rule, now);
415
- case "token-bucket": return tokenBucket(buckets, key, rule, now);
416
- default: return fixedWindow(counters, key, rule, now);
417
- }
448
+ return applyHit(state, key, rule, now, cost);
418
449
  },
419
450
  reset(key) {
420
451
  counters.delete(key);
@@ -425,7 +456,10 @@ function memoryStore(opts = {}) {
425
456
  }
426
457
  //#endregion
427
458
  exports.DEFAULT_ALGORITHM = DEFAULT_ALGORITHM;
459
+ exports.DEFAULT_COST = DEFAULT_COST;
428
460
  exports.DEFAULT_PREFIX = DEFAULT_PREFIX;
461
+ exports.applyHit = applyHit;
462
+ exports.hitCost = hitCost;
429
463
  exports.limiter = limiter;
430
464
  exports.memoryStore = memoryStore;
431
465
  exports.rateLimit = rateLimit;
package/dist/index.d.cts CHANGED
@@ -7,13 +7,18 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
7
7
  declare const DEFAULT_PREFIX = "rl:";
8
8
  /** Default algorithm. */
9
9
  declare const DEFAULT_ALGORITHM: Algorithm;
10
+ /** What one hit costs when no cost is given: one request. */
11
+ declare const DEFAULT_COST = 1;
10
12
  /** The rate-limiting algorithm. */
11
13
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
12
14
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
13
15
  interface RateLimitInfo {
14
- /** The configured request limit for the window. */
16
+ /** The configured limit for the window, in cost units (requests by default). */
15
17
  readonly limit: number;
16
- /** Requests (or tokens) remaining before the limit is hit. */
18
+ /**
19
+ * Cost units remaining before the limit is hit. Whole units for `sliding-window` and `token-bucket` (rounded down);
20
+ * exact for `fixed-window`.
21
+ */
17
22
  readonly remaining: number;
18
23
  /** Milliseconds until the window/bucket resets. */
19
24
  readonly reset: number;
@@ -45,8 +50,12 @@ interface RateLimitRule {
45
50
  * (the in-memory store is single-process; `@ayepi/rate/redis` is distributed).
46
51
  */
47
52
  interface RateLimitStore {
48
- /** Record a hit for `key` under `rule` at time `now` (ms) and return the decision. */
49
- consume(key: string, rule: RateLimitRule, now: number): MaybePromise<RateLimitResult>;
53
+ /**
54
+ * Record a hit of weight `cost` for `key` under `rule` at time `now` (ms) and return the decision. The hit is
55
+ * allowed when the window's total plus `cost` stays within `rule.limit` (for `token-bucket`: when the bucket
56
+ * holds `cost` tokens). A cost of `0` records nothing and reports the current state.
57
+ */
58
+ consume(key: string, rule: RateLimitRule, now: number, cost: number): MaybePromise<RateLimitResult>;
50
59
  /** Clear all state for `key` (optional). */
51
60
  reset?(key: string): MaybePromise<void>;
52
61
  }
@@ -98,10 +107,23 @@ interface RateLimitDefOptions<R extends readonly AnyMiddleware[]> {
98
107
  /** Middleware name for docs/debugging (default `'rateLimit'`). */
99
108
  readonly name?: string;
100
109
  }
110
+ /** One hit against a {@link Limiter}. */
111
+ interface RateHit {
112
+ /** When the hit happens (ms; default `Date.now()`). */
113
+ readonly now?: number;
114
+ /**
115
+ * The hit's weight in the limit's units (default {@link DEFAULT_COST}, one request). Weight hits to make a limit
116
+ * count tokens, bytes or money instead of calls; for money, use whole small units (e.g. micro-dollars) so totals
117
+ * stay exact. `0` reports the current state without recording anything. A cost above the limit is never allowed.
118
+ */
119
+ readonly cost?: number;
120
+ }
121
+ /** The cost of a hit: `cost` or {@link DEFAULT_COST}. Throws a `RangeError` for a negative or non-finite cost. */
122
+ declare function hitCost(cost: number | undefined): number;
101
123
  /** A reusable rate limiter bound to a rule + store — usable anywhere, not just middleware. */
102
124
  interface Limiter {
103
- /** Record a hit for `key` (at `now`, default `Date.now()`) and return the decision. */
104
- check(key: string, now?: number): MaybePromise<RateLimitResult>;
125
+ /** Record a hit for `key` (see {@link RateHit}: when, and how much it costs) and return the decision. */
126
+ check(key: string, hit?: RateHit): MaybePromise<RateLimitResult>;
105
127
  /** Clear all state for `key`. */
106
128
  reset(key: string): MaybePromise<void>;
107
129
  /** The rule this limiter enforces. */
@@ -116,6 +138,10 @@ interface Limiter {
116
138
  * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
117
139
  * const { allowed, retryAfter } = await lim.check(userId)
118
140
  * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
141
+ *
142
+ * // a budget: 5 dollars a day, counted in micro-dollars
143
+ * const spend = limiter({ limit: 5_000_000, window: 86_400_000 })
144
+ * const { allowed: affordable } = await spend.check(userId, { cost: 12_500 })
119
145
  * ```
120
146
  */
121
147
  declare function limiter(opts: LimiterOptions): Limiter;
@@ -167,6 +193,11 @@ interface RateTaskOptions extends DoerTaskOptions {
167
193
  readonly groupWindow?: number;
168
194
  /** Algorithm for the group limit (default: the global `algorithm`). */
169
195
  readonly groupAlgorithm?: Algorithm;
196
+ /**
197
+ * The task's weight against both the global and the group limit (default {@link DEFAULT_COST}). A task waits
198
+ * until both limits have room for its whole cost; see {@link RateHit.cost}.
199
+ */
200
+ readonly cost?: number;
170
201
  }
171
202
  /** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
172
203
  interface RateLimitedDoer extends Doer {
@@ -193,6 +224,12 @@ interface RateLimitedDoerOptions extends LimiterOptions {
193
224
  * pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
194
225
  */
195
226
  readonly groupStore?: RateLimitStore;
227
+ /**
228
+ * Which waiting task is admitted next: `'oldest'` (default) by `createdAt`, or `'priority'` — the highest
229
+ * {@link DoerTaskOptions.priority} first, then the oldest. With `'priority'` an urgent task never waits behind a
230
+ * backlog of less urgent ones held by the rate limit. Either way a task whose group is at its limit is skipped.
231
+ */
232
+ readonly order?: RateDoerOrder;
196
233
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
197
234
  readonly retryFloor?: number;
198
235
  /** Clock injection (default `Date.now`). */
@@ -217,13 +254,16 @@ interface RateLimitedDoerOptions extends LimiterOptions {
217
254
  */
218
255
  readonly onError?: (err: unknown) => void;
219
256
  }
257
+ /** How {@link rateLimitedDoer} picks the next waiting task to admit; see {@link RateLimitedDoerOptions.order}. */
258
+ type RateDoerOrder = 'oldest' | 'priority';
220
259
  /**
221
260
  * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
222
261
  * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
223
262
  * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
224
263
  * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
225
264
  * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
226
- * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
265
+ * Excess tasks wait and are admitted oldest-first, or most urgent first with `order: 'priority'`; a
266
+ * distributed store rate-limits **across a fleet**.
227
267
  *
228
268
  * ```ts
229
269
  * import { rateLimitedDoer } from '@ayepi/rate'
@@ -240,6 +280,32 @@ interface RateLimitedDoerOptions extends LimiterOptions {
240
280
  * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
241
281
  */
242
282
  declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
283
+ /** A window's running total (`fixed-window`, and each bucket of `sliding-window`), and when the window ends (epoch ms). */
284
+ interface RateCounter {
285
+ readonly count: number;
286
+ readonly reset: number;
287
+ }
288
+ /** A token bucket's level, as of `ts` (epoch ms). */
289
+ interface RateBucket {
290
+ readonly tokens: number;
291
+ readonly ts: number;
292
+ }
293
+ /** A per-key table of cells an algorithm reads and writes back. A `Map` is one. */
294
+ interface RateCells<V> {
295
+ get(key: string): V | undefined;
296
+ set(key: string, value: V): void;
297
+ }
298
+ /** The cells {@link applyHit} works on: window counters and token buckets. */
299
+ interface RateState {
300
+ readonly counters: RateCells<RateCounter>;
301
+ readonly buckets: RateCells<RateBucket>;
302
+ }
303
+ /**
304
+ * Run one hit of weight `cost` for `key` under `rule` at `now` against `state`: the single implementation of all three
305
+ * algorithms, which {@link memoryStore} runs on Maps and a database-backed {@link RateLimitStore} can run on rows
306
+ * (inside one transaction, so the read and the write-back are atomic). Every changed cell is written back with `set`.
307
+ */
308
+ declare function applyHit(state: RateState, key: string, rule: RateLimitRule, now: number, cost: number): RateLimitResult;
243
309
  /** Options for {@link memoryStore}. */
244
310
  interface MemoryStoreOptions {
245
311
  /**
@@ -268,4 +334,4 @@ interface MemoryStoreOptions {
268
334
  */
269
335
  declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
270
336
  //#endregion
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 };
337
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_COST, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateBucket, RateCells, RateCounter, RateDoerOrder, RateHit, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateState, RateTaskOptions, applyHit, hitCost, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.d.ts CHANGED
@@ -7,13 +7,18 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
7
7
  declare const DEFAULT_PREFIX = "rl:";
8
8
  /** Default algorithm. */
9
9
  declare const DEFAULT_ALGORITHM: Algorithm;
10
+ /** What one hit costs when no cost is given: one request. */
11
+ declare const DEFAULT_COST = 1;
10
12
  /** The rate-limiting algorithm. */
11
13
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
12
14
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
13
15
  interface RateLimitInfo {
14
- /** The configured request limit for the window. */
16
+ /** The configured limit for the window, in cost units (requests by default). */
15
17
  readonly limit: number;
16
- /** Requests (or tokens) remaining before the limit is hit. */
18
+ /**
19
+ * Cost units remaining before the limit is hit. Whole units for `sliding-window` and `token-bucket` (rounded down);
20
+ * exact for `fixed-window`.
21
+ */
17
22
  readonly remaining: number;
18
23
  /** Milliseconds until the window/bucket resets. */
19
24
  readonly reset: number;
@@ -45,8 +50,12 @@ interface RateLimitRule {
45
50
  * (the in-memory store is single-process; `@ayepi/rate/redis` is distributed).
46
51
  */
47
52
  interface RateLimitStore {
48
- /** Record a hit for `key` under `rule` at time `now` (ms) and return the decision. */
49
- consume(key: string, rule: RateLimitRule, now: number): MaybePromise<RateLimitResult>;
53
+ /**
54
+ * Record a hit of weight `cost` for `key` under `rule` at time `now` (ms) and return the decision. The hit is
55
+ * allowed when the window's total plus `cost` stays within `rule.limit` (for `token-bucket`: when the bucket
56
+ * holds `cost` tokens). A cost of `0` records nothing and reports the current state.
57
+ */
58
+ consume(key: string, rule: RateLimitRule, now: number, cost: number): MaybePromise<RateLimitResult>;
50
59
  /** Clear all state for `key` (optional). */
51
60
  reset?(key: string): MaybePromise<void>;
52
61
  }
@@ -98,10 +107,23 @@ interface RateLimitDefOptions<R extends readonly AnyMiddleware[]> {
98
107
  /** Middleware name for docs/debugging (default `'rateLimit'`). */
99
108
  readonly name?: string;
100
109
  }
110
+ /** One hit against a {@link Limiter}. */
111
+ interface RateHit {
112
+ /** When the hit happens (ms; default `Date.now()`). */
113
+ readonly now?: number;
114
+ /**
115
+ * The hit's weight in the limit's units (default {@link DEFAULT_COST}, one request). Weight hits to make a limit
116
+ * count tokens, bytes or money instead of calls; for money, use whole small units (e.g. micro-dollars) so totals
117
+ * stay exact. `0` reports the current state without recording anything. A cost above the limit is never allowed.
118
+ */
119
+ readonly cost?: number;
120
+ }
121
+ /** The cost of a hit: `cost` or {@link DEFAULT_COST}. Throws a `RangeError` for a negative or non-finite cost. */
122
+ declare function hitCost(cost: number | undefined): number;
101
123
  /** A reusable rate limiter bound to a rule + store — usable anywhere, not just middleware. */
102
124
  interface Limiter {
103
- /** Record a hit for `key` (at `now`, default `Date.now()`) and return the decision. */
104
- check(key: string, now?: number): MaybePromise<RateLimitResult>;
125
+ /** Record a hit for `key` (see {@link RateHit}: when, and how much it costs) and return the decision. */
126
+ check(key: string, hit?: RateHit): MaybePromise<RateLimitResult>;
105
127
  /** Clear all state for `key`. */
106
128
  reset(key: string): MaybePromise<void>;
107
129
  /** The rule this limiter enforces. */
@@ -116,6 +138,10 @@ interface Limiter {
116
138
  * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
117
139
  * const { allowed, retryAfter } = await lim.check(userId)
118
140
  * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
141
+ *
142
+ * // a budget: 5 dollars a day, counted in micro-dollars
143
+ * const spend = limiter({ limit: 5_000_000, window: 86_400_000 })
144
+ * const { allowed: affordable } = await spend.check(userId, { cost: 12_500 })
119
145
  * ```
120
146
  */
121
147
  declare function limiter(opts: LimiterOptions): Limiter;
@@ -167,6 +193,11 @@ interface RateTaskOptions extends DoerTaskOptions {
167
193
  readonly groupWindow?: number;
168
194
  /** Algorithm for the group limit (default: the global `algorithm`). */
169
195
  readonly groupAlgorithm?: Algorithm;
196
+ /**
197
+ * The task's weight against both the global and the group limit (default {@link DEFAULT_COST}). A task waits
198
+ * until both limits have room for its whole cost; see {@link RateHit.cost}.
199
+ */
200
+ readonly cost?: number;
170
201
  }
171
202
  /** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
172
203
  interface RateLimitedDoer extends Doer {
@@ -193,6 +224,12 @@ interface RateLimitedDoerOptions extends LimiterOptions {
193
224
  * pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
194
225
  */
195
226
  readonly groupStore?: RateLimitStore;
227
+ /**
228
+ * Which waiting task is admitted next: `'oldest'` (default) by `createdAt`, or `'priority'` — the highest
229
+ * {@link DoerTaskOptions.priority} first, then the oldest. With `'priority'` an urgent task never waits behind a
230
+ * backlog of less urgent ones held by the rate limit. Either way a task whose group is at its limit is skipped.
231
+ */
232
+ readonly order?: RateDoerOrder;
196
233
  /** Floor on the re-check delay for deferred tasks (ms, default 50). */
197
234
  readonly retryFloor?: number;
198
235
  /** Clock injection (default `Date.now`). */
@@ -217,13 +254,16 @@ interface RateLimitedDoerOptions extends LimiterOptions {
217
254
  */
218
255
  readonly onError?: (err: unknown) => void;
219
256
  }
257
+ /** How {@link rateLimitedDoer} picks the next waiting task to admit; see {@link RateLimitedDoerOptions.order}. */
258
+ type RateDoerOrder = 'oldest' | 'priority';
220
259
  /**
221
260
  * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
222
261
  * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
223
262
  * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
224
263
  * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
225
264
  * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
226
- * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
265
+ * Excess tasks wait and are admitted oldest-first, or most urgent first with `order: 'priority'`; a
266
+ * distributed store rate-limits **across a fleet**.
227
267
  *
228
268
  * ```ts
229
269
  * import { rateLimitedDoer } from '@ayepi/rate'
@@ -240,6 +280,32 @@ interface RateLimitedDoerOptions extends LimiterOptions {
240
280
  * shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
241
281
  */
242
282
  declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
283
+ /** A window's running total (`fixed-window`, and each bucket of `sliding-window`), and when the window ends (epoch ms). */
284
+ interface RateCounter {
285
+ readonly count: number;
286
+ readonly reset: number;
287
+ }
288
+ /** A token bucket's level, as of `ts` (epoch ms). */
289
+ interface RateBucket {
290
+ readonly tokens: number;
291
+ readonly ts: number;
292
+ }
293
+ /** A per-key table of cells an algorithm reads and writes back. A `Map` is one. */
294
+ interface RateCells<V> {
295
+ get(key: string): V | undefined;
296
+ set(key: string, value: V): void;
297
+ }
298
+ /** The cells {@link applyHit} works on: window counters and token buckets. */
299
+ interface RateState {
300
+ readonly counters: RateCells<RateCounter>;
301
+ readonly buckets: RateCells<RateBucket>;
302
+ }
303
+ /**
304
+ * Run one hit of weight `cost` for `key` under `rule` at `now` against `state`: the single implementation of all three
305
+ * algorithms, which {@link memoryStore} runs on Maps and a database-backed {@link RateLimitStore} can run on rows
306
+ * (inside one transaction, so the read and the write-back are atomic). Every changed cell is written back with `set`.
307
+ */
308
+ declare function applyHit(state: RateState, key: string, rule: RateLimitRule, now: number, cost: number): RateLimitResult;
243
309
  /** Options for {@link memoryStore}. */
244
310
  interface MemoryStoreOptions {
245
311
  /**
@@ -268,4 +334,4 @@ interface MemoryStoreOptions {
268
334
  */
269
335
  declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
270
336
  //#endregion
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 };
337
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_COST, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateBucket, RateCells, RateCounter, RateDoerOrder, RateHit, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateState, RateTaskOptions, applyHit, hitCost, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.js CHANGED
@@ -41,14 +41,20 @@ const DEFAULT_STATUS = 429;
41
41
  const DEFAULT_PREFIX = "rl:";
42
42
  /** Default algorithm. */
43
43
  const DEFAULT_ALGORITHM = "fixed-window";
44
- /** Tokens consumed per request (token-bucket). */
45
- const TOKEN_COST = 1;
44
+ /** What one hit costs when no cost is given: one request. */
45
+ const DEFAULT_COST = 1;
46
46
  /** Milliseconds per second — `Retry-After` / `RateLimit-Reset` are expressed in seconds. */
47
47
  const MS_PER_SECOND = 1e3;
48
48
  /** Amortized cleanup: sweep expired in-memory entries once every this many `consume` calls. */
49
49
  const SWEEP_EVERY = 1e3;
50
50
  /** Drop an idle in-memory token bucket after this long with no activity (it refills to full anyway). */
51
51
  const BUCKET_IDLE_MS = 600 * 1e3;
52
+ /** The cost of a hit: `cost` or {@link DEFAULT_COST}. Throws a `RangeError` for a negative or non-finite cost. */
53
+ function hitCost(cost) {
54
+ const c = cost ?? 1;
55
+ if (!Number.isFinite(c) || c < 0) throw new RangeError(`rate-limit cost must be a finite number >= 0, got ${c}`);
56
+ return c;
57
+ }
52
58
  /**
53
59
  * Create a standalone {@link Limiter} — the rate-limit primitive the
54
60
  * {@link rateLimit} middleware is built on. Use it anywhere you have a key: a
@@ -58,6 +64,10 @@ const BUCKET_IDLE_MS = 600 * 1e3;
58
64
  * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
59
65
  * const { allowed, retryAfter } = await lim.check(userId)
60
66
  * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
67
+ *
68
+ * // a budget: 5 dollars a day, counted in micro-dollars
69
+ * const spend = limiter({ limit: 5_000_000, window: 86_400_000 })
70
+ * const { allowed: affordable } = await spend.check(userId, { cost: 12_500 })
61
71
  * ```
62
72
  */
63
73
  function limiter(opts) {
@@ -71,7 +81,7 @@ function limiter(opts) {
71
81
  const prefix = opts.prefix ?? "rl:";
72
82
  return {
73
83
  rule,
74
- check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
84
+ check: (key, hit) => store.consume(prefix + key, rule, hit?.now ?? Date.now(), hitCost(hit?.cost)),
75
85
  reset: (key) => Promise.resolve(store.reset?.(prefix + key))
76
86
  };
77
87
  }
@@ -135,13 +145,19 @@ function rateLimit(opts) {
135
145
  }
136
146
  /** Minimum re-check delay when a deferred task has no explicit retry hint (ms). */
137
147
  const DOER_RETRY_FLOOR = 50;
148
+ /** Whether `a` is admitted before `b` under `order`; equal ties keep insertion order (the earlier `seq`). */
149
+ const admitsBefore = {
150
+ oldest: (a, b) => a.createdAt < b.createdAt,
151
+ priority: (a, b) => a.priority !== b.priority ? a.priority > b.priority : a.createdAt < b.createdAt
152
+ };
138
153
  /**
139
154
  * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
140
155
  * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
141
156
  * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
142
157
  * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
143
158
  * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
144
- * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
159
+ * Excess tasks wait and are admitted oldest-first, or most urgent first with `order: 'priority'`; a
160
+ * distributed store rate-limits **across a fleet**.
145
161
  *
146
162
  * ```ts
147
163
  * import { rateLimitedDoer } from '@ayepi/rate'
@@ -166,6 +182,7 @@ function rateLimitedDoer(opts) {
166
182
  const inner = opts.doer ?? unlimitedDoer();
167
183
  const now = opts.now ?? Date.now;
168
184
  const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
185
+ const before = admitsBefore[opts.order ?? "oldest"];
169
186
  const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
170
187
  const groupStore = opts.groupStore ?? store;
171
188
  const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
@@ -231,14 +248,14 @@ function rateLimitedDoer(opts) {
231
248
  timer.unref?.();
232
249
  };
233
250
  /**
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.
251
+ * The first pending task under `order` not skipped this round (a fresh scan, so tasks queued mid-drain are
252
+ * seen). `pending` is in insertion (seq) order, so equal ties keep the earliest.
236
253
  */
237
254
  const nextPending = (skipped) => {
238
255
  let best;
239
256
  for (const t of pending) {
240
257
  if (skipped.has(t)) continue;
241
- if (!best || t.createdAt < best.createdAt) best = t;
258
+ if (!best || before(t, best)) best = t;
242
259
  }
243
260
  return best;
244
261
  };
@@ -261,7 +278,7 @@ function rateLimitedDoer(opts) {
261
278
  if (groupRule && gk !== void 0) {
262
279
  let gr;
263
280
  try {
264
- gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
281
+ gr = await groupStore.consume(groupPrefix + gk, groupRule, now(), hitCost(o?.cost));
265
282
  } catch (err) {
266
283
  report(err);
267
284
  soonest = Math.min(soonest, floor);
@@ -275,7 +292,10 @@ function rateLimitedDoer(opts) {
275
292
  }
276
293
  let res;
277
294
  try {
278
- res = await lim.check(keyOf(o), now());
295
+ res = await lim.check(keyOf(o), {
296
+ now: now(),
297
+ cost: o?.cost
298
+ });
279
299
  } catch (err) {
280
300
  report(err);
281
301
  soonest = Math.min(soonest, floor);
@@ -301,6 +321,7 @@ function rateLimitedDoer(opts) {
301
321
  pending.push({
302
322
  run: task,
303
323
  opts: o,
324
+ priority: o?.priority ?? 0,
304
325
  createdAt: o?.createdAt ?? now(),
305
326
  seq: seq++
306
327
  });
@@ -310,43 +331,57 @@ function rateLimitedDoer(opts) {
310
331
  done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
311
332
  };
312
333
  }
313
- function fixedWindow(counters, key, rule, now) {
314
- let e = counters.get(key);
315
- if (!e || e.reset <= now) {
316
- e = {
317
- count: 0,
318
- reset: now + rule.window
319
- };
320
- counters.set(key, e);
334
+ /**
335
+ * Run one hit of weight `cost` for `key` under `rule` at `now` against `state`: the single implementation of all three
336
+ * algorithms, which {@link memoryStore} runs on Maps and a database-backed {@link RateLimitStore} can run on rows
337
+ * (inside one transaction, so the read and the write-back are atomic). Every changed cell is written back with `set`.
338
+ */
339
+ function applyHit(state, key, rule, now, cost) {
340
+ switch (rule.algorithm) {
341
+ case "sliding-window": return slidingWindow(state.counters, key, rule, now, cost);
342
+ case "token-bucket": return tokenBucket(state.buckets, key, rule, now, cost);
343
+ default: return fixedWindow(state.counters, key, rule, now, cost);
321
344
  }
322
- const allowed = e.count < rule.limit;
323
- if (allowed || rule.countRejected) e.count++;
345
+ }
346
+ function fixedWindow(counters, key, rule, now, cost) {
347
+ const found = counters.get(key);
348
+ const e = !found || found.reset <= now ? {
349
+ count: 0,
350
+ reset: now + rule.window
351
+ } : found;
352
+ const allowed = e.count + cost <= rule.limit;
353
+ const count = allowed || rule.countRejected ? e.count + cost : e.count;
354
+ counters.set(key, {
355
+ count,
356
+ reset: e.reset
357
+ });
324
358
  const reset = e.reset - now;
325
359
  return {
326
360
  allowed,
327
361
  limit: rule.limit,
328
- remaining: Math.max(0, rule.limit - e.count),
362
+ remaining: Math.max(0, rule.limit - count),
329
363
  reset,
330
364
  retryAfter: allowed ? 0 : reset
331
365
  };
332
366
  }
333
- function slidingWindow(counters, key, rule, now) {
367
+ function slidingWindow(counters, key, rule, now, cost) {
334
368
  const windowStart = Math.floor(now / rule.window) * rule.window;
335
369
  const curKey = `${key}|${windowStart}`;
336
370
  const prevKey = `${key}|${windowStart - rule.window}`;
337
- let cur = counters.get(curKey);
338
- if (!cur || cur.reset <= now) {
339
- cur = {
340
- count: 0,
341
- reset: windowStart + rule.window
342
- };
343
- counters.set(curKey, cur);
344
- }
371
+ const found = counters.get(curKey);
372
+ const cur = !found || found.reset <= now ? {
373
+ count: 0,
374
+ reset: windowStart + rule.window
375
+ } : found;
345
376
  const prevCount = counters.get(prevKey)?.count ?? 0;
346
377
  const weight = (rule.window - (now - windowStart)) / rule.window;
347
- const allowed = prevCount * weight + cur.count + 1 <= rule.limit;
348
- if (allowed || rule.countRejected) cur.count++;
349
- const weighted = prevCount * weight + cur.count;
378
+ const allowed = prevCount * weight + cur.count + cost <= rule.limit;
379
+ const count = allowed || rule.countRejected ? cur.count + cost : cur.count;
380
+ counters.set(curKey, {
381
+ count,
382
+ reset: cur.reset
383
+ });
384
+ const weighted = prevCount * weight + count;
350
385
  const reset = windowStart + rule.window - now;
351
386
  return {
352
387
  allowed,
@@ -356,31 +391,27 @@ function slidingWindow(counters, key, rule, now) {
356
391
  retryAfter: allowed ? 0 : reset
357
392
  };
358
393
  }
359
- function tokenBucket(buckets, key, rule, now) {
394
+ function tokenBucket(buckets, key, rule, now, cost) {
360
395
  const cap = rule.limit;
361
396
  const refillPerMs = rule.limit / rule.window;
362
- let b = buckets.get(key);
363
- if (!b) b = {
397
+ const b = buckets.get(key) ?? {
364
398
  tokens: cap,
365
399
  ts: now
366
400
  };
367
- b.tokens = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
368
- b.ts = now;
369
- const cost = TOKEN_COST;
370
- let allowed = false;
371
- if (b.tokens >= cost) {
372
- b.tokens -= cost;
373
- allowed = true;
374
- }
375
- buckets.set(key, b);
376
- const remaining = Math.floor(b.tokens);
377
- const retryAfter = allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerMs);
378
- const reset = Math.ceil((cap - b.tokens) / refillPerMs);
401
+ const level = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
402
+ const allowed = level >= cost;
403
+ const tokens = allowed ? level - cost : level;
404
+ buckets.set(key, {
405
+ tokens,
406
+ ts: now
407
+ });
408
+ const remaining = Math.floor(tokens);
409
+ const retryAfter = allowed ? 0 : Math.ceil((cost - tokens) / refillPerMs);
379
410
  return {
380
411
  allowed,
381
412
  limit: cap,
382
413
  remaining,
383
- reset,
414
+ reset: Math.ceil((cap - tokens) / refillPerMs),
384
415
  retryAfter
385
416
  };
386
417
  }
@@ -400,6 +431,10 @@ function memoryStore(opts = {}) {
400
431
  const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
401
432
  const counters = /* @__PURE__ */ new Map();
402
433
  const buckets = /* @__PURE__ */ new Map();
434
+ const state = {
435
+ counters,
436
+ buckets
437
+ };
403
438
  let ops = 0;
404
439
  const sweep = (now) => {
405
440
  if (++ops % sweepEvery !== 0) return;
@@ -407,13 +442,9 @@ function memoryStore(opts = {}) {
407
442
  for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
408
443
  };
409
444
  return {
410
- consume(key, rule, now) {
445
+ consume(key, rule, now, cost) {
411
446
  sweep(now);
412
- switch (rule.algorithm) {
413
- case "sliding-window": return slidingWindow(counters, key, rule, now);
414
- case "token-bucket": return tokenBucket(buckets, key, rule, now);
415
- default: return fixedWindow(counters, key, rule, now);
416
- }
447
+ return applyHit(state, key, rule, now, cost);
417
448
  },
418
449
  reset(key) {
419
450
  counters.delete(key);
@@ -423,4 +454,4 @@ function memoryStore(opts = {}) {
423
454
  };
424
455
  }
425
456
  //#endregion
426
- export { DEFAULT_ALGORITHM, DEFAULT_PREFIX, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
457
+ export { DEFAULT_ALGORITHM, DEFAULT_COST, DEFAULT_PREFIX, applyHit, hitCost, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/redis.cjs CHANGED
@@ -1,23 +1,21 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/redis.ts
3
- /** Tokens consumed per request (token-bucket). */
4
- const TOKEN_COST = 1;
5
3
  /** Keep bucketed keys for this many windows so the previous bucket is still readable. */
6
4
  const WINDOW_TTL_FACTOR = 2;
7
5
  /** The Lua scripts return 1 for an allowed request. */
8
6
  const LUA_ALLOWED = 1;
9
7
  const FIXED = `
10
- local c = redis.call('INCR', KEYS[1])
11
- if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
12
- if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('DECR', KEYS[1]) end
13
- return {c, redis.call('PTTL', KEYS[1])}`;
8
+ local c = tonumber(redis.call('INCRBYFLOAT', KEYS[1], ARGV[4]))
9
+ if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
10
+ if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('INCRBYFLOAT', KEYS[1], -tonumber(ARGV[4])) end
11
+ return {tostring(c), redis.call('PTTL', KEYS[1])}`;
14
12
  const SLIDING = `
15
- local cur = redis.call('INCR', KEYS[1])
16
- if cur == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
13
+ local cur = tonumber(redis.call('INCRBYFLOAT', KEYS[1], ARGV[5]))
14
+ if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
17
15
  local prev = redis.call('GET', KEYS[2])
18
16
  prev = prev and tonumber(prev) or 0
19
- if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('DECR', KEYS[1]) end
20
- return {cur, prev}`;
17
+ if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('INCRBYFLOAT', KEYS[1], -tonumber(ARGV[5])) end
18
+ return {tostring(cur), tostring(prev)}`;
21
19
  const TOKEN = `
22
20
  local d = redis.call('HMGET', KEYS[1], 't', 's')
23
21
  local tokens = tonumber(d[1])
@@ -43,16 +41,16 @@ const num = (v) => typeof v === "number" ? v : Number(v);
43
41
  function redisStore(client, opts = {}) {
44
42
  const ns = opts.prefix ?? "";
45
43
  return {
46
- async consume(key, rule, now) {
44
+ async consume(key, rule, now, cost) {
47
45
  const k = ns + key;
48
46
  if (rule.algorithm === "token-bucket") {
49
47
  const cap = rule.limit;
50
48
  const rate = rule.limit / rule.window;
51
49
  const ttl = Math.ceil(rule.window * WINDOW_TTL_FACTOR);
52
- const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, TOKEN_COST);
50
+ const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, cost);
53
51
  const allowed = num(res[0]) === LUA_ALLOWED;
54
52
  const tokens = num(res[1]);
55
- const retryAfter = allowed ? 0 : Math.ceil((TOKEN_COST - tokens) / rate);
53
+ const retryAfter = allowed ? 0 : Math.ceil((cost - tokens) / rate);
56
54
  const reset = Math.ceil((cap - tokens) / rate);
57
55
  return {
58
56
  allowed,
@@ -68,7 +66,7 @@ function redisStore(client, opts = {}) {
68
66
  const curKey = `${k}|${windowStart}`;
69
67
  const prevKey = `${k}|${windowStart - rule.window}`;
70
68
  const weight = (rule.window - (now - windowStart)) / rule.window;
71
- const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected);
69
+ const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected, cost);
72
70
  const cur = num(res[0]);
73
71
  const weighted = num(res[1]) * weight + cur;
74
72
  const allowed = weighted <= rule.limit;
@@ -81,7 +79,7 @@ function redisStore(client, opts = {}) {
81
79
  retryAfter: allowed ? 0 : reset
82
80
  };
83
81
  }
84
- const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected);
82
+ const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected, cost);
85
83
  const count = num(res[0]);
86
84
  const ttl = num(res[1]);
87
85
  const allowed = count <= rule.limit;
package/dist/redis.js CHANGED
@@ -1,22 +1,20 @@
1
1
  //#region src/redis.ts
2
- /** Tokens consumed per request (token-bucket). */
3
- const TOKEN_COST = 1;
4
2
  /** Keep bucketed keys for this many windows so the previous bucket is still readable. */
5
3
  const WINDOW_TTL_FACTOR = 2;
6
4
  /** The Lua scripts return 1 for an allowed request. */
7
5
  const LUA_ALLOWED = 1;
8
6
  const FIXED = `
9
- local c = redis.call('INCR', KEYS[1])
10
- if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
11
- if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('DECR', KEYS[1]) end
12
- return {c, redis.call('PTTL', KEYS[1])}`;
7
+ local c = tonumber(redis.call('INCRBYFLOAT', KEYS[1], ARGV[4]))
8
+ if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
9
+ if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('INCRBYFLOAT', KEYS[1], -tonumber(ARGV[4])) end
10
+ return {tostring(c), redis.call('PTTL', KEYS[1])}`;
13
11
  const SLIDING = `
14
- local cur = redis.call('INCR', KEYS[1])
15
- if cur == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
12
+ local cur = tonumber(redis.call('INCRBYFLOAT', KEYS[1], ARGV[5]))
13
+ if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
16
14
  local prev = redis.call('GET', KEYS[2])
17
15
  prev = prev and tonumber(prev) or 0
18
- if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('DECR', KEYS[1]) end
19
- return {cur, prev}`;
16
+ if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('INCRBYFLOAT', KEYS[1], -tonumber(ARGV[5])) end
17
+ return {tostring(cur), tostring(prev)}`;
20
18
  const TOKEN = `
21
19
  local d = redis.call('HMGET', KEYS[1], 't', 's')
22
20
  local tokens = tonumber(d[1])
@@ -42,16 +40,16 @@ const num = (v) => typeof v === "number" ? v : Number(v);
42
40
  function redisStore(client, opts = {}) {
43
41
  const ns = opts.prefix ?? "";
44
42
  return {
45
- async consume(key, rule, now) {
43
+ async consume(key, rule, now, cost) {
46
44
  const k = ns + key;
47
45
  if (rule.algorithm === "token-bucket") {
48
46
  const cap = rule.limit;
49
47
  const rate = rule.limit / rule.window;
50
48
  const ttl = Math.ceil(rule.window * WINDOW_TTL_FACTOR);
51
- const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, TOKEN_COST);
49
+ const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, cost);
52
50
  const allowed = num(res[0]) === LUA_ALLOWED;
53
51
  const tokens = num(res[1]);
54
- const retryAfter = allowed ? 0 : Math.ceil((TOKEN_COST - tokens) / rate);
52
+ const retryAfter = allowed ? 0 : Math.ceil((cost - tokens) / rate);
55
53
  const reset = Math.ceil((cap - tokens) / rate);
56
54
  return {
57
55
  allowed,
@@ -67,7 +65,7 @@ function redisStore(client, opts = {}) {
67
65
  const curKey = `${k}|${windowStart}`;
68
66
  const prevKey = `${k}|${windowStart - rule.window}`;
69
67
  const weight = (rule.window - (now - windowStart)) / rule.window;
70
- const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected);
68
+ const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected, cost);
71
69
  const cur = num(res[0]);
72
70
  const weighted = num(res[1]) * weight + cur;
73
71
  const allowed = weighted <= rule.limit;
@@ -80,7 +78,7 @@ function redisStore(client, opts = {}) {
80
78
  retryAfter: allowed ? 0 : reset
81
79
  };
82
80
  }
83
- const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected);
81
+ const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected, cost);
84
82
  const count = num(res[0]);
85
83
  const ttl = num(res[1]);
86
84
  const allowed = count <= rule.limit;
package/dist/server.cjs CHANGED
@@ -37,9 +37,10 @@ function rateLimitServer(def, opts) {
37
37
  advertise(skipped);
38
38
  return io.next({ ratelimit: skipped });
39
39
  }
40
+ const cost = require_index.hitCost(opts.cost !== void 0 ? resolve(opts.cost, kio) : void 0);
40
41
  let result;
41
42
  try {
42
- result = await store.consume(prefix + opts.key(kio), rule, Date.now());
43
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now(), cost);
43
44
  } catch (err) {
44
45
  try {
45
46
  opts.onError?.(err);
package/dist/server.d.cts CHANGED
@@ -46,6 +46,11 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
46
46
  * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
47
  */
48
48
  readonly countRejected?: Dynamic<M, boolean>;
49
+ /**
50
+ * What a request costs against the limit — fixed, or per request (default 1). Weight requests to limit by tokens,
51
+ * bytes or money (see `RateHit.cost` in `@ayepi/rate`).
52
+ */
53
+ readonly cost?: Dynamic<M, number>;
49
54
  /** Backend store (default an in-process memory store). */
50
55
  readonly store?: RateLimitStore;
51
56
  /** Key prefix/namespace (default `'rl:'`). */
package/dist/server.d.ts CHANGED
@@ -46,6 +46,11 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
46
46
  * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
47
  */
48
48
  readonly countRejected?: Dynamic<M, boolean>;
49
+ /**
50
+ * What a request costs against the limit — fixed, or per request (default 1). Weight requests to limit by tokens,
51
+ * bytes or money (see `RateHit.cost` in `@ayepi/rate`).
52
+ */
53
+ readonly cost?: Dynamic<M, number>;
49
54
  /** Backend store (default an in-process memory store). */
50
55
  readonly store?: RateLimitStore;
51
56
  /** Key prefix/namespace (default `'rl:'`). */
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { memoryStore, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
1
+ import { hitCost, memoryStore, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
2
2
  //#region src/server.ts
3
3
  /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
4
4
  function resolve(value, io) {
@@ -36,9 +36,10 @@ function rateLimitServer(def, opts) {
36
36
  advertise(skipped);
37
37
  return io.next({ ratelimit: skipped });
38
38
  }
39
+ const cost = hitCost(opts.cost !== void 0 ? resolve(opts.cost, kio) : void 0);
39
40
  let result;
40
41
  try {
41
- result = await store.consume(prefix + opts.key(kio), rule, Date.now());
42
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now(), cost);
42
43
  } catch (err) {
43
44
  try {
44
45
  opts.onError?.(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/rate",
3
- "version": "0.4.1",
3
+ "version": "0.5.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.4.1"
62
+ "@ayepi/core": "^0.5.0"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "ioredis": {
@@ -73,8 +73,8 @@
73
73
  "testcontainers": "^10.13.0",
74
74
  "tsdown": "^0.12.0",
75
75
  "vitest": "^2.1.8",
76
- "zod": "^4.4.3",
77
- "@ayepi/core": "0.4.1"
76
+ "zod": "^4.6.7",
77
+ "@ayepi/core": "0.5.0"
78
78
  },
79
79
  "keywords": [
80
80
  "ayepi",