@alxia/rate-limit 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +74 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +11 -0
- package/dist/rate-limit.d.ts +55 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/store.d.ts +35 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +11 -0
- package/docs/guide.md +421 -0
- package/docs/roadmap.md +53 -0
- package/docs/troubleshooting.md +256 -0
- package/package.json +52 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @alxia/rate-limit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Rate limiting for [alxia](https://www.npmjs.com/package/@alxia/core), typed:
|
|
4
|
+
the 429 is part of every route behind the limit, so
|
|
5
|
+
[`@alxia/client`](https://www.npmjs.com/package/@alxia/client) reads it. No
|
|
6
|
+
dependency.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add @alxia/rate-limit @alxia/core
|
|
10
|
+
bun add -d typescript
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
17
|
+
|
|
18
|
+
const app = alxia()
|
|
19
|
+
.get('/health', ...) // not limited
|
|
20
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
21
|
+
.get('/search', ({ rateLimit, reply }) => ...); // limited; rateLimit.remaining
|
|
22
|
+
|
|
23
|
+
const result = await api.get('/search');
|
|
24
|
+
if (result.status === 429) result.data.retryAfter; // seconds
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Past the limit, a 429 with `Retry-After` and
|
|
28
|
+
`{ error: 'rate_limited', retryAfter }`. Every counted response carries the
|
|
29
|
+
IETF draft's `RateLimit-Limit`, `-Remaining`, `-Reset` and `-Policy`.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
| option | default | |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `limit` | required | requests per window: a whole number, 1 or more |
|
|
36
|
+
| `windowMs` | required | the window, in milliseconds: a whole number, 1 or more |
|
|
37
|
+
| `key` | the client's address | what is counted: `(ctx) => string \| undefined`; `undefined` is not counted. `rateLimit<{ user: User }>(…)` lets it read a `user` an earlier plugin adds |
|
|
38
|
+
| `store` | `MemoryStore` | where: `redisStore` from `@alxia/redis`, or your own `RateLimitStore` |
|
|
39
|
+
| `skip` | none | requests not counted |
|
|
40
|
+
| `headers` | `'draft'` | `'legacy'` for `X-RateLimit-*`, or `false` |
|
|
41
|
+
|
|
42
|
+
Behind a proxy, give the app an `ip` option that reads the header it sets:
|
|
43
|
+
`alxia({ ip: (request) => request.headers.get('x-real-ip') ?? undefined })`.
|
|
44
|
+
|
|
45
|
+
## Across processes
|
|
46
|
+
|
|
47
|
+
`MemoryStore` counts in one process. Behind a load balancer, give every
|
|
48
|
+
process the same store: [`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s
|
|
49
|
+
`redisStore` counts in Redis, with GCRA timed by the Redis server's clock.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { redisStore } from '@alxia/redis';
|
|
53
|
+
|
|
54
|
+
app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { name: 'api' }) }));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A store of your own implements `RateLimitStore`: `consume(key, { limit,
|
|
58
|
+
windowMs })` decides — `allowed`, `remaining`, `resetAfter` and
|
|
59
|
+
`retryAfter`, delays in milliseconds — and a refused request counts
|
|
60
|
+
nothing.
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
| export | |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `rateLimit(options)` | the plugin: an app that derives `rateLimit` |
|
|
67
|
+
| `MemoryStore` | a fixed window in one process's memory |
|
|
68
|
+
| `RateLimitStore`, `Decision`, `Policy` | a store's contract |
|
|
69
|
+
| `RateLimitedBody`, `RateLimitInfo`, `RateLimitOptions` | its types |
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/rate-limit/docs): the options and their defaults, which requests are counted, the headers, the 429 on the client, stores, and testing.
|
|
74
|
+
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md): an error message, and what to do about it.
|
|
75
|
+
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,SAAS,GACT,MAAM,cAAc,CAAC;AACtB,OAAO,EACN,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,MAAM,EACX,KAAK,cAAc,GACnB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/rate-limit.ts
|
|
2
|
+
import { definePlugin } from "@alxia/core";
|
|
3
|
+
|
|
4
|
+
// src/store.ts
|
|
5
|
+
class MemoryStore {
|
|
6
|
+
#hits = new Map;
|
|
7
|
+
#sweeper;
|
|
8
|
+
consume(key, policy) {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
let entry = this.#hits.get(key);
|
|
11
|
+
if (entry === undefined || entry.resetAt <= now) {
|
|
12
|
+
entry = { count: 0, resetAt: now + policy.windowMs };
|
|
13
|
+
this.#hits.set(key, entry);
|
|
14
|
+
}
|
|
15
|
+
this.#sweep(policy.windowMs);
|
|
16
|
+
const resetAfter = entry.resetAt - now;
|
|
17
|
+
if (entry.count >= policy.limit) {
|
|
18
|
+
return {
|
|
19
|
+
allowed: false,
|
|
20
|
+
remaining: 0,
|
|
21
|
+
resetAfter,
|
|
22
|
+
retryAfter: resetAfter
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
entry.count++;
|
|
26
|
+
return {
|
|
27
|
+
allowed: true,
|
|
28
|
+
remaining: policy.limit - entry.count,
|
|
29
|
+
resetAfter,
|
|
30
|
+
retryAfter: 0
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
reset(key) {
|
|
34
|
+
this.#hits.delete(key);
|
|
35
|
+
}
|
|
36
|
+
get size() {
|
|
37
|
+
return this.#hits.size;
|
|
38
|
+
}
|
|
39
|
+
#sweep(windowMs) {
|
|
40
|
+
if (this.#sweeper !== undefined)
|
|
41
|
+
return;
|
|
42
|
+
this.#sweeper = setInterval(() => {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
for (const [key, entry] of this.#hits) {
|
|
45
|
+
if (entry.resetAt <= now)
|
|
46
|
+
this.#hits.delete(key);
|
|
47
|
+
}
|
|
48
|
+
if (this.#hits.size === 0) {
|
|
49
|
+
clearInterval(this.#sweeper);
|
|
50
|
+
this.#sweeper = undefined;
|
|
51
|
+
}
|
|
52
|
+
}, windowMs);
|
|
53
|
+
this.#sweeper.unref?.();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/rate-limit.ts
|
|
58
|
+
function rateLimit(options) {
|
|
59
|
+
for (const name of ["limit", "windowMs"]) {
|
|
60
|
+
const value = options[name];
|
|
61
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
62
|
+
throw new TypeError(`rateLimit: ${name} must be a whole number of 1 or more, not ${String(value)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const store = options.store ?? new MemoryStore;
|
|
66
|
+
const key = options.key ?? ((ctx) => ctx.ip);
|
|
67
|
+
const style = options.headers ?? "draft";
|
|
68
|
+
return definePlugin()((app) => app.derive(async (ctx) => {
|
|
69
|
+
const counted = options.skip?.(ctx) ? undefined : await key(ctx);
|
|
70
|
+
if (counted === undefined) {
|
|
71
|
+
const rateLimit = undefined;
|
|
72
|
+
return { rateLimit };
|
|
73
|
+
}
|
|
74
|
+
const decision = await store.consume(counted, {
|
|
75
|
+
limit: options.limit,
|
|
76
|
+
windowMs: options.windowMs
|
|
77
|
+
});
|
|
78
|
+
const reset = Math.ceil(decision.resetAfter / 1000);
|
|
79
|
+
if (style === "draft") {
|
|
80
|
+
ctx.set.headers.set("ratelimit-limit", String(options.limit));
|
|
81
|
+
ctx.set.headers.set("ratelimit-remaining", String(decision.remaining));
|
|
82
|
+
ctx.set.headers.set("ratelimit-reset", String(reset));
|
|
83
|
+
ctx.set.headers.set("ratelimit-policy", `${options.limit};w=${Math.ceil(options.windowMs / 1000)}`);
|
|
84
|
+
} else if (style === "legacy") {
|
|
85
|
+
ctx.set.headers.set("x-ratelimit-limit", String(options.limit));
|
|
86
|
+
ctx.set.headers.set("x-ratelimit-remaining", String(decision.remaining));
|
|
87
|
+
ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil((Date.now() + decision.resetAfter) / 1000)));
|
|
88
|
+
}
|
|
89
|
+
if (!decision.allowed) {
|
|
90
|
+
const retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));
|
|
91
|
+
const body = { error: "rate_limited", retryAfter };
|
|
92
|
+
return ctx.reply(429, body, {
|
|
93
|
+
headers: { "retry-after": String(retryAfter) }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const rateLimit = {
|
|
97
|
+
limit: options.limit,
|
|
98
|
+
remaining: decision.remaining,
|
|
99
|
+
resetAfter: decision.resetAfter
|
|
100
|
+
};
|
|
101
|
+
return { rateLimit };
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
MemoryStore,
|
|
106
|
+
rateLimit
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
//# debugId=1917CEB5F68549FD64756E2164756E21
|
|
110
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/rate-limit.ts", "../src/store.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { type BaseContext, definePlugin, type Empty } from '@alxia/core';\nimport { MemoryStore, type RateLimitStore } from './store';\n\n/**\n * `Requires` is what `key` and `skip` read from the context beyond\n * `BaseContext` — a `user` an earlier plugin adds — and what the app that\n * uses the limit must then give.\n */\nexport interface RateLimitOptions<Requires extends object = Empty> {\n\t/** How many requests a key may make in a window: a whole number, 1 or more. */\n\treadonly limit: number;\n\t/** The window, in milliseconds: a whole number, 1 or more. */\n\treadonly windowMs: number;\n\t/** What is counted: the client's address by default. `undefined` is not counted. */\n\treadonly key?: (\n\t\tctx: BaseContext & Requires,\n\t) => string | undefined | Promise<string | undefined>;\n\t/** Where it is counted: one process's memory by default. */\n\treadonly store?: RateLimitStore;\n\t/** Requests not counted at all. */\n\treadonly skip?: (ctx: BaseContext & Requires) => boolean;\n\t/**\n\t * The `RateLimit` headers of the IETF draft on every counted response,\n\t * `X-RateLimit-*` with `legacy`, or none. `draft` by default.\n\t */\n\treadonly headers?: 'draft' | 'legacy' | false;\n}\n\n/** The body of the 429. */\nexport interface RateLimitedBody {\n\treadonly error: 'rate_limited';\n\t/** Seconds until the window ends. */\n\treadonly retryAfter: number;\n}\n\n/** What the routes behind the limit read: where the key stands. */\nexport interface RateLimitInfo {\n\treadonly limit: number;\n\treadonly remaining: number;\n\t/** Milliseconds until the allowance is whole again. */\n\treadonly resetAfter: number;\n}\n\n/**\n * A rate limit, as a plugin: every route declared after it counts its\n * requests, and answers a 429 past the limit. The 429 is part of each such\n * route's type, so the client reads it.\n *\n * ```ts\n * app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);\n * ```\n *\n * A `key` that reads what an earlier plugin added names it, and the app\n * must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.\n */\nexport function rateLimit<Requires extends object = Empty>(\n\toptions: RateLimitOptions<Requires>,\n) {\n\tfor (const name of ['limit', 'windowMs'] as const) {\n\t\tconst value = options[name];\n\t\tif (!Number.isSafeInteger(value) || value < 1) {\n\t\t\t// 0 refuses every request; a window of 0 or less never ends one.\n\t\t\tthrow new TypeError(\n\t\t\t\t`rateLimit: ${name} must be a whole number of 1 or more, not ${String(value)}`,\n\t\t\t);\n\t\t}\n\t}\n\tconst store = options.store ?? new MemoryStore();\n\tconst key = options.key ?? ((ctx: BaseContext & Requires) => ctx.ip);\n\tconst style = options.headers ?? 'draft';\n\treturn definePlugin<Requires>()((app) =>\n\t\tapp.derive(async (ctx) => {\n\t\t\tconst counted = options.skip?.(ctx) ? undefined : await key(ctx);\n\t\t\tif (counted === undefined) {\n\t\t\t\tconst rateLimit: RateLimitInfo | undefined = undefined;\n\t\t\t\treturn { rateLimit };\n\t\t\t}\n\t\t\tconst decision = await store.consume(counted, {\n\t\t\t\tlimit: options.limit,\n\t\t\t\twindowMs: options.windowMs,\n\t\t\t});\n\t\t\tconst reset = Math.ceil(decision.resetAfter / 1000);\n\t\t\tif (style === 'draft') {\n\t\t\t\tctx.set.headers.set('ratelimit-limit', String(options.limit));\n\t\t\t\tctx.set.headers.set('ratelimit-remaining', String(decision.remaining));\n\t\t\t\tctx.set.headers.set('ratelimit-reset', String(reset));\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'ratelimit-policy',\n\t\t\t\t\t`${options.limit};w=${Math.ceil(options.windowMs / 1000)}`,\n\t\t\t\t);\n\t\t\t} else if (style === 'legacy') {\n\t\t\t\tctx.set.headers.set('x-ratelimit-limit', String(options.limit));\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'x-ratelimit-remaining',\n\t\t\t\t\tString(decision.remaining),\n\t\t\t\t);\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'x-ratelimit-reset',\n\t\t\t\t\tString(Math.ceil((Date.now() + decision.resetAfter) / 1000)),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!decision.allowed) {\n\t\t\t\tconst retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));\n\t\t\t\tconst body: RateLimitedBody = { error: 'rate_limited', retryAfter };\n\t\t\t\treturn ctx.reply(429, body, {\n\t\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst rateLimit: RateLimitInfo | undefined = {\n\t\t\t\tlimit: options.limit,\n\t\t\t\tremaining: decision.remaining,\n\t\t\t\tresetAfter: decision.resetAfter,\n\t\t\t};\n\t\t\treturn { rateLimit };\n\t\t}),\n\t);\n}\n",
|
|
6
|
+
"/** What a store decides for one request. Every duration is a delay, in milliseconds. */\nexport interface Decision {\n\treadonly allowed: boolean;\n\t/** Requests the key may still make now, after this one. */\n\treadonly remaining: number;\n\t/** Until the key's allowance is whole again. */\n\treadonly resetAfter: number;\n\t/** Until a refused request would be allowed; 0 when this one is. */\n\treadonly retryAfter: number;\n}\n\n/** The policy a store applies: `limit` requests per `windowMs`. */\nexport interface Policy {\n\treadonly limit: number;\n\treadonly windowMs: number;\n}\n\n/**\n * Where requests are counted, and what decides. The memory store counts in\n * one process with a fixed window; `@alxia/redis`'s counts across every\n * process sharing a Redis, with GCRA.\n */\nexport interface RateLimitStore {\n\t/** Counts one request for `key` under `policy`; a refused one counts nothing. */\n\tconsume(key: string, policy: Policy): Decision | Promise<Decision>;\n\t/** Forgets `key`: a user who just logged in. */\n\treset(key: string): void | Promise<void>;\n}\n\n/** A fixed-window counter in memory, swept as windows end. */\nexport class MemoryStore implements RateLimitStore {\n\treadonly #hits = new Map<string, { count: number; resetAt: number }>();\n\t#sweeper: ReturnType<typeof setInterval> | undefined;\n\n\tconsume(key: string, policy: Policy): Decision {\n\t\tconst now = Date.now();\n\t\tlet entry = this.#hits.get(key);\n\t\tif (entry === undefined || entry.resetAt <= now) {\n\t\t\tentry = { count: 0, resetAt: now + policy.windowMs };\n\t\t\tthis.#hits.set(key, entry);\n\t\t}\n\t\tthis.#sweep(policy.windowMs);\n\t\tconst resetAfter = entry.resetAt - now;\n\t\tif (entry.count >= policy.limit) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tremaining: 0,\n\t\t\t\tresetAfter,\n\t\t\t\tretryAfter: resetAfter,\n\t\t\t};\n\t\t}\n\t\tentry.count++;\n\t\treturn {\n\t\t\tallowed: true,\n\t\t\tremaining: policy.limit - entry.count,\n\t\t\tresetAfter,\n\t\t\tretryAfter: 0,\n\t\t};\n\t}\n\n\treset(key: string): void {\n\t\tthis.#hits.delete(key);\n\t}\n\n\t/** How many keys are counted. */\n\tget size(): number {\n\t\treturn this.#hits.size;\n\t}\n\n\t#sweep(windowMs: number): void {\n\t\tif (this.#sweeper !== undefined) return;\n\t\tthis.#sweeper = setInterval(() => {\n\t\t\tconst now = Date.now();\n\t\t\tfor (const [key, entry] of this.#hits) {\n\t\t\t\tif (entry.resetAt <= now) this.#hits.delete(key);\n\t\t\t}\n\t\t\tif (this.#hits.size === 0) {\n\t\t\t\tclearInterval(this.#sweeper);\n\t\t\t\tthis.#sweeper = undefined;\n\t\t\t}\n\t\t}, windowMs);\n\t\tthis.#sweeper.unref?.();\n\t}\n}\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "1917CEB5F68549FD64756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type BaseContext, type Empty } from '@alxia/core';
|
|
2
|
+
import { type RateLimitStore } from './store';
|
|
3
|
+
/**
|
|
4
|
+
* `Requires` is what `key` and `skip` read from the context beyond
|
|
5
|
+
* `BaseContext` — a `user` an earlier plugin adds — and what the app that
|
|
6
|
+
* uses the limit must then give.
|
|
7
|
+
*/
|
|
8
|
+
export interface RateLimitOptions<Requires extends object = Empty> {
|
|
9
|
+
/** How many requests a key may make in a window: a whole number, 1 or more. */
|
|
10
|
+
readonly limit: number;
|
|
11
|
+
/** The window, in milliseconds: a whole number, 1 or more. */
|
|
12
|
+
readonly windowMs: number;
|
|
13
|
+
/** What is counted: the client's address by default. `undefined` is not counted. */
|
|
14
|
+
readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
|
|
15
|
+
/** Where it is counted: one process's memory by default. */
|
|
16
|
+
readonly store?: RateLimitStore;
|
|
17
|
+
/** Requests not counted at all. */
|
|
18
|
+
readonly skip?: (ctx: BaseContext & Requires) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The `RateLimit` headers of the IETF draft on every counted response,
|
|
21
|
+
* `X-RateLimit-*` with `legacy`, or none. `draft` by default.
|
|
22
|
+
*/
|
|
23
|
+
readonly headers?: 'draft' | 'legacy' | false;
|
|
24
|
+
}
|
|
25
|
+
/** The body of the 429. */
|
|
26
|
+
export interface RateLimitedBody {
|
|
27
|
+
readonly error: 'rate_limited';
|
|
28
|
+
/** Seconds until the window ends. */
|
|
29
|
+
readonly retryAfter: number;
|
|
30
|
+
}
|
|
31
|
+
/** What the routes behind the limit read: where the key stands. */
|
|
32
|
+
export interface RateLimitInfo {
|
|
33
|
+
readonly limit: number;
|
|
34
|
+
readonly remaining: number;
|
|
35
|
+
/** Milliseconds until the allowance is whole again. */
|
|
36
|
+
readonly resetAfter: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A rate limit, as a plugin: every route declared after it counts its
|
|
40
|
+
* requests, and answers a 429 past the limit. The 429 is part of each such
|
|
41
|
+
* route's type, so the client reads it.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* A `key` that reads what an earlier plugin added names it, and the app
|
|
48
|
+
* must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>): import("@alxia/core").Alxia<Requires & ({
|
|
51
|
+
rateLimit: undefined;
|
|
52
|
+
} | {
|
|
53
|
+
rateLimit: RateLimitInfo;
|
|
54
|
+
}), Empty, "", import("@alxia/core").Reply<429, RateLimitedBody>> & import("@alxia/core").Requiring<Requires>;
|
|
55
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAe,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAE3D;;;;GAIG;AACH,MAAM,WAAW,gBAAgB,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK;IAChE,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,CAAC,GAAG,CAAC,EAAE,CACd,GAAG,EAAE,WAAW,GAAG,QAAQ,KACvB,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,CAAC,EAAE,cAAc,CAAC;IAChC,mCAAmC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,QAAQ,KAAK,OAAO,CAAC;IACzD;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;CAC9C;AAED,2BAA2B;AAC3B,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,qCAAqC;IACrC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uDAAuD;IACvD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,EACxD,OAAO,EAAE,gBAAgB,CAAC,QAAQ,CAAC;;;;8GA4DnC"}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** What a store decides for one request. Every duration is a delay, in milliseconds. */
|
|
2
|
+
export interface Decision {
|
|
3
|
+
readonly allowed: boolean;
|
|
4
|
+
/** Requests the key may still make now, after this one. */
|
|
5
|
+
readonly remaining: number;
|
|
6
|
+
/** Until the key's allowance is whole again. */
|
|
7
|
+
readonly resetAfter: number;
|
|
8
|
+
/** Until a refused request would be allowed; 0 when this one is. */
|
|
9
|
+
readonly retryAfter: number;
|
|
10
|
+
}
|
|
11
|
+
/** The policy a store applies: `limit` requests per `windowMs`. */
|
|
12
|
+
export interface Policy {
|
|
13
|
+
readonly limit: number;
|
|
14
|
+
readonly windowMs: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where requests are counted, and what decides. The memory store counts in
|
|
18
|
+
* one process with a fixed window; `@alxia/redis`'s counts across every
|
|
19
|
+
* process sharing a Redis, with GCRA.
|
|
20
|
+
*/
|
|
21
|
+
export interface RateLimitStore {
|
|
22
|
+
/** Counts one request for `key` under `policy`; a refused one counts nothing. */
|
|
23
|
+
consume(key: string, policy: Policy): Decision | Promise<Decision>;
|
|
24
|
+
/** Forgets `key`: a user who just logged in. */
|
|
25
|
+
reset(key: string): void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** A fixed-window counter in memory, swept as windows end. */
|
|
28
|
+
export declare class MemoryStore implements RateLimitStore {
|
|
29
|
+
#private;
|
|
30
|
+
consume(key: string, policy: Policy): Decision;
|
|
31
|
+
reset(key: string): void;
|
|
32
|
+
/** How many keys are counted. */
|
|
33
|
+
get size(): number;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,gDAAgD;IAChD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,mEAAmE;AACnE,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC9B,iFAAiF;IACjF,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnE,gDAAgD;IAChD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,8DAA8D;AAC9D,qBAAa,WAAY,YAAW,cAAc;;IAIjD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ;IA0B9C,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIxB,iCAAiC;IACjC,IAAI,IAAI,IAAI,MAAM,CAEjB;CAgBD"}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @alxia/rate-limit documentation
|
|
2
|
+
|
|
3
|
+
The [package README](../README.md) is the short version. This folder is
|
|
4
|
+
the long one: the options, defaults and behaviours with an example each,
|
|
5
|
+
the errors you may meet, and what is coming.
|
|
6
|
+
|
|
7
|
+
| Page | Read it when |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| [Guide](guide.md) | choosing what to count and where, reading the 429 on the client, keeping counts across processes, or testing a limit |
|
|
10
|
+
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit does not count as you expected |
|
|
11
|
+
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
This page covers everything `rateLimit` does: which requests it counts, what
|
|
4
|
+
it answers past the limit, the headers it sets, what a route and a client
|
|
5
|
+
read, and where the counts are kept.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
10
|
+
|
|
11
|
+
const app = alxia()
|
|
12
|
+
.get('/health', ({ reply }) => reply(200, 'ok')) // not limited
|
|
13
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
14
|
+
.get('/search', ({ rateLimit, reply }) => // limited
|
|
15
|
+
reply(200, { remaining: rateLimit?.remaining }),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
app.listen({ port: 3000 });
|
|
19
|
+
// the 101st GET /search from one address within a minute
|
|
20
|
+
// → 429 {"error":"rate_limited","retryAfter":…}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The signature
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
function rateLimit<Requires extends object = Empty>(
|
|
27
|
+
options: RateLimitOptions<Requires>,
|
|
28
|
+
): Alxia<…> & Requiring<Requires>; // an app, given to `use`, which checks `Requires`
|
|
29
|
+
|
|
30
|
+
interface RateLimitOptions<Requires extends object = Empty> {
|
|
31
|
+
readonly limit: number;
|
|
32
|
+
readonly windowMs: number;
|
|
33
|
+
readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
|
|
34
|
+
readonly store?: RateLimitStore;
|
|
35
|
+
readonly skip?: (ctx: BaseContext & Requires) => boolean;
|
|
36
|
+
readonly headers?: 'draft' | 'legacy' | false;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`rateLimit` returns an app whose single route hook counts the request. Given
|
|
41
|
+
to `use`, it adds `rateLimit` to the context of every route declared after
|
|
42
|
+
it, and its 429 to each of those routes' types. `Requires` is what `key`
|
|
43
|
+
and `skip` read beyond `BaseContext`; see [Reading the app's context](#reading-the-apps-context).
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
| Option | Type | Default | Effect |
|
|
48
|
+
| --- | --- | --- | --- |
|
|
49
|
+
| `limit` | `number` | required | requests one key may make in a window: a whole number, 1 or more |
|
|
50
|
+
| `windowMs` | `number` | required | the window, in milliseconds: a whole number, 1 or more |
|
|
51
|
+
| `key` | `(ctx: BaseContext & Requires) => string \| undefined \| Promise<…>` | `ctx.ip` | what is counted; `undefined` is not counted |
|
|
52
|
+
| `store` | `RateLimitStore` | a new `MemoryStore` | where the counts are kept |
|
|
53
|
+
| `skip` | `(ctx: BaseContext & Requires) => boolean` | none | requests not counted at all |
|
|
54
|
+
| `headers` | `'draft' \| 'legacy' \| false` | `'draft'` | which rate-limit headers each counted response carries |
|
|
55
|
+
|
|
56
|
+
A `limit` or a `windowMs` that is not a whole number of 1 or more makes
|
|
57
|
+
`rateLimit()` throw a `TypeError` when it is called, so the app fails at
|
|
58
|
+
startup ([troubleshooting](troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)).
|
|
59
|
+
|
|
60
|
+
A store may refuse larger values than `rateLimit` does: `redisStore`
|
|
61
|
+
refuses a `limit × windowMs` above 9,007,199,254,740 and a `windowMs`
|
|
62
|
+
above ten 365-day years (315,360,000,000), on the first request it counts rather than at startup
|
|
63
|
+
([`@alxia/redis`: Policies Redis refuses](https://github.com/softistx/alxia/blob/develop/packages/redis/docs/guide/rate-limits.md#policies-redis-refuses)).
|
|
64
|
+
|
|
65
|
+
### `key`
|
|
66
|
+
|
|
67
|
+
By default a limit counts per client address, `ctx.ip`, which is what the
|
|
68
|
+
app's [`ip` option](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)
|
|
69
|
+
reads. Behind a proxy, that is the proxy's address unless you set `ip`:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const app = alxia({
|
|
73
|
+
ip: (request, server) =>
|
|
74
|
+
request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
|
|
75
|
+
}).use(rateLimit({ limit: 100, windowMs: 60_000 }));
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Count by something else — an API key, a token — by returning it from `key`.
|
|
79
|
+
It may be async. A request whose key is `undefined` is not counted: it passes,
|
|
80
|
+
gets no rate-limit header, and its route reads `rateLimit` as `undefined`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
app.use(
|
|
84
|
+
rateLimit({
|
|
85
|
+
limit: 1_000,
|
|
86
|
+
windowMs: 60 * 60_000,
|
|
87
|
+
key: ({ request }) => request.headers.get('x-api-key') ?? undefined, // no key: not counted
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`key` is typed with `BaseContext`, what every route hook reads: the request,
|
|
93
|
+
`url`, `ip`, `server`, `route` and `pathParams`, and with `Requires`, empty
|
|
94
|
+
by default. To read what an earlier plugin added, see [Reading the app's
|
|
95
|
+
context](#reading-the-apps-context).
|
|
96
|
+
|
|
97
|
+
### `skip`
|
|
98
|
+
|
|
99
|
+
A request `skip` returns `true` for is not counted, exactly like one whose
|
|
100
|
+
key is `undefined`. It is synchronous.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
app.use(
|
|
104
|
+
rateLimit({
|
|
105
|
+
limit: 100,
|
|
106
|
+
windowMs: 60_000,
|
|
107
|
+
skip: ({ ip }) => ip === '127.0.0.1', // the local health checker
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Reading the app's context
|
|
113
|
+
|
|
114
|
+
To count by what an earlier plugin added, such as a signed-in `user`, name
|
|
115
|
+
it as `rateLimit`'s type argument. `key` and `skip` then read it, and the
|
|
116
|
+
limit is a [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
|
|
117
|
+
plugin: an app that does not give `user` before it cannot use it.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const perUser = rateLimit<{ user: { id: string; role: string } }>({
|
|
121
|
+
limit: 100,
|
|
122
|
+
windowMs: 60_000,
|
|
123
|
+
key: ({ user }) => user.id,
|
|
124
|
+
skip: ({ user }) => user.role === 'admin',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const app = alxia()
|
|
128
|
+
.use(auth) // derives user, or answers 401
|
|
129
|
+
.use(perUser)
|
|
130
|
+
.get('/search', handler);
|
|
131
|
+
|
|
132
|
+
alxia().use(perUser);
|
|
133
|
+
// error: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `headers`
|
|
137
|
+
|
|
138
|
+
Every counted response — the route's own and the 429 alike — carries the
|
|
139
|
+
headers of the style you choose:
|
|
140
|
+
|
|
141
|
+
| `headers` | Sent | Values |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| `'draft'` (default) | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy` | the limit; what is left; seconds until the allowance is whole again; `<limit>;w=<window in seconds>` |
|
|
144
|
+
| `'legacy'` | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | the limit; what is left; the Unix time, in seconds, at which the allowance is whole again |
|
|
145
|
+
| `false` | none | — |
|
|
146
|
+
|
|
147
|
+
With `limit: 1, windowMs: 60_000`, the first request answers:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
ratelimit-limit: 1
|
|
151
|
+
ratelimit-remaining: 0
|
|
152
|
+
ratelimit-reset: 60
|
|
153
|
+
ratelimit-policy: 1;w=60
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The 429 carries `Retry-After` whatever `headers` says.
|
|
157
|
+
|
|
158
|
+
### `store`
|
|
159
|
+
|
|
160
|
+
Each `rateLimit` call without a `store` creates its own `MemoryStore`: the
|
|
161
|
+
counts live in that process and are lost when it stops. See
|
|
162
|
+
[Stores](#stores) for sharing them across processes, and for resetting a key.
|
|
163
|
+
|
|
164
|
+
## Which requests are counted
|
|
165
|
+
|
|
166
|
+
The limit is a route hook, so order decides, at runtime and in the types:
|
|
167
|
+
|
|
168
|
+
- a route declared **before** `use(rateLimit(…))` is not counted, and its
|
|
169
|
+
type has no 429;
|
|
170
|
+
- a route declared **after** it is counted, and may answer the 429;
|
|
171
|
+
- inside a [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md#groups),
|
|
172
|
+
the limit stays in the group.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const app = alxia()
|
|
176
|
+
.get('/health', ({ reply }) => reply(200, 'ok')) // never counted
|
|
177
|
+
.group('/auth', (auth) =>
|
|
178
|
+
auth
|
|
179
|
+
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000 })) // 5 per 15 minutes
|
|
180
|
+
.post('/login', ({ reply }) => reply(200, 'ok')),
|
|
181
|
+
)
|
|
182
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 })) // 100 per minute
|
|
183
|
+
.get('/search', ({ reply }) => reply(200, [])); // the group's limit does not reach it
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Route hooks run before the request is validated: a request the route
|
|
187
|
+
refuses with a 400 has already been counted. A request that matches no route
|
|
188
|
+
(a 404) never reaches the hook, and is not counted.
|
|
189
|
+
|
|
190
|
+
Two limits on the same routes both count, and either may answer the 429 —
|
|
191
|
+
a burst limit and an hourly one, say. Each sets its own headers on the
|
|
192
|
+
response, so with two `'draft'` limits the later one's values win; give the
|
|
193
|
+
other `headers: false`. A route reads the `rateLimit` of the later one.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
app
|
|
197
|
+
.use(rateLimit({ limit: 10, windowMs: 1_000 })) // a burst
|
|
198
|
+
.use(rateLimit({ limit: 1_000, windowMs: 60 * 60_000, headers: false })) // an hour
|
|
199
|
+
.get('/search', ({ rateLimit, reply }) => reply(200, rateLimit ?? null)); // the hourly limit's info
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## What a route reads
|
|
203
|
+
|
|
204
|
+
Every route after the limit reads `ctx.rateLimit`:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
interface RateLimitInfo {
|
|
208
|
+
readonly limit: number;
|
|
209
|
+
readonly remaining: number; // after this request
|
|
210
|
+
readonly resetAfter: number; // milliseconds until the allowance is whole again
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
It is `RateLimitInfo | undefined`: `undefined` when the request was not
|
|
215
|
+
counted, by `skip` or an `undefined` key.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
app
|
|
219
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
220
|
+
.get('/quota', ({ rateLimit, reply }) =>
|
|
221
|
+
rateLimit === undefined
|
|
222
|
+
? reply(200, { limited: false as const })
|
|
223
|
+
: reply(200, { limited: true as const, remaining: rateLimit.remaining }),
|
|
224
|
+
);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## The 429
|
|
228
|
+
|
|
229
|
+
Past the limit the hook ends the request before the route runs:
|
|
230
|
+
|
|
231
|
+
```text
|
|
232
|
+
HTTP/1.1 429 Too Many Requests
|
|
233
|
+
retry-after: 60
|
|
234
|
+
ratelimit-limit: 100
|
|
235
|
+
ratelimit-remaining: 0
|
|
236
|
+
ratelimit-reset: 60
|
|
237
|
+
ratelimit-policy: 100;w=60
|
|
238
|
+
|
|
239
|
+
{"error":"rate_limited","retryAfter":60}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
interface RateLimitedBody {
|
|
244
|
+
readonly error: 'rate_limited';
|
|
245
|
+
readonly retryAfter: number; // seconds until a request would be allowed, at least 1
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`retryAfter` and `Retry-After` are the same number. A refused request
|
|
250
|
+
counts nothing: retrying after `retryAfter` seconds succeeds.
|
|
251
|
+
|
|
252
|
+
## On the client
|
|
253
|
+
|
|
254
|
+
[`@alxia/client`](https://www.npmjs.com/package/@alxia/client) reads the
|
|
255
|
+
429 from the app's type: a route after the limit resolves to a union with
|
|
256
|
+
`429`, whose `data` is `{ error: 'rate_limited'; retryAfter: number }`.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { client } from '@alxia/client';
|
|
260
|
+
import type { App } from './server';
|
|
261
|
+
|
|
262
|
+
const api = client<App>('http://localhost:3000');
|
|
263
|
+
|
|
264
|
+
async function search() {
|
|
265
|
+
for (;;) {
|
|
266
|
+
const result = await api.get('/search');
|
|
267
|
+
if (result.status !== 429) return result;
|
|
268
|
+
await Bun.sleep(result.data.retryAfter * 1000); // wait, then try again
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A route declared before the limit has no 429 in its type; comparing its
|
|
274
|
+
status to `429` is a compile error.
|
|
275
|
+
|
|
276
|
+
## Stores
|
|
277
|
+
|
|
278
|
+
A store counts and decides. `rateLimit` asks it once per counted request,
|
|
279
|
+
and never knows which store it was given.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
interface RateLimitStore {
|
|
283
|
+
/** Counts one request for `key` under `policy`; a refused one counts nothing. */
|
|
284
|
+
consume(key: string, policy: Policy): Decision | Promise<Decision>;
|
|
285
|
+
/** Forgets `key`. */
|
|
286
|
+
reset(key: string): void | Promise<void>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface Policy {
|
|
290
|
+
readonly limit: number;
|
|
291
|
+
readonly windowMs: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface Decision {
|
|
295
|
+
readonly allowed: boolean;
|
|
296
|
+
readonly remaining: number; // requests still allowed now, after this one
|
|
297
|
+
readonly resetAfter: number; // ms until the allowance is whole again
|
|
298
|
+
readonly retryAfter: number; // ms until a refused request would be allowed; 0 when allowed
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### `MemoryStore`
|
|
303
|
+
|
|
304
|
+
A fixed window per key, in one process's memory. The first request of a key
|
|
305
|
+
opens a window of `windowMs`; the key may make `limit` requests in it; the
|
|
306
|
+
next request after it ends opens a new one. Ended windows are swept on a
|
|
307
|
+
timer that does not keep the process alive.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { MemoryStore } from '@alxia/rate-limit';
|
|
311
|
+
|
|
312
|
+
const store = new MemoryStore();
|
|
313
|
+
const policy = { limit: 2, windowMs: 60_000 };
|
|
314
|
+
|
|
315
|
+
store.consume('a', policy); // { allowed: true, remaining: 1, resetAfter: 60000, retryAfter: 0 }
|
|
316
|
+
store.consume('a', policy); // { allowed: true, remaining: 0, … }
|
|
317
|
+
store.consume('a', policy); // { allowed: false, remaining: 0, retryAfter: 60000, … }
|
|
318
|
+
store.reset('a');
|
|
319
|
+
store.consume('a', policy); // { allowed: true, remaining: 1, … }
|
|
320
|
+
store.size; // 1: the keys it counts
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
A `MemoryStore` keys its counts by `key` alone, not by policy: give each
|
|
324
|
+
limit its own store, as `rateLimit` does when you pass none.
|
|
325
|
+
|
|
326
|
+
### Across processes
|
|
327
|
+
|
|
328
|
+
Behind a load balancer each process would count on its own, and a client
|
|
329
|
+
would get `limit` requests per process. Give every process the same store:
|
|
330
|
+
[`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s `redisStore`
|
|
331
|
+
counts in Redis, with GCRA timed by the Redis server's clock.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { redisStore } from '@alxia/redis';
|
|
335
|
+
|
|
336
|
+
app.use(
|
|
337
|
+
rateLimit({
|
|
338
|
+
limit: 100,
|
|
339
|
+
windowMs: 60_000,
|
|
340
|
+
store: redisStore(redis, { name: 'api' }), // redis: a Bun RedisClient
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Resetting a key
|
|
346
|
+
|
|
347
|
+
Keep a reference to the store to forget a key — the failed logins of an
|
|
348
|
+
address that has just logged in:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { alxia } from '@alxia/core';
|
|
352
|
+
import { MemoryStore, rateLimit } from '@alxia/rate-limit';
|
|
353
|
+
import { z } from 'zod';
|
|
354
|
+
|
|
355
|
+
const attempts = new MemoryStore();
|
|
356
|
+
const passwords = new Map([['ada', 'lovelace']]);
|
|
357
|
+
|
|
358
|
+
export const app = alxia()
|
|
359
|
+
.group('/auth', (auth) =>
|
|
360
|
+
auth
|
|
361
|
+
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
|
|
362
|
+
.post(
|
|
363
|
+
'/login',
|
|
364
|
+
{ body: z.object({ name: z.string(), password: z.string() }) },
|
|
365
|
+
async ({ body, ip, reply }) => {
|
|
366
|
+
if (passwords.get(body.name) !== body.password) {
|
|
367
|
+
return reply(401, { error: 'invalid_credentials' as const });
|
|
368
|
+
}
|
|
369
|
+
if (ip !== undefined) await attempts.reset(ip); // the key is ctx.ip by default
|
|
370
|
+
return reply(200, { name: body.name });
|
|
371
|
+
},
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The key to reset is the one `key` returned: `ctx.ip` by default.
|
|
377
|
+
|
|
378
|
+
### A store of your own
|
|
379
|
+
|
|
380
|
+
Implement `RateLimitStore`. `consume` decides — `allowed`, `remaining`,
|
|
381
|
+
`resetAfter` and `retryAfter`, each delay in milliseconds — and must count
|
|
382
|
+
nothing for a refused request, or a client that retries on time is refused
|
|
383
|
+
again. `rateLimit` rounds the delays up to seconds for the headers and the
|
|
384
|
+
429 body.
|
|
385
|
+
|
|
386
|
+
## Testing
|
|
387
|
+
|
|
388
|
+
Without a server, `ctx.ip` is `undefined`, so the default key counts
|
|
389
|
+
nothing. Give the app an `ip` that reads a header, and send it:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
import { expect, test } from 'bun:test';
|
|
393
|
+
import { client } from '@alxia/client';
|
|
394
|
+
import { alxia } from '@alxia/core';
|
|
395
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
396
|
+
|
|
397
|
+
const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
|
|
398
|
+
.use(rateLimit({ limit: 2, windowMs: 60_000 }))
|
|
399
|
+
.get('/limited', ({ rateLimit, reply }) => reply(200, rateLimit?.remaining ?? -1));
|
|
400
|
+
|
|
401
|
+
test('answers 429 past the limit, per address', async () => {
|
|
402
|
+
const api = client(app);
|
|
403
|
+
const init = { init: { headers: { 'x-ip': '1.1.1.1' } } };
|
|
404
|
+
expect((await api.get('/limited', init)).data).toBe(1);
|
|
405
|
+
await api.get('/limited', init);
|
|
406
|
+
const third = await api.get('/limited', init);
|
|
407
|
+
expect(third.status).toBe(429);
|
|
408
|
+
expect(third.response.headers.get('retry-after')).toBe('60');
|
|
409
|
+
const other = await api.get('/limited', { init: { headers: { 'x-ip': '2.2.2.2' } } });
|
|
410
|
+
expect(other.status).toBe(200);
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The counts live as long as the app: build a new app per test, or use a
|
|
415
|
+
distinct `x-ip` in each, so one test does not spend another's allowance.
|
|
416
|
+
|
|
417
|
+
## See also
|
|
418
|
+
|
|
419
|
+
- [Troubleshooting](troubleshooting.md): a message, and what to do about it.
|
|
420
|
+
- [`@alxia/core`'s hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md):
|
|
421
|
+
how route hooks and their order work.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/rate-limit` gives an app, and what is coming. This page is a
|
|
4
|
+
direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A runtime dependency.** `@alxia/rate-limit` installs nothing beside
|
|
23
|
+
itself; `@alxia/core` is its only peer.
|
|
24
|
+
- **A Redis store in this package.** This package defines the store's
|
|
25
|
+
contract and ships the memory store; counting across processes is
|
|
26
|
+
`@alxia/redis`'s `redisStore`, which answers the same contract, so an app
|
|
27
|
+
that never runs more than one process installs no Redis client.
|
|
28
|
+
|
|
29
|
+
## Shipped
|
|
30
|
+
|
|
31
|
+
### 0.1.0
|
|
32
|
+
|
|
33
|
+
- **A rate limit as a plugin.** `use(rateLimit({ limit, windowMs }))` counts
|
|
34
|
+
the requests of every route declared after it, per client address by
|
|
35
|
+
default, and answers a 429 with `Retry-After` and
|
|
36
|
+
`{ error: 'rate_limited', retryAfter }` past the limit.
|
|
37
|
+
- **Options that can work, or a startup error.** A `limit` or `windowMs`
|
|
38
|
+
that is not a whole number of 1 or more throws when `rateLimit()` is
|
|
39
|
+
called.
|
|
40
|
+
- **A typed 429.** The 429 is part of each limited route's type, so
|
|
41
|
+
`@alxia/client` reads it, and a route declared before the limit has none.
|
|
42
|
+
- **What the route reads.** `ctx.rateLimit` gives the limit, what is left,
|
|
43
|
+
and when the allowance is whole again.
|
|
44
|
+
- **Standard headers.** The IETF draft's `RateLimit-Limit`, `-Remaining`,
|
|
45
|
+
`-Reset` and `-Policy` on every counted response, `X-RateLimit-*` with
|
|
46
|
+
`headers: 'legacy'`, or none.
|
|
47
|
+
- **What is counted, your way.** `key` counts by an address, a token or an
|
|
48
|
+
API key, sync or async; `skip` and an `undefined` key leave a request
|
|
49
|
+
uncounted. `rateLimit<{ user: User }>(…)` types them with what an earlier
|
|
50
|
+
plugin adds, and an app that does not give it cannot use the limit.
|
|
51
|
+
- **A store contract.** `RateLimitStore` decides each request; `MemoryStore`
|
|
52
|
+
counts a fixed window in one process, and `@alxia/redis`'s `redisStore`
|
|
53
|
+
counts across processes. `reset(key)` forgets a key.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by what you see: a TypeScript error, a response, or a
|
|
4
|
+
limit that does not count the way you expected. `@alxia/rate-limit` throws
|
|
5
|
+
nothing of its own; past the limit it answers a 429.
|
|
6
|
+
|
|
7
|
+
**Types**
|
|
8
|
+
|
|
9
|
+
- [`Property 'user' does not exist on type 'BaseContext & Empty'`](#property-user-does-not-exist-on-type-basecontext--empty)
|
|
10
|
+
- [`Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'`](#type---promiseboolean-is-not-assignable-to-type-ctx-basecontext--empty--boolean)
|
|
11
|
+
- [`'rateLimit' is possibly 'undefined'`](#ratelimit-is-possibly-undefined)
|
|
12
|
+
- [`This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap`](#this-comparison-appears-to-be-unintentional-because-the-types-200--500-and-429-have-no-overlap)
|
|
13
|
+
|
|
14
|
+
**Startup**
|
|
15
|
+
|
|
16
|
+
- [`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)
|
|
17
|
+
|
|
18
|
+
**Responses**
|
|
19
|
+
|
|
20
|
+
- [`429 {"error":"rate_limited","retryAfter":…}`](#429-errorrate_limitedretryafter)
|
|
21
|
+
|
|
22
|
+
**Counting**
|
|
23
|
+
|
|
24
|
+
- [Every client is refused at once](#every-client-is-refused-at-once)
|
|
25
|
+
- [Nothing is limited, and no `RateLimit-*` header is sent](#nothing-is-limited-and-no-ratelimit--header-is-sent)
|
|
26
|
+
- [A client makes more than `limit` requests](#a-client-makes-more-than-limit-requests)
|
|
27
|
+
- [A client is refused before `limit` requests](#a-client-is-refused-before-limit-requests)
|
|
28
|
+
|
|
29
|
+
## Types
|
|
30
|
+
|
|
31
|
+
### `Property 'user' does not exist on type 'BaseContext & Empty'`
|
|
32
|
+
|
|
33
|
+
**When:** a `key` (or `skip`) reads something an earlier `derive` or plugin
|
|
34
|
+
added to the context, and `rateLimit` is not told about it.
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Why:** `key` and `skip` are typed with `BaseContext`, what every route hook
|
|
41
|
+
reads — `request`, `url`, `ip`, `server`, `route`, `pathParams` — plus what
|
|
42
|
+
you name as `rateLimit`'s type argument, and nothing else. `rateLimit` is
|
|
43
|
+
built before it is used, so it cannot see the app it will be used on.
|
|
44
|
+
|
|
45
|
+
**Fix:** name what `key` reads. The app that uses the limit must then give
|
|
46
|
+
it, before the limit:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const perUser = rateLimit<{ user: { id: string } }>({
|
|
50
|
+
limit: 100,
|
|
51
|
+
windowMs: 60_000,
|
|
52
|
+
key: ({ user }) => user.id,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
alxia().use(auth).use(perUser); // auth derives user
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
On an app that does not give `user`, `use(perUser)` is a compile error:
|
|
59
|
+
[`the plugin reads "user", which this app's context does not give`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
|
|
60
|
+
|
|
61
|
+
### `Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'`
|
|
62
|
+
|
|
63
|
+
**When:** `skip` is an `async` function.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
error TS2322: Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'.
|
|
67
|
+
Type 'Promise<boolean>' is not assignable to type 'boolean'.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Why:** `skip` is synchronous: a promise is always truthy, so an async
|
|
71
|
+
`skip` would skip every request. `key` may be async; `skip` may not.
|
|
72
|
+
|
|
73
|
+
**Fix:** decide synchronously, or move the async part into `key` and return
|
|
74
|
+
`undefined` for a request that should not be counted:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
app.use(
|
|
78
|
+
rateLimit({
|
|
79
|
+
limit: 100,
|
|
80
|
+
windowMs: 60_000,
|
|
81
|
+
key: async ({ ip }) => ((await isTrusted(ip)) ? undefined : ip), // undefined: not counted
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `'rateLimit' is possibly 'undefined'`
|
|
87
|
+
|
|
88
|
+
**When:** a route reads `ctx.rateLimit.remaining` directly.
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
error TS18048: 'rateLimit' is possibly 'undefined'.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Why:** `ctx.rateLimit` is `RateLimitInfo | undefined`. It is `undefined`
|
|
95
|
+
for a request that was not counted: `skip` returned `true`, or `key`
|
|
96
|
+
returned `undefined` — which the default key does when `ctx.ip` is
|
|
97
|
+
`undefined`.
|
|
98
|
+
|
|
99
|
+
**Fix:**
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
app
|
|
103
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
104
|
+
.get('/quota', ({ rateLimit, reply }) => reply(200, { remaining: rateLimit?.remaining ?? null }));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap`
|
|
108
|
+
|
|
109
|
+
**When:** client code checks for a 429 on a route that cannot answer one.
|
|
110
|
+
The left-hand union is that route's statuses.
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
error TS2367: This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Why:** the limit applies to the routes declared after
|
|
117
|
+
`use(rateLimit(…))`, in the types as at runtime. This route was declared
|
|
118
|
+
before it, or outside the group that holds the limit, so it is never
|
|
119
|
+
limited and its type has no 429.
|
|
120
|
+
|
|
121
|
+
**Fix:** declare the route after the limit, if it should be limited — or
|
|
122
|
+
drop the check, if it should not:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const app = alxia()
|
|
126
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
127
|
+
.get('/search', ({ reply }) => reply(200, [])); // now 200 | 429 | 500
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Startup
|
|
131
|
+
|
|
132
|
+
### `TypeError: rateLimit: … must be a whole number of 1 or more, not …`
|
|
133
|
+
|
|
134
|
+
**When:** `rateLimit()` is called with a `limit` or a `windowMs` of 0, a
|
|
135
|
+
negative or fractional number, or `NaN`, often from an environment variable
|
|
136
|
+
that is unset. It throws at once, so the app fails at startup:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
TypeError: rateLimit: limit must be a whole number of 1 or more, not 0
|
|
140
|
+
TypeError: rateLimit: windowMs must be a whole number of 1 or more, not NaN
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Why:** a `limit` of 0 would refuse every request, and a window of 0 or
|
|
144
|
+
less would never end one.
|
|
145
|
+
|
|
146
|
+
**Fix:** pass whole numbers. A value read from the environment is a
|
|
147
|
+
string, or `undefined` when the variable is unset: give it a default, and
|
|
148
|
+
let an empty or non-numeric value still fail at startup, as it should:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }));
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Responses
|
|
155
|
+
|
|
156
|
+
### `429 {"error":"rate_limited","retryAfter":…}`
|
|
157
|
+
|
|
158
|
+
**When:** a key has made `limit` counted requests within the window.
|
|
159
|
+
|
|
160
|
+
**Why:** that is the limit working. `retryAfter` and the `Retry-After`
|
|
161
|
+
header are the seconds until a request would be allowed, at least 1. The
|
|
162
|
+
refused request counted nothing.
|
|
163
|
+
|
|
164
|
+
**Fix:** on the client, wait that long; the 429 is in the route's type, so
|
|
165
|
+
`data` is typed:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const result = await api.get('/search');
|
|
169
|
+
if (result.status === 429) {
|
|
170
|
+
await Bun.sleep(result.data.retryAfter * 1000);
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
If it arrives sooner than you expect, see the entries below.
|
|
175
|
+
|
|
176
|
+
## Counting
|
|
177
|
+
|
|
178
|
+
### Every client is refused at once
|
|
179
|
+
|
|
180
|
+
**When:** in production, behind a reverse proxy or a load balancer: one
|
|
181
|
+
busy client, or a handful of ordinary ones, and everyone gets the 429.
|
|
182
|
+
|
|
183
|
+
**Why:** the default key is `ctx.ip`, the connection's address — the
|
|
184
|
+
proxy's, the same for every request. All clients share one allowance.
|
|
185
|
+
|
|
186
|
+
**Fix:** give the app an `ip` option that reads the header your proxy sets,
|
|
187
|
+
and only from a proxy you trust:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const app = alxia({
|
|
191
|
+
ip: (request, server) =>
|
|
192
|
+
request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
|
|
193
|
+
}).use(rateLimit({ limit: 100, windowMs: 60_000 }));
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Nothing is limited, and no `RateLimit-*` header is sent
|
|
197
|
+
|
|
198
|
+
**When:** requests past `limit` still answer 200, without a rate-limit
|
|
199
|
+
header, and `ctx.rateLimit` is `undefined` — typically in a test calling
|
|
200
|
+
the app through `client(app)`, `app.fetch` or `app.request`.
|
|
201
|
+
|
|
202
|
+
**Why:** a request whose key is `undefined` is not counted. Without a server
|
|
203
|
+
there is no connection, so the default `ip` is `undefined`, and so is the
|
|
204
|
+
default key. A custom `key` returning `undefined`, or a `skip` returning
|
|
205
|
+
`true`, does the same. (A route declared before the limit is not counted
|
|
206
|
+
either, and has no 429 in its type.)
|
|
207
|
+
|
|
208
|
+
**Fix:** in tests, read the address from a header you send:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
|
|
212
|
+
.use(rateLimit({ limit: 2, windowMs: 60_000 }))
|
|
213
|
+
.get('/limited', ({ reply }) => reply(200, 'ok'));
|
|
214
|
+
|
|
215
|
+
await client(app).get('/limited', { init: { headers: { 'x-ip': '1.1.1.1' } } });
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### A client makes more than `limit` requests
|
|
219
|
+
|
|
220
|
+
**When:** the app runs as several processes or instances, or restarts.
|
|
221
|
+
|
|
222
|
+
**Why:** the default store is a `MemoryStore`, which counts in one process's
|
|
223
|
+
memory. Each process grants `limit` on its own, and a restart forgets
|
|
224
|
+
every count.
|
|
225
|
+
|
|
226
|
+
**Fix:** give every process the same store, such as `@alxia/redis`'s:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { redisStore } from '@alxia/redis';
|
|
230
|
+
|
|
231
|
+
app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { name: 'api' }) }));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### A client is refused before `limit` requests
|
|
235
|
+
|
|
236
|
+
**When:** a 429 comes earlier than `limit` suggests.
|
|
237
|
+
|
|
238
|
+
**Why:** one of these:
|
|
239
|
+
|
|
240
|
+
- **Two limits share one `MemoryStore`.** It keys its counts by key alone,
|
|
241
|
+
not by policy, so both limits spend the same count, and the smaller
|
|
242
|
+
`limit` refuses first.
|
|
243
|
+
- **Two limits apply to the route.** A group's limit and an app-wide one
|
|
244
|
+
both count; either may refuse. With two `'draft'` limits, the headers
|
|
245
|
+
show the later one's numbers.
|
|
246
|
+
- **Refused requests are counted.** The limit runs before the route
|
|
247
|
+
validates the request, so a request answered 400 has spent one.
|
|
248
|
+
|
|
249
|
+
**Fix:** give each limit its own `MemoryStore` — or none, and `rateLimit`
|
|
250
|
+
creates one — and give all but one limit `headers: false`:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
app
|
|
254
|
+
.use(rateLimit({ limit: 10, windowMs: 1_000 }))
|
|
255
|
+
.use(rateLimit({ limit: 1_000, windowMs: 60 * 60_000, headers: false }));
|
|
256
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@alxia/rate-limit",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Rate limiting for alxia, typed: the 429 is part of every route behind it, and the client reads it",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/rate-limit"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/client": "^0.2.0",
|
|
45
|
+
"@alxia/core": "^0.2.0",
|
|
46
|
+
"@types/bun": "^1.4.2",
|
|
47
|
+
"zod": "^4.2.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@alxia/core": "^0.2.0",
|
|
51
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|