@ayepi/rate 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/ayepi-rate-stores-doer.md +139 -22
- package/ayepi-rate.md +63 -31
- package/dist/index.cjs +122 -25
- package/dist/index.d.cts +90 -8
- package/dist/index.d.ts +90 -8
- package/dist/index.js +121 -26
- package/dist/server.cjs +16 -4
- package/dist/server.d.cts +35 -10
- package/dist/server.d.ts +35 -10
- package/dist/server.js +17 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -49,6 +49,9 @@ retryAfter }`); on exceeded requests the chain short-circuits with the 429.
|
|
|
49
49
|
- `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
|
|
50
50
|
move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
|
|
51
51
|
message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
|
|
52
|
+
The rule fields — `limit`, `window`, `algorithm`, `countRejected` — each accept a fixed
|
|
53
|
+
value **or** a per-request `(io) => value`, so limits can differ by caller (e.g.
|
|
54
|
+
`limit: (io) => io.ctx.user.plan === 'pro' ? 1000 : 100`).
|
|
52
55
|
|
|
53
56
|
## Standalone (without middleware)
|
|
54
57
|
|
|
@@ -94,6 +97,16 @@ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-buc
|
|
|
94
97
|
const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
|
|
95
98
|
```
|
|
96
99
|
|
|
100
|
+
**Two-tier limiting.** Add a `groupLimit` (a default, or per task) and a task must clear both
|
|
101
|
+
the global limit **and** its `group`'s limit — e.g. a global API cap plus a per-user/per-model
|
|
102
|
+
cap. A hot group is skipped (not blocking others), groups needn't be known up front (buckets
|
|
103
|
+
auto-evict when idle), and a shared `store`/`groupStore` (Redis) enforces both tiers across a fleet:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
rateLimitedDoer({ limit: 1000, window: 60_000, groupLimit: 50 })
|
|
107
|
+
.do(() => callModel(req), { group: `${userId}:${modelId}`, groupLimit: 200 }) // per-request override
|
|
108
|
+
```
|
|
109
|
+
|
|
97
110
|
## Algorithms
|
|
98
111
|
|
|
99
112
|
- `fixed-window` (default) — simple counter per window.
|
|
@@ -49,15 +49,26 @@ Memcached, …); only `consume` is required.
|
|
|
49
49
|
### `memoryStore` (default, bundled)
|
|
50
50
|
|
|
51
51
|
```ts
|
|
52
|
-
function memoryStore(): RateLimitStore
|
|
52
|
+
function memoryStore(opts?: MemoryStoreOptions): RateLimitStore
|
|
53
|
+
|
|
54
|
+
interface MemoryStoreOptions {
|
|
55
|
+
/** Sweep expired/idle entries once every this many `consume` calls (default 1000). */
|
|
56
|
+
readonly sweepEvery?: number;
|
|
57
|
+
/** Drop an idle token-bucket after this long with no activity (ms, default 10 min). */
|
|
58
|
+
readonly idleMs?: number;
|
|
59
|
+
}
|
|
53
60
|
```
|
|
54
61
|
|
|
55
62
|
An in-process store implementing all three algorithms, zero dependencies. It is the
|
|
56
|
-
default when no `store` is passed.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
default when no `store` is passed. **Single process only** — two server instances each get
|
|
64
|
+
their own independent budget; use the Redis store to share a limit across pods.
|
|
65
|
+
|
|
66
|
+
**Bounded memory for dynamic keys.** Entries are swept lazily, so an unbounded, not-known-in-advance
|
|
67
|
+
key space — e.g. per-user/per-model rate-limit **groups** (see `rateLimitedDoer` below) — doesn't grow
|
|
68
|
+
without bound: a windowed counter is dropped once its window has elapsed, and an idle `token-bucket`
|
|
69
|
+
after `idleMs` (default 10 min; they refill to full anyway). Active keys live long enough to enforce
|
|
70
|
+
the limit; idle ones go away. For high churn, lower `sweepEvery` (tighter memory, a little more scan
|
|
71
|
+
cost) and/or `idleMs`: `memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
|
|
61
72
|
|
|
62
73
|
### `redisStore` — `@ayepi/rate/redis`
|
|
63
74
|
|
|
@@ -111,11 +122,28 @@ distributed store (verified by the integration test). `reset(key)` issues a `DEL
|
|
|
111
122
|
## `rateLimitedDoer` — gating task start rate
|
|
112
123
|
|
|
113
124
|
```ts
|
|
114
|
-
function rateLimitedDoer(opts: RateLimitedDoerOptions):
|
|
125
|
+
function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer
|
|
115
126
|
|
|
116
127
|
interface RateLimitedDoerOptions extends LimiterOptions {
|
|
117
|
-
/** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
128
|
+
/** Limit key for the GLOBAL bucket — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
118
129
|
readonly key?: string | ((opts: DoerTaskOptions) => string);
|
|
130
|
+
|
|
131
|
+
// ── two-tier: an optional per-`group` limit on top of the global one ──
|
|
132
|
+
/** Default group limit (a second gate bucketed by a task's `group`). Per-task `groupLimit` overrides it; omit for global-only. */
|
|
133
|
+
readonly groupLimit?: number;
|
|
134
|
+
/** Derive a task's group bucket key (default `opts.group`). Return `undefined` to skip the group gate for a task. */
|
|
135
|
+
readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
|
|
136
|
+
/** Store for the group buckets (default: the global `store`). A distributed store limits groups across a fleet. */
|
|
137
|
+
readonly groupStore?: RateLimitStore;
|
|
138
|
+
|
|
139
|
+
// ── sustained-backlog watch (observational) ──
|
|
140
|
+
/** Fire while the pending queue stays continuously non-empty past `backlogAfterMs`. Must not throw. */
|
|
141
|
+
readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
|
|
142
|
+
/** How long the queue must stay non-empty before `onBacklog` first fires (ms). */
|
|
143
|
+
readonly backlogAfterMs?: number;
|
|
144
|
+
/** Re-fire `onBacklog` every this many ms while still backed up (omit to fire once). */
|
|
145
|
+
readonly backlogEveryMs?: number;
|
|
146
|
+
|
|
119
147
|
/** Floor on the re-check delay for deferred tasks (ms, default 50). */
|
|
120
148
|
readonly retryFloor?: number;
|
|
121
149
|
/** Clock injection (default `Date.now`). */
|
|
@@ -123,6 +151,23 @@ interface RateLimitedDoerOptions extends LimiterOptions {
|
|
|
123
151
|
/** The doer that actually runs admitted tasks (default `unlimitedDoer`). Compose policies. */
|
|
124
152
|
readonly doer?: Doer;
|
|
125
153
|
}
|
|
154
|
+
|
|
155
|
+
/** Per-task options — DoerTaskOptions (`group`/`priority`/`createdAt`) plus a per-request group-limit override. */
|
|
156
|
+
interface RateTaskOptions extends DoerTaskOptions {
|
|
157
|
+
readonly groupLimit?: number; // overrides the configured default; enables the group gate for this task
|
|
158
|
+
readonly groupWindow?: number; // default: the global `window`
|
|
159
|
+
readonly groupAlgorithm?: Algorithm; // default: the global `algorithm`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A Doer whose `do` accepts RateTaskOptions (assignable to Doer). */
|
|
163
|
+
interface RateLimitedDoer extends Doer {
|
|
164
|
+
do(task: () => Promise<void>, opts?: RateTaskOptions): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RateLimitedBacklogInfo {
|
|
168
|
+
readonly pending: number; // tasks waiting on the rate limit
|
|
169
|
+
readonly nonEmptyForMs: number; // how long the queue has been continuously non-empty
|
|
170
|
+
}
|
|
126
171
|
```
|
|
127
172
|
|
|
128
173
|
(`RateLimitedDoerOptions` extends `LimiterOptions` — see `ayepi-rate.md` for
|
|
@@ -135,8 +180,50 @@ A `Doer` (from `@ayepi/core/doer`) that caps the **start rate** of tasks using t
|
|
|
135
180
|
policy. Excess tasks wait, **oldest-first**; with a distributed store this rate-limits
|
|
136
181
|
**across a fleet**.
|
|
137
182
|
|
|
138
|
-
A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section
|
|
139
|
-
|
|
183
|
+
A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section
|
|
184
|
+
below. `rateLimitedDoer`'s `available()` is `min(limit − pending, inner.available())`.
|
|
185
|
+
|
|
186
|
+
### Two-tier: a global limit + a per-group limit
|
|
187
|
+
|
|
188
|
+
Set a `groupLimit` (a configured default, or per task via `RateTaskOptions`) and a task must
|
|
189
|
+
clear **both** the global limit and its `group`'s limit to be admitted — e.g. a global API cap
|
|
190
|
+
plus a per-user / per-model / per-model-family cap. `group` and `groupLimit` are read at
|
|
191
|
+
**admission time**, so groups need not be known up front.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { rateLimitedDoer } from '@ayepi/rate'
|
|
195
|
+
|
|
196
|
+
const doer = rateLimitedDoer({
|
|
197
|
+
limit: 1000, window: 60_000, algorithm: 'token-bucket', // global cap
|
|
198
|
+
groupLimit: 50, // default per-group cap
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// per-request override — the limit known at request time (e.g. a plan tier):
|
|
202
|
+
doer.do(() => callModel(req), { group: `${userId}:${modelId}`, groupLimit: tier === 'pro' ? 200 : 20 })
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Semantics:
|
|
206
|
+
|
|
207
|
+
- **Both gates, group first.** The group gate is checked before the global. A denied check
|
|
208
|
+
consumes nothing (the group rule forces `countRejected: false`), so a task whose group is at
|
|
209
|
+
its limit is **skipped**, not admitted — and no global token is spent on it.
|
|
210
|
+
- **Head-of-line avoidance.** A skipped (group-capped) task is left pending while **other
|
|
211
|
+
groups' tasks proceed**, so a hot user/model can't block everyone. Within a group, oldest-first.
|
|
212
|
+
- **Global exhaustion stops the round.** When the shared global bucket is out, nothing more is
|
|
213
|
+
admitted this pass. Edge case: a task that cleared its group but then hits an exhausted global
|
|
214
|
+
had its group token spent (bounded, self-corrects on retry) — only when global is the bottleneck.
|
|
215
|
+
- **Global-only when no group.** A task with no `group`, or no group limit in scope, passes straight to the global gate.
|
|
216
|
+
- **Fleet-shared, bounded memory.** Point `groupStore` (and `store`) at `@ayepi/rate/redis` to
|
|
217
|
+
share both tiers across pods. The dynamic group key space is auto-evicted (memory sweep / Redis
|
|
218
|
+
TTL), so it doesn't grow without bound — see `memoryStore` above.
|
|
219
|
+
|
|
220
|
+
### Sustained-backlog watch
|
|
221
|
+
|
|
222
|
+
`onBacklog` fires with `{ pending, nonEmptyForMs }` once the pending queue has stayed
|
|
223
|
+
continuously non-empty past `backlogAfterMs` (repeating every `backlogEveryMs`, or once if
|
|
224
|
+
omitted) — a signal that the rate limit can't admit fast enough. Purely observational
|
|
225
|
+
(alerting/autoscaling), a throw is ignored, and the timer is `unref`'d and cleared when the
|
|
226
|
+
queue drains.
|
|
140
227
|
|
|
141
228
|
### Example — cap an `@ayepi/work` engine
|
|
142
229
|
|
|
@@ -177,6 +264,33 @@ With a static `key` string (or the default `'doer'`) all tasks share one bucket.
|
|
|
177
264
|
|
|
178
265
|
---
|
|
179
266
|
|
|
267
|
+
## The core `Doer` (`@ayepi/core/doer`)
|
|
268
|
+
|
|
269
|
+
`rateLimitedDoer` builds on the runtime-agnostic `Doer` primitive:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
interface Doer {
|
|
273
|
+
available(): number; // how many tasks it will accept right now
|
|
274
|
+
do(task: () => Promise<void>, opts?: DoerTaskOptions): void; // fire-and-forget (swallows errors)
|
|
275
|
+
done(): Promise<void>; // resolves when all accepted tasks settle
|
|
276
|
+
}
|
|
277
|
+
interface DoerTaskOptions { group?: string; priority?: number; createdAt?: number }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Bundled policies: **`unlimitedDoer`** (no cap), **`balancedDoer`** (cap N; fair-share across
|
|
281
|
+
`group`s, then priority, then age), **`priorityDoer`** (cap N; highest priority first), **`ageDoer`**
|
|
282
|
+
(cap N; oldest first). The three bounded doers share `BoundedDoerOptions` (`max`, `buffer?`, `now?`).
|
|
283
|
+
|
|
284
|
+
- **`doWith(doer, fn, opts?)`** — submit a task and get its **result** back (`Promise<T>`), the
|
|
285
|
+
result-returning counterpart to the fire-and-forget `do` (which returns void and swallows the
|
|
286
|
+
error). Use it when a doer governs request-scoped work: `const user = await doWith(apiDoer, () => fetchUser(id), { group: tenantId })`.
|
|
287
|
+
- **Sustained-backlog watch** — the bounded doers accept `onBacklog: (info) => void` with
|
|
288
|
+
`backlogAfterMs` / `backlogEveryMs`, firing `{ pending, running, nonEmptyForMs }` once the pending
|
|
289
|
+
queue stays continuously non-empty past the threshold (the doer analogue of `rateLimitedDoer`'s
|
|
290
|
+
`onBacklog`). Purely observational; one `unref`'d timer, only while backed up.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
180
294
|
## How it works under the hood
|
|
181
295
|
|
|
182
296
|
### The three algorithms
|
|
@@ -240,18 +354,21 @@ limiter's dependency middleware run first and their context is available — see
|
|
|
240
354
|
|
|
241
355
|
### The doer abstraction
|
|
242
356
|
|
|
243
|
-
`rateLimitedDoer` keeps a `pending` queue.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
357
|
+
`rateLimitedDoer` keeps a `pending` queue. Each `drain` scans it **oldest-first** and admits
|
|
358
|
+
every task that clears its gates, skipping (not blocking on) group-capped ones:
|
|
359
|
+
|
|
360
|
+
- if the inner doer has no capacity (`inner.available() <= 0`), it records a short re-check and stops;
|
|
361
|
+
- for the oldest not-yet-skipped task, it checks the **group gate first** (when a `group` + group
|
|
362
|
+
limit apply): a denied group check consumes nothing, so the task is **skipped** (left pending) and
|
|
363
|
+
the scan continues to other groups — recording that group's `retryAfter`;
|
|
364
|
+
- then the **global gate** (`limiter.check(key, now)`): if denied, the shared bucket is out, so no
|
|
365
|
+
further task can be admitted this round and the scan stops (recording the global `retryAfter`);
|
|
366
|
+
- if both allow, it removes the task and calls `inner.do(task.run, task.opts)`.
|
|
367
|
+
|
|
368
|
+
After the scan, if tasks remain it arms **one** timer for the **soonest** recorded re-check
|
|
369
|
+
(`max(retryFloor, soonest)`), so it drains again exactly when the earliest gate would allow. A
|
|
370
|
+
store error on either gate is reported to `onError` and backs the round off by `retryFloor`. `done()`
|
|
371
|
+
resolves once `pending` is empty **and** the inner doer's `done()` resolves. All timers are `unref`'d.
|
|
255
372
|
|
|
256
373
|
---
|
|
257
374
|
|
package/ayepi-rate.md
CHANGED
|
@@ -96,8 +96,8 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
|
|
|
96
96
|
| `limiter` | function | Standalone limiter primitive (`check`/`reset`/`rule`). |
|
|
97
97
|
| `rateLimitResponse` | function | Build a 429 `Response` from limiter info. |
|
|
98
98
|
| `rateLimitHeaders` | function | Compute the `RateLimit-*` header map from limiter info. |
|
|
99
|
-
| `memoryStore` | function | The bundled in-process store (all three algorithms). |
|
|
100
|
-
| `rateLimitedDoer` | function | A `Doer` that caps task **start rate
|
|
99
|
+
| `memoryStore` | function | The bundled in-process store (all three algorithms); `memoryStore(opts?)` — tunable eviction (`sweepEvery`/`idleMs`). |
|
|
100
|
+
| `rateLimitedDoer` | function | A `Doer` that caps task **start rate** — global + optional **per-group** limit, plus a backlog watch. |
|
|
101
101
|
| `Algorithm` | type | `'fixed-window' \| 'sliding-window' \| 'token-bucket'`. |
|
|
102
102
|
| `RateLimitInfo` | interface | Limiter state exposed to handlers + headers. |
|
|
103
103
|
| `RateLimitResult` | interface | `RateLimitInfo` + `allowed`. |
|
|
@@ -107,7 +107,11 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
|
|
|
107
107
|
| `LimiterOptions` | interface | Base config (`limit`/`window`/`algorithm`/`store`/`prefix`). |
|
|
108
108
|
| `RateLimitResponseOptions` | interface | `status`/`message`/`headers` for `rateLimitResponse`. |
|
|
109
109
|
| `RateLimitDefOptions` | interface | Options for the `rateLimit` def (`name`/`requires`). |
|
|
110
|
-
| `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer
|
|
110
|
+
| `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer` (global + group limits, backlog watch). |
|
|
111
|
+
| `RateLimitedDoer` | interface | The doer `rateLimitedDoer` returns (a `Doer` whose `do` takes `RateTaskOptions`). |
|
|
112
|
+
| `RateTaskOptions` | interface | Per-task options — `DoerTaskOptions` + `groupLimit`/`groupWindow`/`groupAlgorithm`. |
|
|
113
|
+
| `RateLimitedBacklogInfo` | interface | `{ pending, nonEmptyForMs }` passed to the doer's `onBacklog`. |
|
|
114
|
+
| `MemoryStoreOptions` | interface | `{ sweepEvery?, idleMs? }` — tune `memoryStore` eviction. |
|
|
111
115
|
| `Limiter` | interface | The object `limiter()` returns. |
|
|
112
116
|
|
|
113
117
|
### Server subpath `@ayepi/rate/server`
|
|
@@ -115,7 +119,8 @@ Everything below is exported. `@internal` symbols are intentionally omitted.
|
|
|
115
119
|
| Export | Kind | Purpose |
|
|
116
120
|
| --- | --- | --- |
|
|
117
121
|
| `rateLimit` | function | Same name, **augmented with `.server(def, opts)`** to bind the policy. |
|
|
118
|
-
| `RateLimitServerOptions` | interface | The policy options for `.server` (
|
|
122
|
+
| `RateLimitServerOptions` | interface | The policy options for `.server` (rule fields accept a static value or a per-request `(io) => T`). |
|
|
123
|
+
| `RateKeyFn` / `Dynamic` | type | Helpers: `RateKeyFn<M, T> = (io) => T`; `Dynamic<M, T> = T \| RateKeyFn<M, T>` — the shape of every per-request option. |
|
|
119
124
|
|
|
120
125
|
### Redis subpath `@ayepi/rate/redis`
|
|
121
126
|
|
|
@@ -158,10 +163,10 @@ it in another middleware's `requires` (see `ayepi-core-middleware.md`).
|
|
|
158
163
|
### The impl (`@ayepi/rate/server`)
|
|
159
164
|
|
|
160
165
|
```ts
|
|
161
|
-
rateLimit.server: <
|
|
162
|
-
def: RateLimitDef
|
|
163
|
-
opts: RateLimitServerOptions<
|
|
164
|
-
) => BoundMiddleware // pass to implement(api).middleware(...)
|
|
166
|
+
rateLimit.server: <M extends AnyMiddleware>(
|
|
167
|
+
def: M, // a RateLimitDef — its `requires` types `io.ctx`
|
|
168
|
+
opts: RateLimitServerOptions<M>,
|
|
169
|
+
) => BoundMiddleware<M> // pass to implement(api).middleware(...)
|
|
165
170
|
```
|
|
166
171
|
|
|
167
172
|
`.server` binds the policy and returns the bound pair. It composes with the chainable
|
|
@@ -170,20 +175,35 @@ rateLimit.server: <const R extends readonly AnyMiddleware[]>(
|
|
|
170
175
|
### `RateLimitServerOptions`
|
|
171
176
|
|
|
172
177
|
```ts
|
|
173
|
-
|
|
174
|
-
|
|
178
|
+
// A rule field is either a fixed value or resolved per request from the key `io`.
|
|
179
|
+
type RateKeyFn<M, T> = (io: RateKeyIO<StackCtx<ReqOf<M>>>) => T;
|
|
180
|
+
type Dynamic<M, T> = T | RateKeyFn<M, T>;
|
|
181
|
+
|
|
182
|
+
interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
175
183
|
/** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
|
|
176
|
-
readonly key:
|
|
184
|
+
readonly key: RateKeyFn<M, string>;
|
|
185
|
+
/** Max requests (or token-bucket capacity) per window — fixed, or per request. */
|
|
186
|
+
readonly limit: Dynamic<M, number>;
|
|
187
|
+
/** Window length in ms (also the token refill period) — fixed, or per request. */
|
|
188
|
+
readonly window: Dynamic<M, number>;
|
|
189
|
+
/** Algorithm (default `'fixed-window'`) — fixed, or per request. */
|
|
190
|
+
readonly algorithm?: Dynamic<M, Algorithm>;
|
|
191
|
+
/** Count rejected (over-limit) requests against the limit (default `false`) — fixed, or per request. */
|
|
192
|
+
readonly countRejected?: Dynamic<M, boolean>;
|
|
193
|
+
/** Backend store (default an in-process `memoryStore`). */
|
|
194
|
+
readonly store?: RateLimitStore;
|
|
195
|
+
/** Key prefix/namespace (default `'rl:'`). */
|
|
196
|
+
readonly prefix?: string;
|
|
177
197
|
/** Over-limit status code (default `429`). */
|
|
178
198
|
readonly status?: number;
|
|
179
199
|
/** Over-limit body — string, JSON value, or a function of (info, io). */
|
|
180
|
-
readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<
|
|
200
|
+
readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
|
|
181
201
|
/** Response headers (see `RateLimitResponseOptions`). */
|
|
182
202
|
readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
|
|
183
203
|
/** Also emit the `RateLimit-*` headers on allowed/skipped responses (default `false`). */
|
|
184
204
|
readonly alwaysHeaders?: boolean;
|
|
185
205
|
/** Bypass the limiter for some requests (e.g. an allow-list). */
|
|
186
|
-
readonly skip?:
|
|
206
|
+
readonly skip?: RateKeyFn<M, boolean>;
|
|
187
207
|
/** Serve through (as allowed) when the **store** errors, instead of failing the request. Default `false` (fail-closed). */
|
|
188
208
|
readonly failOpen?: boolean;
|
|
189
209
|
/** Observe a store error (e.g. Redis down). Fires regardless of `failOpen`. Off by default; must not throw. */
|
|
@@ -191,30 +211,24 @@ interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddl
|
|
|
191
211
|
}
|
|
192
212
|
```
|
|
193
213
|
|
|
214
|
+
> **Dynamic policy.** `limit`, `window`, `algorithm`, and `countRejected` each accept a plain
|
|
215
|
+
> value **or** a `(io) => value` resolved per request — so different callers get different
|
|
216
|
+
> limits (e.g. a higher `limit` for a premium plan, a longer `window` for a trusted key). `io`
|
|
217
|
+
> is the same `{ req, ctx }` `key`/`skip` receive, with `io.ctx` typed from the def's `requires`.
|
|
218
|
+
> Resolve `window`/`algorithm` from a **stable** property of the identity (a given key should
|
|
219
|
+
> always map to the same window/algorithm) — the store buckets its per-key state by them, so
|
|
220
|
+
> varying them for one key across requests gives inconsistent accounting. Varying only `limit`
|
|
221
|
+
> is always safe.
|
|
222
|
+
|
|
194
223
|
> **Store errors.** By default the limiter is **fail-closed**: if the store (e.g. a Redis
|
|
195
224
|
> outage) throws, the error propagates and the request is rejected — a store outage doesn't
|
|
196
225
|
> silently lift the limit. Set `failOpen: true` to serve such requests through instead, and/or
|
|
197
226
|
> `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
|
|
198
227
|
> too: a store error there is reported and admission retried — it never strands pending tasks.
|
|
199
228
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
interface LimiterOptions {
|
|
204
|
-
/** Max requests (or token-bucket capacity) per window. */
|
|
205
|
-
readonly limit: number;
|
|
206
|
-
/** Window length in milliseconds (also the token refill period). */
|
|
207
|
-
readonly window: number;
|
|
208
|
-
/** Algorithm (default `'fixed-window'`). */
|
|
209
|
-
readonly algorithm?: Algorithm;
|
|
210
|
-
/** Backend store (default an in-process `memoryStore`). */
|
|
211
|
-
readonly store?: RateLimitStore;
|
|
212
|
-
/** Key prefix/namespace (default `'rl:'`). */
|
|
213
|
-
readonly prefix?: string;
|
|
214
|
-
/** Count requests that are themselves rejected (over-limit) against the limit (default `false`). */
|
|
215
|
-
readonly countRejected?: boolean;
|
|
216
|
-
}
|
|
217
|
-
```
|
|
229
|
+
> The standalone `limiter()` primitive (and `rateLimitedDoer`) takes a **static**
|
|
230
|
+
> `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
|
|
231
|
+
> resolution is a middleware feature, since only the request path carries an `io`.
|
|
218
232
|
|
|
219
233
|
Notes grounded in the source:
|
|
220
234
|
|
|
@@ -304,6 +318,24 @@ const app = implement(api)
|
|
|
304
318
|
.server()
|
|
305
319
|
```
|
|
306
320
|
|
|
321
|
+
### Example — per-caller limits (dynamic `limit`)
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// shared.ts — auth provides { user: { id, plan } }
|
|
325
|
+
const limit = rateLimit({ requires: [auth] })
|
|
326
|
+
// server.ts
|
|
327
|
+
rateLimit.server(limit, {
|
|
328
|
+
key: (io) => io.ctx.user.id,
|
|
329
|
+
limit: (io) => (io.ctx.user.plan === 'pro' ? 1_000 : 100), // premium callers get more
|
|
330
|
+
window: 60_000, // keep window/algorithm stable per key
|
|
331
|
+
algorithm: 'sliding-window',
|
|
332
|
+
})
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Any of `limit` / `window` / `algorithm` / `countRejected` can be a `(io) => value`. Derive
|
|
336
|
+
`window`/`algorithm` from a stable identity attribute (plan, tenant) — not from time or the
|
|
337
|
+
request path — so a given key always maps to the same bucketing.
|
|
338
|
+
|
|
307
339
|
### Example — per-IP limiting without `requires`
|
|
308
340
|
|
|
309
341
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -66,10 +66,10 @@ function limiter(opts) {
|
|
|
66
66
|
const rule = {
|
|
67
67
|
limit: opts.limit,
|
|
68
68
|
window: opts.window,
|
|
69
|
-
algorithm: opts.algorithm ??
|
|
69
|
+
algorithm: opts.algorithm ?? "fixed-window",
|
|
70
70
|
countRejected: opts.countRejected ?? false
|
|
71
71
|
};
|
|
72
|
-
const prefix = opts.prefix ??
|
|
72
|
+
const prefix = opts.prefix ?? "rl:";
|
|
73
73
|
return {
|
|
74
74
|
rule,
|
|
75
75
|
check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
|
|
@@ -151,18 +151,78 @@ const DOER_RETRY_FLOOR = 50;
|
|
|
151
151
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
152
152
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
153
153
|
* ```
|
|
154
|
+
*
|
|
155
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
156
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
157
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
158
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
159
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
154
160
|
*/
|
|
155
161
|
function rateLimitedDoer(opts) {
|
|
156
|
-
const
|
|
162
|
+
const store = opts.store ?? memoryStore();
|
|
163
|
+
const lim = limiter({
|
|
164
|
+
...opts,
|
|
165
|
+
store
|
|
166
|
+
});
|
|
157
167
|
const inner = opts.doer ?? (0, _ayepi_core_doer.unlimitedDoer)();
|
|
158
168
|
const now = opts.now ?? Date.now;
|
|
159
169
|
const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
|
|
160
170
|
const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
|
|
171
|
+
const groupStore = opts.groupStore ?? store;
|
|
172
|
+
const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
|
|
173
|
+
const groupKeyOf = (o) => opts.groupKey ? opts.groupKey(o ?? {}) : o?.group;
|
|
174
|
+
const groupRuleFor = (o) => {
|
|
175
|
+
const limit = o?.groupLimit ?? opts.groupLimit;
|
|
176
|
+
if (limit === void 0) return;
|
|
177
|
+
return {
|
|
178
|
+
limit,
|
|
179
|
+
window: o?.groupWindow ?? opts.window,
|
|
180
|
+
algorithm: o?.groupAlgorithm ?? opts.algorithm ?? "fixed-window",
|
|
181
|
+
countRejected: false
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const report = (err) => {
|
|
185
|
+
try {
|
|
186
|
+
opts.onError?.(err);
|
|
187
|
+
} catch {}
|
|
188
|
+
};
|
|
161
189
|
const pending = [];
|
|
162
190
|
const idle = [];
|
|
163
191
|
let seq = 0;
|
|
164
192
|
let draining = false;
|
|
165
193
|
let timer = null;
|
|
194
|
+
const backlogWatch = opts.onBacklog !== void 0 && opts.backlogAfterMs !== void 0;
|
|
195
|
+
let nonEmptySince = null;
|
|
196
|
+
let backlogTimer = null;
|
|
197
|
+
const armBacklog = (ms) => {
|
|
198
|
+
backlogTimer = setTimeout(fireBacklog, ms);
|
|
199
|
+
backlogTimer.unref?.();
|
|
200
|
+
};
|
|
201
|
+
function fireBacklog() {
|
|
202
|
+
try {
|
|
203
|
+
opts.onBacklog({
|
|
204
|
+
pending: pending.length,
|
|
205
|
+
nonEmptyForMs: now() - nonEmptySince
|
|
206
|
+
});
|
|
207
|
+
} catch {}
|
|
208
|
+
if (opts.backlogEveryMs !== void 0) armBacklog(opts.backlogEveryMs);
|
|
209
|
+
else backlogTimer = null;
|
|
210
|
+
}
|
|
211
|
+
const syncBacklog = () => {
|
|
212
|
+
if (!backlogWatch) return;
|
|
213
|
+
if (pending.length > 0) {
|
|
214
|
+
if (nonEmptySince === null) {
|
|
215
|
+
nonEmptySince = now();
|
|
216
|
+
armBacklog(opts.backlogAfterMs);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
nonEmptySince = null;
|
|
220
|
+
if (backlogTimer) {
|
|
221
|
+
clearTimeout(backlogTimer);
|
|
222
|
+
backlogTimer = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
166
226
|
const arm = (ms) => {
|
|
167
227
|
if (timer) return;
|
|
168
228
|
timer = setTimeout(() => {
|
|
@@ -171,40 +231,67 @@ function rateLimitedDoer(opts) {
|
|
|
171
231
|
}, Math.max(floor, ms));
|
|
172
232
|
timer.unref?.();
|
|
173
233
|
};
|
|
234
|
+
/**
|
|
235
|
+
* The oldest pending task not skipped this round (a fresh scan, so tasks queued mid-drain are
|
|
236
|
+
* seen). `pending` is in insertion (seq) order, so equal `createdAt` ties keep the earliest.
|
|
237
|
+
*/
|
|
238
|
+
const nextPending = (skipped) => {
|
|
239
|
+
let best;
|
|
240
|
+
for (const t of pending) {
|
|
241
|
+
if (skipped.has(t)) continue;
|
|
242
|
+
if (!best || t.createdAt < best.createdAt) best = t;
|
|
243
|
+
}
|
|
244
|
+
return best;
|
|
245
|
+
};
|
|
174
246
|
const drain = async () => {
|
|
175
247
|
if (draining) return;
|
|
176
248
|
draining = true;
|
|
177
249
|
try {
|
|
178
|
-
|
|
250
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
251
|
+
let soonest = Infinity;
|
|
252
|
+
for (;;) {
|
|
179
253
|
if (inner.available() <= 0) {
|
|
180
|
-
|
|
254
|
+
soonest = Math.min(soonest, floor);
|
|
181
255
|
break;
|
|
182
256
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
257
|
+
const task = nextPending(skipped);
|
|
258
|
+
if (!task) break;
|
|
259
|
+
const o = task.opts;
|
|
260
|
+
const groupRule = groupRuleFor(o);
|
|
261
|
+
const gk = groupKeyOf(o);
|
|
262
|
+
if (groupRule && gk !== void 0) {
|
|
263
|
+
let gr;
|
|
264
|
+
try {
|
|
265
|
+
gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
|
|
266
|
+
} catch (err) {
|
|
267
|
+
report(err);
|
|
268
|
+
soonest = Math.min(soonest, floor);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (!gr.allowed) {
|
|
272
|
+
soonest = Math.min(soonest, gr.retryAfter);
|
|
273
|
+
skipped.add(task);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
188
276
|
}
|
|
189
|
-
const task = pending[best];
|
|
190
277
|
let res;
|
|
191
278
|
try {
|
|
192
|
-
res = await lim.check(keyOf(
|
|
279
|
+
res = await lim.check(keyOf(o), now());
|
|
193
280
|
} catch (err) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
} catch {}
|
|
197
|
-
arm(floor);
|
|
281
|
+
report(err);
|
|
282
|
+
soonest = Math.min(soonest, floor);
|
|
198
283
|
break;
|
|
199
284
|
}
|
|
200
285
|
if (!res.allowed) {
|
|
201
|
-
|
|
286
|
+
soonest = Math.min(soonest, res.retryAfter);
|
|
202
287
|
break;
|
|
203
288
|
}
|
|
204
|
-
pending.splice(
|
|
289
|
+
pending.splice(pending.indexOf(task), 1);
|
|
205
290
|
inner.do(task.run, task.opts);
|
|
206
291
|
}
|
|
207
292
|
if (pending.length === 0) for (const r of idle.splice(0)) r();
|
|
293
|
+
else arm(soonest);
|
|
294
|
+
syncBacklog();
|
|
208
295
|
} finally {
|
|
209
296
|
draining = false;
|
|
210
297
|
}
|
|
@@ -218,6 +305,7 @@ function rateLimitedDoer(opts) {
|
|
|
218
305
|
createdAt: o?.createdAt ?? now(),
|
|
219
306
|
seq: seq++
|
|
220
307
|
});
|
|
308
|
+
syncBacklog();
|
|
221
309
|
drain();
|
|
222
310
|
},
|
|
223
311
|
done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
|
|
@@ -298,19 +386,26 @@ function tokenBucket(buckets, key, rule, now) {
|
|
|
298
386
|
};
|
|
299
387
|
}
|
|
300
388
|
/**
|
|
301
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
389
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
390
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
391
|
+
* share limits across pods.
|
|
392
|
+
*
|
|
393
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
394
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
395
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
396
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
397
|
+
* for high churn.
|
|
305
398
|
*/
|
|
306
|
-
function memoryStore() {
|
|
399
|
+
function memoryStore(opts = {}) {
|
|
400
|
+
const sweepEvery = opts.sweepEvery ?? SWEEP_EVERY;
|
|
401
|
+
const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
|
|
307
402
|
const counters = /* @__PURE__ */ new Map();
|
|
308
403
|
const buckets = /* @__PURE__ */ new Map();
|
|
309
404
|
let ops = 0;
|
|
310
405
|
const sweep = (now) => {
|
|
311
|
-
if (++ops %
|
|
406
|
+
if (++ops % sweepEvery !== 0) return;
|
|
312
407
|
for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
|
|
313
|
-
for (const [k, b] of buckets) if (now - b.ts >
|
|
408
|
+
for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
|
|
314
409
|
};
|
|
315
410
|
return {
|
|
316
411
|
consume(key, rule, now) {
|
|
@@ -329,6 +424,8 @@ function memoryStore() {
|
|
|
329
424
|
};
|
|
330
425
|
}
|
|
331
426
|
//#endregion
|
|
427
|
+
exports.DEFAULT_ALGORITHM = DEFAULT_ALGORITHM;
|
|
428
|
+
exports.DEFAULT_PREFIX = DEFAULT_PREFIX;
|
|
332
429
|
exports.limiter = limiter;
|
|
333
430
|
exports.memoryStore = memoryStore;
|
|
334
431
|
exports.rateLimit = rateLimit;
|