@ayepi/rate 0.3.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 +10 -0
- package/ayepi-rate-stores-doer.md +139 -22
- package/ayepi-rate.md +8 -4
- package/dist/index.cjs +118 -23
- package/dist/index.d.cts +86 -8
- package/dist/index.d.ts +86 -8
- package/dist/index.js +118 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -97,6 +97,16 @@ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-buc
|
|
|
97
97
|
const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
|
|
98
98
|
```
|
|
99
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
|
+
|
|
100
110
|
## Algorithms
|
|
101
111
|
|
|
102
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`
|
|
@@ -222,7 +226,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
|
222
226
|
> `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
|
|
223
227
|
> too: a store error there is reported and admission retried — it never strands pending tasks.
|
|
224
228
|
|
|
225
|
-
> The standalone `limiter()` primitive (and `rateLimitedDoer`)
|
|
229
|
+
> The standalone `limiter()` primitive (and `rateLimitedDoer`) takes a **static**
|
|
226
230
|
> `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
|
|
227
231
|
> resolution is a middleware feature, since only the request path carries an `io`.
|
|
228
232
|
|
package/dist/index.cjs
CHANGED
|
@@ -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) {
|
package/dist/index.d.cts
CHANGED
|
@@ -148,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
|
|
|
148
148
|
type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
|
|
149
149
|
ratelimit: RateLimitInfo;
|
|
150
150
|
}, R>;
|
|
151
|
+
/** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
|
|
152
|
+
interface RateLimitedBacklogInfo {
|
|
153
|
+
/** Tasks waiting on the rate limit to admit them (the queue depth). */
|
|
154
|
+
readonly pending: number;
|
|
155
|
+
/** How long the queue has been *continuously* non-empty (ms). */
|
|
156
|
+
readonly nonEmptyForMs: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
|
|
160
|
+
* `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
|
|
161
|
+
* present, the task must clear both the global limit **and** its group's limit to be admitted.
|
|
162
|
+
*/
|
|
163
|
+
interface RateTaskOptions extends DoerTaskOptions {
|
|
164
|
+
/** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
|
|
165
|
+
readonly groupLimit?: number;
|
|
166
|
+
/** Window for the group limit (ms; default: the global `window`). */
|
|
167
|
+
readonly groupWindow?: number;
|
|
168
|
+
/** Algorithm for the group limit (default: the global `algorithm`). */
|
|
169
|
+
readonly groupAlgorithm?: Algorithm;
|
|
170
|
+
}
|
|
171
|
+
/** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
|
|
172
|
+
interface RateLimitedDoer extends Doer {
|
|
173
|
+
/** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
|
|
174
|
+
do(task: () => Promise<void>, opts?: RateTaskOptions): void;
|
|
175
|
+
}
|
|
151
176
|
/** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
|
|
152
177
|
interface RateLimitedDoerOptions extends LimiterOptions {
|
|
153
|
-
/** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
178
|
+
/** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
154
179
|
readonly key?: string | ((opts: DoerTaskOptions) => string);
|
|
180
|
+
/**
|
|
181
|
+
* Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
|
|
182
|
+
* A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
|
|
183
|
+
* The group window/algorithm default to the global ones unless overridden per task.
|
|
184
|
+
*/
|
|
185
|
+
readonly groupLimit?: number;
|
|
186
|
+
/** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
|
|
187
|
+
readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* Store for the group buckets (default: the global `store`). A distributed store limits groups
|
|
190
|
+
* across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
|
|
191
|
+
* when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
|
|
192
|
+
* an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
|
|
193
|
+
* pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
|
|
194
|
+
*/
|
|
195
|
+
readonly groupStore?: RateLimitStore;
|
|
155
196
|
/** Floor on the re-check delay for deferred tasks (ms, default 50). */
|
|
156
197
|
readonly retryFloor?: number;
|
|
157
198
|
/** Clock injection (default `Date.now`). */
|
|
158
199
|
readonly now?: () => number;
|
|
159
200
|
/** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
|
|
160
201
|
readonly doer?: Doer;
|
|
202
|
+
/**
|
|
203
|
+
* Notified when the pending queue stays **continuously non-empty** past
|
|
204
|
+
* {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
|
|
205
|
+
* admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
|
|
206
|
+
* does, the throw is ignored. Requires `backlogAfterMs`.
|
|
207
|
+
*/
|
|
208
|
+
readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
|
|
209
|
+
/** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
|
|
210
|
+
readonly backlogAfterMs?: number;
|
|
211
|
+
/** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
|
|
212
|
+
readonly backlogEveryMs?: number;
|
|
161
213
|
/**
|
|
162
214
|
* Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
|
|
163
215
|
* never crashes on it — the task stays pending and admission is retried shortly. Off by
|
|
@@ -180,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
|
|
|
180
232
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
181
233
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
182
234
|
* ```
|
|
235
|
+
*
|
|
236
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
237
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
238
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
239
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
240
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
183
241
|
*/
|
|
184
|
-
declare function rateLimitedDoer(opts: RateLimitedDoerOptions):
|
|
242
|
+
declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
|
|
243
|
+
/** Options for {@link memoryStore}. */
|
|
244
|
+
interface MemoryStoreOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
|
|
247
|
+
* memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
|
|
248
|
+
* that aren't known up front — at a little more periodic scan cost.
|
|
249
|
+
*/
|
|
250
|
+
readonly sweepEvery?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
|
|
253
|
+
* Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
|
|
254
|
+
* elapsed, regardless of this.
|
|
255
|
+
*/
|
|
256
|
+
readonly idleMs?: number;
|
|
257
|
+
}
|
|
185
258
|
/**
|
|
186
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
259
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
260
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
261
|
+
* share limits across pods.
|
|
262
|
+
*
|
|
263
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
264
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
265
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
266
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
267
|
+
* for high churn.
|
|
190
268
|
*/
|
|
191
|
-
declare function memoryStore(): RateLimitStore;
|
|
269
|
+
declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
|
|
192
270
|
//#endregion
|
|
193
|
-
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
|
271
|
+
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
package/dist/index.d.ts
CHANGED
|
@@ -148,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
|
|
|
148
148
|
type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
|
|
149
149
|
ratelimit: RateLimitInfo;
|
|
150
150
|
}, R>;
|
|
151
|
+
/** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
|
|
152
|
+
interface RateLimitedBacklogInfo {
|
|
153
|
+
/** Tasks waiting on the rate limit to admit them (the queue depth). */
|
|
154
|
+
readonly pending: number;
|
|
155
|
+
/** How long the queue has been *continuously* non-empty (ms). */
|
|
156
|
+
readonly nonEmptyForMs: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
|
|
160
|
+
* `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
|
|
161
|
+
* present, the task must clear both the global limit **and** its group's limit to be admitted.
|
|
162
|
+
*/
|
|
163
|
+
interface RateTaskOptions extends DoerTaskOptions {
|
|
164
|
+
/** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
|
|
165
|
+
readonly groupLimit?: number;
|
|
166
|
+
/** Window for the group limit (ms; default: the global `window`). */
|
|
167
|
+
readonly groupWindow?: number;
|
|
168
|
+
/** Algorithm for the group limit (default: the global `algorithm`). */
|
|
169
|
+
readonly groupAlgorithm?: Algorithm;
|
|
170
|
+
}
|
|
171
|
+
/** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
|
|
172
|
+
interface RateLimitedDoer extends Doer {
|
|
173
|
+
/** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
|
|
174
|
+
do(task: () => Promise<void>, opts?: RateTaskOptions): void;
|
|
175
|
+
}
|
|
151
176
|
/** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
|
|
152
177
|
interface RateLimitedDoerOptions extends LimiterOptions {
|
|
153
|
-
/** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
178
|
+
/** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
154
179
|
readonly key?: string | ((opts: DoerTaskOptions) => string);
|
|
180
|
+
/**
|
|
181
|
+
* Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
|
|
182
|
+
* A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
|
|
183
|
+
* The group window/algorithm default to the global ones unless overridden per task.
|
|
184
|
+
*/
|
|
185
|
+
readonly groupLimit?: number;
|
|
186
|
+
/** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
|
|
187
|
+
readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* Store for the group buckets (default: the global `store`). A distributed store limits groups
|
|
190
|
+
* across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
|
|
191
|
+
* when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
|
|
192
|
+
* an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
|
|
193
|
+
* pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
|
|
194
|
+
*/
|
|
195
|
+
readonly groupStore?: RateLimitStore;
|
|
155
196
|
/** Floor on the re-check delay for deferred tasks (ms, default 50). */
|
|
156
197
|
readonly retryFloor?: number;
|
|
157
198
|
/** Clock injection (default `Date.now`). */
|
|
158
199
|
readonly now?: () => number;
|
|
159
200
|
/** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
|
|
160
201
|
readonly doer?: Doer;
|
|
202
|
+
/**
|
|
203
|
+
* Notified when the pending queue stays **continuously non-empty** past
|
|
204
|
+
* {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
|
|
205
|
+
* admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
|
|
206
|
+
* does, the throw is ignored. Requires `backlogAfterMs`.
|
|
207
|
+
*/
|
|
208
|
+
readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
|
|
209
|
+
/** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
|
|
210
|
+
readonly backlogAfterMs?: number;
|
|
211
|
+
/** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
|
|
212
|
+
readonly backlogEveryMs?: number;
|
|
161
213
|
/**
|
|
162
214
|
* Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
|
|
163
215
|
* never crashes on it — the task stays pending and admission is retried shortly. Off by
|
|
@@ -180,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
|
|
|
180
232
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
181
233
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
182
234
|
* ```
|
|
235
|
+
*
|
|
236
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
237
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
238
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
239
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
240
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
183
241
|
*/
|
|
184
|
-
declare function rateLimitedDoer(opts: RateLimitedDoerOptions):
|
|
242
|
+
declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
|
|
243
|
+
/** Options for {@link memoryStore}. */
|
|
244
|
+
interface MemoryStoreOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
|
|
247
|
+
* memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
|
|
248
|
+
* that aren't known up front — at a little more periodic scan cost.
|
|
249
|
+
*/
|
|
250
|
+
readonly sweepEvery?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
|
|
253
|
+
* Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
|
|
254
|
+
* elapsed, regardless of this.
|
|
255
|
+
*/
|
|
256
|
+
readonly idleMs?: number;
|
|
257
|
+
}
|
|
185
258
|
/**
|
|
186
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
259
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
260
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
261
|
+
* share limits across pods.
|
|
262
|
+
*
|
|
263
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
264
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
265
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
266
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
267
|
+
* for high churn.
|
|
190
268
|
*/
|
|
191
|
-
declare function memoryStore(): RateLimitStore;
|
|
269
|
+
declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
|
|
192
270
|
//#endregion
|
|
193
|
-
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
|
271
|
+
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
package/dist/index.js
CHANGED
|
@@ -150,18 +150,78 @@ const DOER_RETRY_FLOOR = 50;
|
|
|
150
150
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
151
151
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
152
152
|
* ```
|
|
153
|
+
*
|
|
154
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
155
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
156
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
157
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
158
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
153
159
|
*/
|
|
154
160
|
function rateLimitedDoer(opts) {
|
|
155
|
-
const
|
|
161
|
+
const store = opts.store ?? memoryStore();
|
|
162
|
+
const lim = limiter({
|
|
163
|
+
...opts,
|
|
164
|
+
store
|
|
165
|
+
});
|
|
156
166
|
const inner = opts.doer ?? unlimitedDoer();
|
|
157
167
|
const now = opts.now ?? Date.now;
|
|
158
168
|
const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
|
|
159
169
|
const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
|
|
170
|
+
const groupStore = opts.groupStore ?? store;
|
|
171
|
+
const groupPrefix = (opts.prefix ?? "rl:") + "grp:";
|
|
172
|
+
const groupKeyOf = (o) => opts.groupKey ? opts.groupKey(o ?? {}) : o?.group;
|
|
173
|
+
const groupRuleFor = (o) => {
|
|
174
|
+
const limit = o?.groupLimit ?? opts.groupLimit;
|
|
175
|
+
if (limit === void 0) return;
|
|
176
|
+
return {
|
|
177
|
+
limit,
|
|
178
|
+
window: o?.groupWindow ?? opts.window,
|
|
179
|
+
algorithm: o?.groupAlgorithm ?? opts.algorithm ?? "fixed-window",
|
|
180
|
+
countRejected: false
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const report = (err) => {
|
|
184
|
+
try {
|
|
185
|
+
opts.onError?.(err);
|
|
186
|
+
} catch {}
|
|
187
|
+
};
|
|
160
188
|
const pending = [];
|
|
161
189
|
const idle = [];
|
|
162
190
|
let seq = 0;
|
|
163
191
|
let draining = false;
|
|
164
192
|
let timer = null;
|
|
193
|
+
const backlogWatch = opts.onBacklog !== void 0 && opts.backlogAfterMs !== void 0;
|
|
194
|
+
let nonEmptySince = null;
|
|
195
|
+
let backlogTimer = null;
|
|
196
|
+
const armBacklog = (ms) => {
|
|
197
|
+
backlogTimer = setTimeout(fireBacklog, ms);
|
|
198
|
+
backlogTimer.unref?.();
|
|
199
|
+
};
|
|
200
|
+
function fireBacklog() {
|
|
201
|
+
try {
|
|
202
|
+
opts.onBacklog({
|
|
203
|
+
pending: pending.length,
|
|
204
|
+
nonEmptyForMs: now() - nonEmptySince
|
|
205
|
+
});
|
|
206
|
+
} catch {}
|
|
207
|
+
if (opts.backlogEveryMs !== void 0) armBacklog(opts.backlogEveryMs);
|
|
208
|
+
else backlogTimer = null;
|
|
209
|
+
}
|
|
210
|
+
const syncBacklog = () => {
|
|
211
|
+
if (!backlogWatch) return;
|
|
212
|
+
if (pending.length > 0) {
|
|
213
|
+
if (nonEmptySince === null) {
|
|
214
|
+
nonEmptySince = now();
|
|
215
|
+
armBacklog(opts.backlogAfterMs);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
nonEmptySince = null;
|
|
219
|
+
if (backlogTimer) {
|
|
220
|
+
clearTimeout(backlogTimer);
|
|
221
|
+
backlogTimer = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
165
225
|
const arm = (ms) => {
|
|
166
226
|
if (timer) return;
|
|
167
227
|
timer = setTimeout(() => {
|
|
@@ -170,40 +230,67 @@ function rateLimitedDoer(opts) {
|
|
|
170
230
|
}, Math.max(floor, ms));
|
|
171
231
|
timer.unref?.();
|
|
172
232
|
};
|
|
233
|
+
/**
|
|
234
|
+
* The oldest pending task not skipped this round (a fresh scan, so tasks queued mid-drain are
|
|
235
|
+
* seen). `pending` is in insertion (seq) order, so equal `createdAt` ties keep the earliest.
|
|
236
|
+
*/
|
|
237
|
+
const nextPending = (skipped) => {
|
|
238
|
+
let best;
|
|
239
|
+
for (const t of pending) {
|
|
240
|
+
if (skipped.has(t)) continue;
|
|
241
|
+
if (!best || t.createdAt < best.createdAt) best = t;
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
};
|
|
173
245
|
const drain = async () => {
|
|
174
246
|
if (draining) return;
|
|
175
247
|
draining = true;
|
|
176
248
|
try {
|
|
177
|
-
|
|
249
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
250
|
+
let soonest = Infinity;
|
|
251
|
+
for (;;) {
|
|
178
252
|
if (inner.available() <= 0) {
|
|
179
|
-
|
|
253
|
+
soonest = Math.min(soonest, floor);
|
|
180
254
|
break;
|
|
181
255
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
256
|
+
const task = nextPending(skipped);
|
|
257
|
+
if (!task) break;
|
|
258
|
+
const o = task.opts;
|
|
259
|
+
const groupRule = groupRuleFor(o);
|
|
260
|
+
const gk = groupKeyOf(o);
|
|
261
|
+
if (groupRule && gk !== void 0) {
|
|
262
|
+
let gr;
|
|
263
|
+
try {
|
|
264
|
+
gr = await groupStore.consume(groupPrefix + gk, groupRule, now());
|
|
265
|
+
} catch (err) {
|
|
266
|
+
report(err);
|
|
267
|
+
soonest = Math.min(soonest, floor);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (!gr.allowed) {
|
|
271
|
+
soonest = Math.min(soonest, gr.retryAfter);
|
|
272
|
+
skipped.add(task);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
187
275
|
}
|
|
188
|
-
const task = pending[best];
|
|
189
276
|
let res;
|
|
190
277
|
try {
|
|
191
|
-
res = await lim.check(keyOf(
|
|
278
|
+
res = await lim.check(keyOf(o), now());
|
|
192
279
|
} catch (err) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
} catch {}
|
|
196
|
-
arm(floor);
|
|
280
|
+
report(err);
|
|
281
|
+
soonest = Math.min(soonest, floor);
|
|
197
282
|
break;
|
|
198
283
|
}
|
|
199
284
|
if (!res.allowed) {
|
|
200
|
-
|
|
285
|
+
soonest = Math.min(soonest, res.retryAfter);
|
|
201
286
|
break;
|
|
202
287
|
}
|
|
203
|
-
pending.splice(
|
|
288
|
+
pending.splice(pending.indexOf(task), 1);
|
|
204
289
|
inner.do(task.run, task.opts);
|
|
205
290
|
}
|
|
206
291
|
if (pending.length === 0) for (const r of idle.splice(0)) r();
|
|
292
|
+
else arm(soonest);
|
|
293
|
+
syncBacklog();
|
|
207
294
|
} finally {
|
|
208
295
|
draining = false;
|
|
209
296
|
}
|
|
@@ -217,6 +304,7 @@ function rateLimitedDoer(opts) {
|
|
|
217
304
|
createdAt: o?.createdAt ?? now(),
|
|
218
305
|
seq: seq++
|
|
219
306
|
});
|
|
307
|
+
syncBacklog();
|
|
220
308
|
drain();
|
|
221
309
|
},
|
|
222
310
|
done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
|
|
@@ -297,19 +385,26 @@ function tokenBucket(buckets, key, rule, now) {
|
|
|
297
385
|
};
|
|
298
386
|
}
|
|
299
387
|
/**
|
|
300
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
388
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
389
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
390
|
+
* share limits across pods.
|
|
391
|
+
*
|
|
392
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
393
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
394
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
395
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
396
|
+
* for high churn.
|
|
304
397
|
*/
|
|
305
|
-
function memoryStore() {
|
|
398
|
+
function memoryStore(opts = {}) {
|
|
399
|
+
const sweepEvery = opts.sweepEvery ?? SWEEP_EVERY;
|
|
400
|
+
const idleMs = opts.idleMs ?? BUCKET_IDLE_MS;
|
|
306
401
|
const counters = /* @__PURE__ */ new Map();
|
|
307
402
|
const buckets = /* @__PURE__ */ new Map();
|
|
308
403
|
let ops = 0;
|
|
309
404
|
const sweep = (now) => {
|
|
310
|
-
if (++ops %
|
|
405
|
+
if (++ops % sweepEvery !== 0) return;
|
|
311
406
|
for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
|
|
312
|
-
for (const [k, b] of buckets) if (now - b.ts >
|
|
407
|
+
for (const [k, b] of buckets) if (now - b.ts > idleMs) buckets.delete(k);
|
|
313
408
|
};
|
|
314
409
|
return {
|
|
315
410
|
consume(key, rule, now) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/rate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.4.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependenciesMeta": {
|
|
65
65
|
"ioredis": {
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"tsdown": "^0.12.0",
|
|
75
75
|
"vitest": "^2.1.8",
|
|
76
76
|
"zod": "^4.4.3",
|
|
77
|
-
"@ayepi/core": "0.
|
|
77
|
+
"@ayepi/core": "0.4.0"
|
|
78
78
|
},
|
|
79
79
|
"keywords": [
|
|
80
80
|
"ayepi",
|