@ayepi/rate 0.4.0 → 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 +3 -3
- package/ayepi-rate-stores-doer.md +39 -12
- package/ayepi-rate.md +36 -3
- package/dist/index.cjs +88 -54
- package/dist/index.d.cts +74 -8
- package/dist/index.d.ts +74 -8
- package/dist/index.js +86 -55
- package/dist/redis.cjs +13 -15
- package/dist/redis.js +13 -15
- package/dist/server.cjs +2 -1
- package/dist/server.d.cts +5 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +3 -2
- package/package.json +4 -4
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
|
|
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
|
-
/**
|
|
34
|
-
|
|
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`:
|
|
41
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
399
|
-
check(key: string,
|
|
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
|
-
/**
|
|
46
|
-
const
|
|
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,
|
|
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
|
|
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
|
|
236
|
-
* seen). `pending` is in insertion (seq) order, so equal
|
|
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
|
|
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),
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
counters
|
|
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
|
-
|
|
324
|
-
|
|
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 -
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 +
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
364
|
-
if (!b) b = {
|
|
398
|
+
const b = buckets.get(key) ?? {
|
|
365
399
|
tokens: cap,
|
|
366
400
|
ts: now
|
|
367
401
|
};
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
16
|
+
/** The configured limit for the window, in cost units (requests by default). */
|
|
15
17
|
readonly limit: number;
|
|
16
|
-
/**
|
|
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
|
-
/**
|
|
49
|
-
|
|
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` (
|
|
104
|
-
check(key: string,
|
|
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
|
|
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
|
|
16
|
+
/** The configured limit for the window, in cost units (requests by default). */
|
|
15
17
|
readonly limit: number;
|
|
16
|
-
/**
|
|
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
|
-
/**
|
|
49
|
-
|
|
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` (
|
|
104
|
-
check(key: string,
|
|
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
|
|
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
|
-
/**
|
|
45
|
-
const
|
|
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,
|
|
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
|
|
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
|
|
235
|
-
* seen). `pending` is in insertion (seq) order, so equal
|
|
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
|
|
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),
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
counters
|
|
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
|
-
|
|
323
|
-
|
|
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 -
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 +
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
363
|
-
if (!b) b = {
|
|
397
|
+
const b = buckets.get(key) ?? {
|
|
364
398
|
tokens: cap,
|
|
365
399
|
ts: now
|
|
366
400
|
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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('
|
|
11
|
-
if
|
|
12
|
-
if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('
|
|
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('
|
|
16
|
-
if
|
|
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('
|
|
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,
|
|
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((
|
|
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('
|
|
10
|
-
if
|
|
11
|
-
if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('
|
|
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('
|
|
15
|
-
if
|
|
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('
|
|
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,
|
|
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((
|
|
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.
|
|
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.
|
|
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.
|
|
77
|
-
"@ayepi/core": "0.
|
|
76
|
+
"zod": "^4.6.7",
|
|
77
|
+
"@ayepi/core": "0.5.0"
|
|
78
78
|
},
|
|
79
79
|
"keywords": [
|
|
80
80
|
"ayepi",
|