@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/dist/index.d.cts
CHANGED
|
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
|
|
|
3
3
|
|
|
4
4
|
//#region src/index.d.ts
|
|
5
5
|
|
|
6
|
+
/** Default key namespace. */
|
|
7
|
+
declare const DEFAULT_PREFIX = "rl:";
|
|
8
|
+
/** Default algorithm. */
|
|
9
|
+
declare const DEFAULT_ALGORITHM: Algorithm;
|
|
6
10
|
/** The rate-limiting algorithm. */
|
|
7
11
|
type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
8
12
|
/** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
|
|
@@ -144,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
|
|
|
144
148
|
type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
|
|
145
149
|
ratelimit: RateLimitInfo;
|
|
146
150
|
}, R>;
|
|
151
|
+
/** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
|
|
152
|
+
interface RateLimitedBacklogInfo {
|
|
153
|
+
/** Tasks waiting on the rate limit to admit them (the queue depth). */
|
|
154
|
+
readonly pending: number;
|
|
155
|
+
/** How long the queue has been *continuously* non-empty (ms). */
|
|
156
|
+
readonly nonEmptyForMs: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
|
|
160
|
+
* `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
|
|
161
|
+
* present, the task must clear both the global limit **and** its group's limit to be admitted.
|
|
162
|
+
*/
|
|
163
|
+
interface RateTaskOptions extends DoerTaskOptions {
|
|
164
|
+
/** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
|
|
165
|
+
readonly groupLimit?: number;
|
|
166
|
+
/** Window for the group limit (ms; default: the global `window`). */
|
|
167
|
+
readonly groupWindow?: number;
|
|
168
|
+
/** Algorithm for the group limit (default: the global `algorithm`). */
|
|
169
|
+
readonly groupAlgorithm?: Algorithm;
|
|
170
|
+
}
|
|
171
|
+
/** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
|
|
172
|
+
interface RateLimitedDoer extends Doer {
|
|
173
|
+
/** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
|
|
174
|
+
do(task: () => Promise<void>, opts?: RateTaskOptions): void;
|
|
175
|
+
}
|
|
147
176
|
/** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
|
|
148
177
|
interface RateLimitedDoerOptions extends LimiterOptions {
|
|
149
|
-
/** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
178
|
+
/** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
150
179
|
readonly key?: string | ((opts: DoerTaskOptions) => string);
|
|
180
|
+
/**
|
|
181
|
+
* Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
|
|
182
|
+
* A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
|
|
183
|
+
* The group window/algorithm default to the global ones unless overridden per task.
|
|
184
|
+
*/
|
|
185
|
+
readonly groupLimit?: number;
|
|
186
|
+
/** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
|
|
187
|
+
readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* Store for the group buckets (default: the global `store`). A distributed store limits groups
|
|
190
|
+
* across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
|
|
191
|
+
* when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
|
|
192
|
+
* an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
|
|
193
|
+
* pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
|
|
194
|
+
*/
|
|
195
|
+
readonly groupStore?: RateLimitStore;
|
|
151
196
|
/** Floor on the re-check delay for deferred tasks (ms, default 50). */
|
|
152
197
|
readonly retryFloor?: number;
|
|
153
198
|
/** Clock injection (default `Date.now`). */
|
|
154
199
|
readonly now?: () => number;
|
|
155
200
|
/** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
|
|
156
201
|
readonly doer?: Doer;
|
|
202
|
+
/**
|
|
203
|
+
* Notified when the pending queue stays **continuously non-empty** past
|
|
204
|
+
* {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
|
|
205
|
+
* admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
|
|
206
|
+
* does, the throw is ignored. Requires `backlogAfterMs`.
|
|
207
|
+
*/
|
|
208
|
+
readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
|
|
209
|
+
/** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
|
|
210
|
+
readonly backlogAfterMs?: number;
|
|
211
|
+
/** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
|
|
212
|
+
readonly backlogEveryMs?: number;
|
|
157
213
|
/**
|
|
158
214
|
* Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
|
|
159
215
|
* never crashes on it — the task stays pending and admission is retried shortly. Off by
|
|
@@ -176,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
|
|
|
176
232
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
177
233
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
178
234
|
* ```
|
|
235
|
+
*
|
|
236
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
237
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
238
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
239
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
240
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
179
241
|
*/
|
|
180
|
-
declare function rateLimitedDoer(opts: RateLimitedDoerOptions):
|
|
242
|
+
declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
|
|
243
|
+
/** Options for {@link memoryStore}. */
|
|
244
|
+
interface MemoryStoreOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
|
|
247
|
+
* memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
|
|
248
|
+
* that aren't known up front — at a little more periodic scan cost.
|
|
249
|
+
*/
|
|
250
|
+
readonly sweepEvery?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
|
|
253
|
+
* Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
|
|
254
|
+
* elapsed, regardless of this.
|
|
255
|
+
*/
|
|
256
|
+
readonly idleMs?: number;
|
|
257
|
+
}
|
|
181
258
|
/**
|
|
182
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
259
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
260
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
261
|
+
* share limits across pods.
|
|
262
|
+
*
|
|
263
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
264
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
265
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
266
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
267
|
+
* for high churn.
|
|
186
268
|
*/
|
|
187
|
-
declare function memoryStore(): RateLimitStore;
|
|
269
|
+
declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
|
|
188
270
|
//#endregion
|
|
189
|
-
export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
|
271
|
+
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
|
|
|
3
3
|
|
|
4
4
|
//#region src/index.d.ts
|
|
5
5
|
|
|
6
|
+
/** Default key namespace. */
|
|
7
|
+
declare const DEFAULT_PREFIX = "rl:";
|
|
8
|
+
/** Default algorithm. */
|
|
9
|
+
declare const DEFAULT_ALGORITHM: Algorithm;
|
|
6
10
|
/** The rate-limiting algorithm. */
|
|
7
11
|
type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
8
12
|
/** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
|
|
@@ -144,16 +148,68 @@ declare function rateLimit<const R extends readonly AnyMiddleware[] = readonly [
|
|
|
144
148
|
type RateLimitDef<R extends readonly AnyMiddleware[] = readonly []> = MiddlewareDef<{
|
|
145
149
|
ratelimit: RateLimitInfo;
|
|
146
150
|
}, R>;
|
|
151
|
+
/** Details of a sustained backlog, passed to {@link RateLimitedDoerOptions.onBacklog}. */
|
|
152
|
+
interface RateLimitedBacklogInfo {
|
|
153
|
+
/** Tasks waiting on the rate limit to admit them (the queue depth). */
|
|
154
|
+
readonly pending: number;
|
|
155
|
+
/** How long the queue has been *continuously* non-empty (ms). */
|
|
156
|
+
readonly nonEmptyForMs: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Per-task options for a {@link rateLimitedDoer} — {@link DoerTaskOptions} (`group`/`priority`/
|
|
160
|
+
* `createdAt`) plus a per-request **group limit** override. When a `group` and a group limit are
|
|
161
|
+
* present, the task must clear both the global limit **and** its group's limit to be admitted.
|
|
162
|
+
*/
|
|
163
|
+
interface RateTaskOptions extends DoerTaskOptions {
|
|
164
|
+
/** Per-task group rate limit (a second gate, bucketed by `group`). Overrides {@link RateLimitedDoerOptions.groupLimit}. */
|
|
165
|
+
readonly groupLimit?: number;
|
|
166
|
+
/** Window for the group limit (ms; default: the global `window`). */
|
|
167
|
+
readonly groupWindow?: number;
|
|
168
|
+
/** Algorithm for the group limit (default: the global `algorithm`). */
|
|
169
|
+
readonly groupAlgorithm?: Algorithm;
|
|
170
|
+
}
|
|
171
|
+
/** A {@link Doer} whose `do` accepts {@link RateTaskOptions} — i.e. a per-task group limit. */
|
|
172
|
+
interface RateLimitedDoer extends Doer {
|
|
173
|
+
/** Accept a task; `opts` may carry a per-task `group` + `groupLimit` (a second, per-group gate). */
|
|
174
|
+
do(task: () => Promise<void>, opts?: RateTaskOptions): void;
|
|
175
|
+
}
|
|
147
176
|
/** Options for {@link rateLimitedDoer} — a {@link LimiterOptions} plus doer-specific knobs. */
|
|
148
177
|
interface RateLimitedDoerOptions extends LimiterOptions {
|
|
149
|
-
/** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
178
|
+
/** Limit key for the **global** bucket — a single shared bucket by default (`'doer'`), or derived per task. */
|
|
150
179
|
readonly key?: string | ((opts: DoerTaskOptions) => string);
|
|
180
|
+
/**
|
|
181
|
+
* Default **group** limit — a second gate bucketed by a task's `group`, on top of the global limit.
|
|
182
|
+
* A per-task {@link RateTaskOptions.groupLimit} overrides it. Omit for no group gate (global only).
|
|
183
|
+
* The group window/algorithm default to the global ones unless overridden per task.
|
|
184
|
+
*/
|
|
185
|
+
readonly groupLimit?: number;
|
|
186
|
+
/** Derive a task's group bucket key (default: `opts.group`). Return `undefined` to skip the group gate for a task. */
|
|
187
|
+
readonly groupKey?: (opts: DoerTaskOptions) => string | undefined;
|
|
188
|
+
/**
|
|
189
|
+
* Store for the group buckets (default: the global `store`). A distributed store limits groups
|
|
190
|
+
* across a fleet. Groups need not be known up front — bucket state is per-group and **auto-evicted
|
|
191
|
+
* when idle** (the {@link memoryStore} sweeps expired/idle keys; `@ayepi/rate/redis` sets TTLs), so
|
|
192
|
+
* an unbounded, dynamic group space (per-user/per-model) won't grow without bound. For high churn,
|
|
193
|
+
* pass a tuned store, e.g. `groupStore: memoryStore({ sweepEvery: 200, idleMs: 30_000 })`.
|
|
194
|
+
*/
|
|
195
|
+
readonly groupStore?: RateLimitStore;
|
|
151
196
|
/** Floor on the re-check delay for deferred tasks (ms, default 50). */
|
|
152
197
|
readonly retryFloor?: number;
|
|
153
198
|
/** Clock injection (default `Date.now`). */
|
|
154
199
|
readonly now?: () => number;
|
|
155
200
|
/** The doer that actually runs admitted tasks (default {@link unlimitedDoer}). Compose policies. */
|
|
156
201
|
readonly doer?: Doer;
|
|
202
|
+
/**
|
|
203
|
+
* Notified when the pending queue stays **continuously non-empty** past
|
|
204
|
+
* {@link RateLimitedDoerOptions.backlogAfterMs} — a sustained backlog of tasks the rate limit can't
|
|
205
|
+
* admit fast enough. Purely observational (for alerting/autoscaling); it must not throw — if it
|
|
206
|
+
* does, the throw is ignored. Requires `backlogAfterMs`.
|
|
207
|
+
*/
|
|
208
|
+
readonly onBacklog?: (info: RateLimitedBacklogInfo) => void;
|
|
209
|
+
/** How long the queue must stay non-empty before {@link RateLimitedDoerOptions.onBacklog} first fires (ms). */
|
|
210
|
+
readonly backlogAfterMs?: number;
|
|
211
|
+
/** Re-fire `onBacklog` every this many ms while still backed up. Omit to fire once per backlog episode. */
|
|
212
|
+
readonly backlogEveryMs?: number;
|
|
157
213
|
/**
|
|
158
214
|
* Observe a store error during admission (e.g. a distributed store hiccup). The drain loop
|
|
159
215
|
* never crashes on it — the task stays pending and admission is retried shortly. Off by
|
|
@@ -176,14 +232,40 @@ interface RateLimitedDoerOptions extends LimiterOptions {
|
|
|
176
232
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
177
233
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
178
234
|
* ```
|
|
235
|
+
*
|
|
236
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
237
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
238
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
239
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
240
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
179
241
|
*/
|
|
180
|
-
declare function rateLimitedDoer(opts: RateLimitedDoerOptions):
|
|
242
|
+
declare function rateLimitedDoer(opts: RateLimitedDoerOptions): RateLimitedDoer;
|
|
243
|
+
/** Options for {@link memoryStore}. */
|
|
244
|
+
interface MemoryStoreOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Sweep expired/idle entries once every this many `consume` calls (default 1000). Lower bounds
|
|
247
|
+
* memory tighter when keys churn — e.g. many short-lived rate-limit **groups** (per-user/per-model)
|
|
248
|
+
* that aren't known up front — at a little more periodic scan cost.
|
|
249
|
+
*/
|
|
250
|
+
readonly sweepEvery?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Drop an idle **token-bucket** key after this long with no activity (ms, default 10 min).
|
|
253
|
+
* Windowed (`fixed-window`/`sliding-window`) counters are always dropped once their window has
|
|
254
|
+
* elapsed, regardless of this.
|
|
255
|
+
*/
|
|
256
|
+
readonly idleMs?: number;
|
|
257
|
+
}
|
|
181
258
|
/**
|
|
182
|
-
* An in-process {@link RateLimitStore} implementing all three algorithms. The
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
259
|
+
* An in-process {@link RateLimitStore} implementing all three algorithms. The default store — fine
|
|
260
|
+
* for a single instance; use a distributed store (e.g. `@ayepi/rate/redis`, which sets key TTLs) to
|
|
261
|
+
* share limits across pods.
|
|
262
|
+
*
|
|
263
|
+
* **Bounded memory for dynamic keys.** Entries are swept lazily so an unbounded, not-known-in-advance
|
|
264
|
+
* key space (per-user/per-model rate-limit **groups**) doesn't grow without bound: a windowed counter
|
|
265
|
+
* is dropped once its window elapses, and an idle token-bucket after {@link MemoryStoreOptions.idleMs}.
|
|
266
|
+
* Active keys live long enough to enforce the limit; idle ones go away. Tune {@link MemoryStoreOptions}
|
|
267
|
+
* for high churn.
|
|
186
268
|
*/
|
|
187
|
-
declare function memoryStore(): RateLimitStore;
|
|
269
|
+
declare function memoryStore(opts?: MemoryStoreOptions): RateLimitStore;
|
|
188
270
|
//#endregion
|
|
189
|
-
export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
|
271
|
+
export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, MemoryStoreOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedBacklogInfo, RateLimitedDoer, RateLimitedDoerOptions, RateTaskOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
package/dist/index.js
CHANGED
|
@@ -65,10 +65,10 @@ function limiter(opts) {
|
|
|
65
65
|
const rule = {
|
|
66
66
|
limit: opts.limit,
|
|
67
67
|
window: opts.window,
|
|
68
|
-
algorithm: opts.algorithm ??
|
|
68
|
+
algorithm: opts.algorithm ?? "fixed-window",
|
|
69
69
|
countRejected: opts.countRejected ?? false
|
|
70
70
|
};
|
|
71
|
-
const prefix = opts.prefix ??
|
|
71
|
+
const prefix = opts.prefix ?? "rl:";
|
|
72
72
|
return {
|
|
73
73
|
rule,
|
|
74
74
|
check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
|
|
@@ -150,18 +150,78 @@ const DOER_RETRY_FLOOR = 50;
|
|
|
150
150
|
* const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
|
|
151
151
|
* const w = createWork({ work: [sendEmail] as const, doer })
|
|
152
152
|
* ```
|
|
153
|
+
*
|
|
154
|
+
* **Two-tier limiting.** Set a `groupLimit` (default, or per task via {@link RateTaskOptions}) and a
|
|
155
|
+
* task must clear both the global limit **and** its `group`'s limit — e.g. a global API cap plus a
|
|
156
|
+
* per-user / per-model cap. The group gate is checked first (a denied group check consumes nothing),
|
|
157
|
+
* so a task whose group is at its limit is **skipped** rather than blocking other groups, and the
|
|
158
|
+
* shared global bucket isn't spent on it. `group`/`groupLimit` are read at admission time.
|
|
153
159
|
*/
|
|
154
160
|
function rateLimitedDoer(opts) {
|
|
155
|
-
const
|
|
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) {
|
|
@@ -328,4 +423,4 @@ function memoryStore() {
|
|
|
328
423
|
};
|
|
329
424
|
}
|
|
330
425
|
//#endregion
|
|
331
|
-
export { limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
|
426
|
+
export { DEFAULT_ALGORITHM, DEFAULT_PREFIX, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
|
package/dist/server.cjs
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_index = require("./index.cjs");
|
|
3
3
|
//#region src/server.ts
|
|
4
|
+
/** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
|
|
5
|
+
function resolve(value, io) {
|
|
6
|
+
return typeof value === "function" ? value(io) : value;
|
|
7
|
+
}
|
|
4
8
|
/** Bind a {@link rateLimit} def to its runtime policy. */
|
|
5
9
|
function rateLimitServer(def, opts) {
|
|
6
|
-
const
|
|
10
|
+
const store = opts.store ?? require_index.memoryStore();
|
|
11
|
+
const prefix = opts.prefix ?? "rl:";
|
|
7
12
|
const message = opts.message;
|
|
13
|
+
const ruleFor = (io) => ({
|
|
14
|
+
limit: resolve(opts.limit, io),
|
|
15
|
+
window: resolve(opts.window, io),
|
|
16
|
+
algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
|
|
17
|
+
countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
|
|
18
|
+
});
|
|
8
19
|
const run = async (io) => {
|
|
9
20
|
const kio = {
|
|
10
21
|
req: io.req,
|
|
11
22
|
ctx: io.ctx
|
|
12
23
|
};
|
|
24
|
+
const rule = ruleFor(kio);
|
|
13
25
|
const advertise = (info) => {
|
|
14
26
|
if (!opts.alwaysHeaders) return;
|
|
15
27
|
for (const [name, value] of Object.entries(require_index.rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
|
|
16
28
|
};
|
|
17
29
|
const fullBudget = () => ({
|
|
18
|
-
limit:
|
|
19
|
-
remaining:
|
|
30
|
+
limit: rule.limit,
|
|
31
|
+
remaining: rule.limit,
|
|
20
32
|
reset: 0,
|
|
21
33
|
retryAfter: 0
|
|
22
34
|
});
|
|
@@ -27,7 +39,7 @@ function rateLimitServer(def, opts) {
|
|
|
27
39
|
}
|
|
28
40
|
let result;
|
|
29
41
|
try {
|
|
30
|
-
result = await
|
|
42
|
+
result = await store.consume(prefix + opts.key(kio), rule, Date.now());
|
|
31
43
|
} catch (err) {
|
|
32
44
|
try {
|
|
33
45
|
opts.onError?.(err);
|
package/dist/server.d.cts
CHANGED
|
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
|
|
|
5
5
|
|
|
6
6
|
/** The `requires` chain of a middleware def. */
|
|
7
7
|
type ReqOf<M extends AnyMiddleware> = M['__req'];
|
|
8
|
+
/** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
|
|
9
|
+
type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
|
|
10
|
+
/**
|
|
11
|
+
* A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
|
|
12
|
+
* `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
|
|
13
|
+
*/
|
|
14
|
+
type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
|
|
15
|
+
/**
|
|
16
|
+
* A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
|
|
17
|
+
* from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
|
|
18
|
+
* `limit` for a premium plan, a longer `window` for a trusted client.
|
|
19
|
+
*/
|
|
20
|
+
type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
|
|
8
21
|
/**
|
|
9
22
|
* Server-side options for binding a {@link rateLimit} def — the limiting policy and
|
|
10
23
|
* response customization, with `key`/`skip`/`message` typed against the def's
|
|
11
24
|
* `requires` context.
|
|
12
25
|
*
|
|
26
|
+
* The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
|
|
27
|
+
* a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
|
|
28
|
+
* **Resolve these from a stable property of the identity** (the same key should always map to
|
|
29
|
+
* the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
|
|
30
|
+
* `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
|
|
31
|
+
* `limit` is always safe. See {@link Dynamic}.
|
|
32
|
+
*
|
|
13
33
|
* @typeParam M - the rate-limit def being bound.
|
|
14
34
|
*/
|
|
15
35
|
interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
16
36
|
/** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
|
|
17
|
-
readonly key:
|
|
18
|
-
/** Max requests (or token-bucket capacity) per window. */
|
|
19
|
-
readonly limit: number
|
|
20
|
-
/** Window length in milliseconds (also the token refill period). */
|
|
21
|
-
readonly window: number
|
|
22
|
-
/** Algorithm (default `'fixed-window'`). */
|
|
23
|
-
readonly algorithm?: Algorithm
|
|
37
|
+
readonly key: RateKeyFn<M, string>;
|
|
38
|
+
/** Max requests (or token-bucket capacity) per window — fixed, or per request. */
|
|
39
|
+
readonly limit: Dynamic<M, number>;
|
|
40
|
+
/** Window length in milliseconds (also the token refill period) — fixed, or per request. */
|
|
41
|
+
readonly window: Dynamic<M, number>;
|
|
42
|
+
/** Algorithm (default `'fixed-window'`) — fixed, or per request. */
|
|
43
|
+
readonly algorithm?: Dynamic<M, Algorithm>;
|
|
44
|
+
/**
|
|
45
|
+
* Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
|
|
46
|
+
* request. Default `false`; see {@link RateLimitRule.countRejected}.
|
|
47
|
+
*/
|
|
48
|
+
readonly countRejected?: Dynamic<M, boolean>;
|
|
24
49
|
/** Backend store (default an in-process memory store). */
|
|
25
50
|
readonly store?: RateLimitStore;
|
|
26
51
|
/** Key prefix/namespace (default `'rl:'`). */
|
|
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
|
28
53
|
/** Over-limit status code (default `429`). */
|
|
29
54
|
readonly status?: number;
|
|
30
55
|
/** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
|
|
31
|
-
readonly message?: string | Json | ((info: RateLimitInfo, io:
|
|
56
|
+
readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
|
|
32
57
|
/** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
|
|
33
58
|
readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
|
|
34
59
|
/**
|
|
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
|
|
|
39
64
|
*/
|
|
40
65
|
readonly alwaysHeaders?: boolean;
|
|
41
66
|
/** Bypass the limiter for some requests (e.g. an allow-list). */
|
|
42
|
-
readonly skip?:
|
|
67
|
+
readonly skip?: RateKeyFn<M, boolean>;
|
|
43
68
|
/**
|
|
44
69
|
* What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
|
|
45
70
|
* request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
|
|
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
|
|
|
64
89
|
server: typeof rateLimitServer;
|
|
65
90
|
};
|
|
66
91
|
//#endregion
|
|
67
|
-
export { RateLimitServerOptions, rateLimit };
|
|
92
|
+
export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };
|