@ultimat3/http 30.9.0 → 31.1.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/CLAUDE.md +44 -40
- package/README.md +48 -35
- package/package.json +7 -6
- package/src/adapter.ts +14 -0
- package/src/auth-redirect.ts +11 -3
- package/src/bearer-mount.ts +7 -5
- package/src/body-ceiling.ts +54 -0
- package/src/byte-range.ts +6 -2
- package/src/compress.ts +77 -6
- package/src/config.ts +29 -7
- package/src/context.ts +3 -2
- package/src/cors.ts +20 -0
- package/src/error-facts.ts +27 -8
- package/src/error-log-level.ts +16 -2
- package/src/error-map-http.ts +2 -0
- package/src/error-map-tier-1.ts +11 -3
- package/src/error-map-tier-2.ts +10 -0
- package/src/error-map-tier-3.ts +14 -0
- package/src/error-map-tier-4.ts +15 -0
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +9 -5
- package/src/hooks.ts +44 -1
- package/src/index.ts +31 -86
- package/src/locale.ts +2 -2
- package/src/pipeline.ts +40 -11
- package/src/problem-meta.ts +8 -5
- package/src/rate-limit-buckets.ts +21 -3
- package/src/rate-limit-config.ts +123 -0
- package/src/rate-limit-degrade.ts +19 -4
- package/src/rate-limit-errors.ts +4 -4
- package/src/rate-limit-installed.ts +43 -53
- package/src/rate-limit-postgres.ts +10 -9
- package/src/rate-limit-redis.ts +176 -0
- package/src/rate-limit-stage.ts +3 -2
- package/src/rate-limit-store-deadline.ts +24 -6
- package/src/rate-limit.ts +13 -108
- package/src/server.ts +19 -37
- package/src/stages.ts +5 -2
|
@@ -1,72 +1,62 @@
|
|
|
1
|
-
// The store a PRIMITIVE's declared `rateLimit:` is counted in —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The store a PRIMITIVE's declared `rateLimit:` is counted in — and, since 31.0.0, the one the
|
|
2
|
+
// HTTP pipeline's limiter counts in too. `@ultimat3/action`'s `invoke` and `@ultimat3/query`'s
|
|
3
|
+
// read path both spend from it, on every surface; one slot, here, so the two tier-3 packages
|
|
4
|
+
// (which cannot import each other) and every `httpServer` count in one place.
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
7
|
+
import type { Bucket, RateLimitConfig, RateLimitStore } from './rate-limit';
|
|
7
8
|
import { memoryRateLimitStore } from './rate-limit';
|
|
8
9
|
import { rateLimitNotShared } from './rate-limit-errors';
|
|
9
10
|
|
|
10
|
-
/** What a process falls back to when nothing
|
|
11
|
+
/** What a process falls back to when nothing installed one: one process' memory. */
|
|
11
12
|
let base: RateLimitStore = memoryRateLimitStore();
|
|
12
|
-
let baseChosen = false;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* reverting the slot under B while B still served. A frame object is its own identity, so the
|
|
17
|
-
* same store adopted twice is two frames.
|
|
14
|
+
* Installed stores over the base: core's `adapterSlot`, a stack each release pops only its own
|
|
15
|
+
* frame from — two boots stopped in either order never revert the slot under the one still serving.
|
|
18
16
|
*/
|
|
19
|
-
const
|
|
17
|
+
const slot = /* @__PURE__ */ adapterSlot<RateLimitStore>(() => base);
|
|
20
18
|
|
|
21
|
-
const current = (): RateLimitStore =>
|
|
22
|
-
const isChosen = (): boolean => baseChosen || frames.length > 0;
|
|
19
|
+
const current = (): RateLimitStore => slot.current();
|
|
23
20
|
|
|
24
21
|
/**
|
|
25
|
-
* The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* The boot's install, and the only one: exported from `@ultimat3/http/adapter`, never from the
|
|
23
|
+
* package's `.` barrel. An app does not call it — it names its store as `rateLimitStore` in
|
|
24
|
+
* `apps/<app>/runtime.ts` and the boot installs that, or `postgresRateLimitStore` over the pool it
|
|
25
|
+
* opened. Answers the undo that removes THIS frame and no other, whatever order installers stop in.
|
|
29
26
|
*/
|
|
30
|
-
export const installRateLimitStore = (store: RateLimitStore): void =>
|
|
31
|
-
base = store;
|
|
32
|
-
baseChosen = true;
|
|
33
|
-
};
|
|
27
|
+
export const installRateLimitStore = (store: RateLimitStore): (() => void) => slot.install(store);
|
|
34
28
|
|
|
35
29
|
export const installedRateLimitStore = (): RateLimitStore => current();
|
|
36
30
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
31
|
+
/**
|
|
32
|
+
* The slot as a store: every call reads whatever is installed AT THAT CALL. What `httpServer`'s
|
|
33
|
+
* limiter counts in, so a store installed after the server was built — a test's override seam, a
|
|
34
|
+
* boot that orders its installs differently — is the one a request spends from. Its `scope` is
|
|
35
|
+
* read live too; the boot check reads it once, when the pipeline is built.
|
|
36
|
+
*/
|
|
37
|
+
export const liveRateLimitStore: RateLimitStore = Object.freeze({
|
|
38
|
+
get scope() {
|
|
39
|
+
return current().scope;
|
|
40
|
+
},
|
|
41
|
+
take: (key: string, bucket: Bucket, cost: number, nowMs: number) =>
|
|
42
|
+
current().take(key, bucket, cost, nowMs),
|
|
43
|
+
peek: (key: string, bucket: Bucket, nowMs: number) => current().peek(key, bucket, nowMs),
|
|
44
|
+
reset: (key: string) => current().reset(key),
|
|
45
|
+
});
|
|
53
46
|
|
|
54
47
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
48
|
+
* The per-test reset: the DEFAULT memory store's buckets emptied, every installed store left where
|
|
49
|
+
* it is. A suite that installed a shared store once (a live Redis or Postgres run) keeps running on
|
|
50
|
+
* it; only the fallback a plain unit test counts in forgets what the previous test spent.
|
|
58
51
|
*/
|
|
59
|
-
export const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const at = frames.indexOf(frame);
|
|
68
|
-
if (at !== -1) frames.splice(at, 1);
|
|
69
|
-
};
|
|
52
|
+
export const clearDefaultRateLimitBuckets = (): void => {
|
|
53
|
+
base = memoryRateLimitStore();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Test-only. Every frame and the base, back to one process' memory. */
|
|
57
|
+
export const resetRateLimitStore = (): void => {
|
|
58
|
+
slot.reset();
|
|
59
|
+
base = memoryRateLimitStore();
|
|
70
60
|
};
|
|
71
61
|
|
|
72
62
|
/**
|
|
@@ -86,7 +76,7 @@ export const assertInstalledRateLimitScope = (config: RateLimitConfig): void =>
|
|
|
86
76
|
*/
|
|
87
77
|
export async function withScratchRateLimitStore<T>(fn: () => Promise<T>): Promise<T> {
|
|
88
78
|
const scratch = memoryRateLimitStore();
|
|
89
|
-
const release =
|
|
79
|
+
const release = installRateLimitStore({
|
|
90
80
|
scope: current().scope,
|
|
91
81
|
take: (key, bucket, cost, nowMs) => scratch.take(key, bucket, cost, nowMs),
|
|
92
82
|
peek: (key, bucket, nowMs) => scratch.peek(key, bucket, nowMs),
|
|
@@ -31,7 +31,10 @@ create table if not exists x_rate_limit (
|
|
|
31
31
|
updated_at timestamptz not null default now()
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
-- v31: never scanned (the purge reads last_ms) and paid on every take. Dropped where an earlier
|
|
35
|
+
-- boot created it, idempotent like the rest. Never a semicolon in a comment here: the boot splits
|
|
36
|
+
-- this text on it.
|
|
37
|
+
drop index if exists x_rate_limit_updated_at_idx;
|
|
35
38
|
`;
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -140,7 +143,7 @@ export interface PostgresRateLimitStore extends RateLimitStore {
|
|
|
140
143
|
/**
|
|
141
144
|
* Delete every bucket that has refilled to capacity, and answer how many. The table is the one
|
|
142
145
|
* part of this store that does not bound itself — Postgres forgets nothing on its own, and the
|
|
143
|
-
* key falls back to the connection address, so a scan rotating through
|
|
146
|
+
* key falls back to the connection address, so a scan rotating through IPv6 /64s mints a row
|
|
144
147
|
* per request. An app runs this from a `task` on whatever cadence its traffic deserves.
|
|
145
148
|
*
|
|
146
149
|
* `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
|
|
@@ -152,19 +155,17 @@ export interface PostgresRateLimitStore extends RateLimitStore {
|
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
/**
|
|
155
|
-
* **
|
|
158
|
+
* **The boot installs it** over the pool it opened (`x dev`, `x serve`); an app names another
|
|
159
|
+
* store, or this one over another executor, in one place:
|
|
156
160
|
*
|
|
157
161
|
* ```ts
|
|
158
|
-
* //
|
|
159
|
-
* http: { rateLimit: { scope: 'shared' } }
|
|
160
|
-
*
|
|
161
|
-
* // apps/web/server.ts — what PROVIDES it
|
|
162
|
+
* // apps/web/runtime.ts — what PROVIDES it
|
|
162
163
|
* const client = db();
|
|
163
|
-
*
|
|
164
|
+
* export const runtime = {
|
|
164
165
|
* rateLimitStore: postgresRateLimitStore({
|
|
165
166
|
* executor: { query: (text, values) => client.query({ text, values }) },
|
|
166
167
|
* }),
|
|
167
|
-
* }
|
|
168
|
+
* };
|
|
168
169
|
* ```
|
|
169
170
|
*
|
|
170
171
|
* `assertRateLimitScope` compares the two once, inside `httpPipeline`, and a `'shared'`
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The shared rate-limit store over Redis: one Lua token bucket per take, so the refill, the spend
|
|
2
|
+
// and the expiry are one atomic step on the server. Beside Postgres, not instead of it: a hot key
|
|
3
|
+
// (one NAT, a tenant bucket) serialises every request on one row lock there, and Redis runs a
|
|
4
|
+
// script per key without one. The bucket expires once refilled, so it bounds itself — no purge.
|
|
5
|
+
|
|
6
|
+
import type { Scheduler } from '@ultimat3/core';
|
|
7
|
+
import { stringField } from '@ultimat3/core';
|
|
8
|
+
import { httpCountInvalid } from './errors';
|
|
9
|
+
import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
|
|
10
|
+
import { rateLimitDecision } from './rate-limit';
|
|
11
|
+
import { degradedFallback, isRateLimitStoreUnreachable } from './rate-limit-degrade';
|
|
12
|
+
import { rateLimitStoreUnavailable } from './rate-limit-errors';
|
|
13
|
+
import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
14
|
+
import { storeDeadline } from './rate-limit-store-deadline';
|
|
15
|
+
|
|
16
|
+
/** The slice of Bun's `RedisClient` (core's `redisClient()`) this store sends through. */
|
|
17
|
+
export interface RedisRateLimitClient {
|
|
18
|
+
send(command: string, args: string[]): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `KEYS[1]` the bucket; `ARGV` capacity, refill per second, cost, the caller's `nowMs`. The same
|
|
23
|
+
* arithmetic as the memory store and `SQL_RATE_LIMIT_TAKE`: refill capped at capacity, elapsed
|
|
24
|
+
* never negative, `l` only ever moves forward (two replicas share no clock). Tokens go back as a
|
|
25
|
+
* STRING — Redis truncates a Lua number reply to an integer, and 0.5 left is not 0. The key
|
|
26
|
+
* expires when it would be full again — the memory store's `forgetAt` — which answers exactly as a
|
|
27
|
+
* missing one. A week's window is a week's key: capping it would hand back a fresh allowance. A
|
|
28
|
+
* refill of 0 never reaches the script (`take` refuses it), so no key is held forever.
|
|
29
|
+
*/
|
|
30
|
+
export const REDIS_RATE_LIMIT_TAKE = `
|
|
31
|
+
local cap = tonumber(ARGV[1])
|
|
32
|
+
local rate = tonumber(ARGV[2])
|
|
33
|
+
local cost = tonumber(ARGV[3])
|
|
34
|
+
local now = tonumber(ARGV[4])
|
|
35
|
+
local held = redis.call('HMGET', KEYS[1], 't', 'l')
|
|
36
|
+
local tokens = cap
|
|
37
|
+
local last = now
|
|
38
|
+
if held[1] then
|
|
39
|
+
tokens = tonumber(held[1])
|
|
40
|
+
last = tonumber(held[2])
|
|
41
|
+
end
|
|
42
|
+
tokens = math.min(cap, tokens + math.max(0, now - last) / 1000 * rate)
|
|
43
|
+
local allowed = tokens >= cost
|
|
44
|
+
if allowed then tokens = tokens - cost end
|
|
45
|
+
redis.call('HSET', KEYS[1], 't', string.format('%.17g', tokens), 'l', string.format('%d', math.max(last, now)))
|
|
46
|
+
redis.call('PEXPIRE', KEYS[1], math.max(1, math.ceil((cap - tokens) / rate * 1000)))
|
|
47
|
+
return { string.format('%.17g', tokens), allowed and 1 or 0 }
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const TAKE_SHA = new Bun.CryptoHasher('sha1').update(REDIS_RATE_LIMIT_TAKE).digest('hex');
|
|
51
|
+
|
|
52
|
+
export interface RedisRateLimitStoreOptions {
|
|
53
|
+
/** Core's `redisClient()` (`@ultimat3/core/redis`), or anything with its `send`. */
|
|
54
|
+
readonly client: RedisRateLimitClient;
|
|
55
|
+
/** Key namespace, so two apps can share one Redis. Default `x`; keys are `<prefix>:rl:<key>`. */
|
|
56
|
+
readonly prefix?: string | undefined;
|
|
57
|
+
/** One round trip's budget before the decision degrades to this pod's memory. Default 250 ms. */
|
|
58
|
+
readonly deadlineMs?: number | undefined;
|
|
59
|
+
/** Injection seam for that deadline; production passes none. */
|
|
60
|
+
readonly schedule?: Scheduler | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Bun's code for a command refused for want of a connection — the store is down, not wrong. */
|
|
64
|
+
const CONNECTION_CLOSED = 'ERR_REDIS_CONNECTION_CLOSED';
|
|
65
|
+
|
|
66
|
+
const unreachable = (error: unknown): boolean =>
|
|
67
|
+
isRateLimitStoreUnreachable(error) || stringField(error, 'code') === CONNECTION_CLOSED;
|
|
68
|
+
|
|
69
|
+
/** A reply list's member as a finite number, or `null` for anything else. */
|
|
70
|
+
const numberAt = (reply: unknown, index: number): number | null => {
|
|
71
|
+
if (!Array.isArray(reply)) return null;
|
|
72
|
+
const value: unknown = reply[index];
|
|
73
|
+
if (value === null || value === undefined) return null;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* **Name it in `apps/<app>/runtime.ts`**, the one place a store is chosen:
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* import { redisClient } from '@ultimat3/core/redis';
|
|
83
|
+
* export const runtime = { rateLimitStore: redisRateLimitStore({ client: redisClient() }) };
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* `scope: 'shared'`, so it satisfies `rateLimit: { scope: 'shared' }`. A Redis that is down or
|
|
87
|
+
* past `deadlineMs` degrades to this pod's memory, logged — the same rule and the same fallback
|
|
88
|
+
* as `postgresRateLimitStore` (`rate-limit-degrade.ts`); an error REPLY (a script the server
|
|
89
|
+
* refuses) still throws.
|
|
90
|
+
*/
|
|
91
|
+
export function redisRateLimitStore(options: RedisRateLimitStoreOptions): RedisRateLimitStore {
|
|
92
|
+
const { client } = options;
|
|
93
|
+
const prefix = options.prefix ?? 'x';
|
|
94
|
+
const keyOf = (key: string): string => `${prefix}:rl:${key}`;
|
|
95
|
+
const deadline = storeDeadline(options.deadlineMs, options.schedule, 'redisRateLimitStore');
|
|
96
|
+
const fallback = degradedFallback('redisRateLimitStore');
|
|
97
|
+
const degrade = <T>(error: unknown, nowMs: number, decide: () => Promise<T>): Promise<T> => {
|
|
98
|
+
if (!unreachable(error)) throw error;
|
|
99
|
+
fallback.note(error, nowMs);
|
|
100
|
+
return decide();
|
|
101
|
+
};
|
|
102
|
+
// EVALSHA first: the script is sent once per server, not per request. A server that has not
|
|
103
|
+
// seen it (a restart, a failover) answers NOSCRIPT, and EVAL both runs and caches it.
|
|
104
|
+
const runTake = async (args: string[]): Promise<unknown> => {
|
|
105
|
+
try {
|
|
106
|
+
return await client.send('EVALSHA', [TAKE_SHA, '1', ...args]);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!(stringField(error, 'message') ?? '').includes('NOSCRIPT')) throw error;
|
|
109
|
+
return client.send('EVAL', [REDIS_RATE_LIMIT_TAKE, '1', ...args]);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
scope: 'shared',
|
|
115
|
+
|
|
116
|
+
async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
|
|
117
|
+
// Config resolution refuses it first; this is the store's own guard for a bucket handed in
|
|
118
|
+
// directly, because a key whose bucket never refills would never expire.
|
|
119
|
+
if (!Number.isFinite(bucket.refillPerSecond) || bucket.refillPerSecond <= 0) {
|
|
120
|
+
throw httpCountInvalid(
|
|
121
|
+
'rateLimit bucket refillPerSecond',
|
|
122
|
+
bucket.refillPerSecond,
|
|
123
|
+
'a finite number of tokens per second above 0',
|
|
124
|
+
'rateLimit: { buckets: { default: { capacity: 120, refillPerSecond: 2 } } }',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
let reply: unknown;
|
|
128
|
+
try {
|
|
129
|
+
reply = await deadline.bound(
|
|
130
|
+
'take',
|
|
131
|
+
runTake([
|
|
132
|
+
keyOf(key),
|
|
133
|
+
String(bucket.capacity),
|
|
134
|
+
String(bucket.refillPerSecond),
|
|
135
|
+
String(cost),
|
|
136
|
+
String(Math.floor(nowMs)),
|
|
137
|
+
]),
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return degrade(error, nowMs, () => fallback.store.take(key, bucket, cost, nowMs));
|
|
141
|
+
}
|
|
142
|
+
const tokens = numberAt(reply, 0);
|
|
143
|
+
const allowed = numberAt(reply, 1);
|
|
144
|
+
// The script always answers both; anything else is not this script running, and inventing
|
|
145
|
+
// "allowed" would be the limiter silently switched off.
|
|
146
|
+
if (tokens === null || allowed === null) throw rateLimitStoreUnavailable('take');
|
|
147
|
+
return rateLimitDecision(bucket, tokens, cost, allowed === 1, nowMs);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async peek(key, bucket, nowMs) {
|
|
151
|
+
let reply: unknown;
|
|
152
|
+
try {
|
|
153
|
+
reply = await deadline.bound('peek', client.send('HMGET', [keyOf(key), 't', 'l']));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return degrade(error, nowMs, () => fallback.store.peek(key, bucket, nowMs));
|
|
156
|
+
}
|
|
157
|
+
const stored = numberAt(reply, 0);
|
|
158
|
+
const lastMs = numberAt(reply, 1);
|
|
159
|
+
const tokens =
|
|
160
|
+
stored === null || lastMs === null
|
|
161
|
+
? bucket.capacity
|
|
162
|
+
: refilledTokens(bucket, stored, lastMs, Math.floor(nowMs));
|
|
163
|
+
return rateLimitPeek(bucket, tokens);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async reset(key): Promise<void> {
|
|
167
|
+
// Both places a decision can come from, the fallback first — the Postgres store's order.
|
|
168
|
+
await fallback.store.reset(key);
|
|
169
|
+
await client.send('DEL', [keyOf(key)]);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RedisRateLimitStore extends RateLimitStore {
|
|
175
|
+
readonly scope: RateLimitScope;
|
|
176
|
+
}
|
package/src/rate-limit-stage.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// has a caller, and the one allowance the `auth` stage spends for a request that failed to have
|
|
3
3
|
// one. Split out of `stages.ts` at the size ceiling; the bucket maths stays in `rate-limit.ts`.
|
|
4
4
|
|
|
5
|
+
import { addressNetwork } from '@ultimat3/core';
|
|
5
6
|
import { actorView, type RequestContext } from './context';
|
|
6
7
|
import {
|
|
7
8
|
type RateLimitConfig,
|
|
@@ -85,9 +86,9 @@ const routeBucketOf = (ctx: RequestContext, config: RateLimitConfig): string | n
|
|
|
85
86
|
return meta?.rateLimit ?? config.defaultBucket;
|
|
86
87
|
};
|
|
87
88
|
|
|
88
|
-
/** The one key failed credentials are counted under: the address alone, never the route. */
|
|
89
|
+
/** The one key failed credentials are counted under: the address alone (an IPv6 /64), never the route. */
|
|
89
90
|
const unauthenticatedKey = (ip: string | null): string =>
|
|
90
|
-
`${UNAUTHENTICATED_SCOPE}|ip:${ip
|
|
91
|
+
`${UNAUTHENTICATED_SCOPE}|ip:${ip === null ? 'unknown' : addressNetwork(ip)}`;
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* The `auth` stage's GATE, before `authenticate()` on a required route: an address whose failure
|
|
@@ -26,11 +26,24 @@ export interface StoreDeadline {
|
|
|
26
26
|
bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
/** Which store's factory a refusal names — the one the author raises `deadlineMs` on. */
|
|
30
|
+
export type SharedStoreOwner = 'postgresRateLimitStore' | 'redisRateLimitStore';
|
|
31
|
+
|
|
32
|
+
/** The command that checks the store a timeout names: no table indexed by a value. */
|
|
33
|
+
const fixFor = (owner: SharedStoreOwner): string =>
|
|
34
|
+
owner === 'redisRateLimitStore'
|
|
35
|
+
? 'redis-cli -u "$REDIS_URL" --latency # a Redis that is slow rather than gone takes redisRateLimitStore({ client, deadlineMs }) raised in the boot that builds it'
|
|
36
|
+
: 'x doctor --json # names the database binding this pod cannot reach; a database that is slow rather than gone takes postgresRateLimitStore({ executor, deadlineMs }) raised in the boot that builds it';
|
|
37
|
+
|
|
38
|
+
export const rateLimitStoreTimeout = (
|
|
39
|
+
statement: string,
|
|
40
|
+
deadlineMs: number,
|
|
41
|
+
owner: SharedStoreOwner = 'postgresRateLimitStore',
|
|
42
|
+
): HttpError =>
|
|
30
43
|
new HttpError({
|
|
31
44
|
code: 'X_RATE_LIMIT_STORE_TIMEOUT',
|
|
32
45
|
cause: `the shared rate-limit store's ${statement} statement did not answer within ${String(deadlineMs)} ms, so this decision was taken from this pod's memory`,
|
|
33
|
-
fix:
|
|
46
|
+
fix: fixFor(owner),
|
|
34
47
|
meta: { statement, deadlineMs },
|
|
35
48
|
});
|
|
36
49
|
|
|
@@ -41,18 +54,23 @@ export const rateLimitStoreTimeout = (statement: string, deadlineMs: number): Ht
|
|
|
41
54
|
export function storeDeadline(
|
|
42
55
|
deadlineMs: number = RATE_LIMIT_STORE_DEADLINE_MS,
|
|
43
56
|
schedule: Scheduler = unrefSchedule,
|
|
57
|
+
owner: SharedStoreOwner = 'postgresRateLimitStore',
|
|
44
58
|
): StoreDeadline {
|
|
45
|
-
finiteCount(
|
|
59
|
+
finiteCount(owner, 'deadlineMs', deadlineMs, 1);
|
|
60
|
+
const handle = owner === 'postgresRateLimitStore' ? 'executor' : 'client';
|
|
46
61
|
assert(
|
|
47
62
|
deadlineMs <= MAX_TIMER_MS,
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
`${owner} deadlineMs is ${String(deadlineMs)}, past the longest delay a timer holds, so it would fire at once`,
|
|
64
|
+
`${owner}({ ${handle}, deadlineMs: ${String(RATE_LIMIT_STORE_DEADLINE_MS)} }) # or any whole number of ms up to ${String(MAX_TIMER_MS)}`,
|
|
50
65
|
);
|
|
51
66
|
return {
|
|
52
67
|
bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T> {
|
|
53
68
|
let cancel: () => void = () => undefined;
|
|
54
69
|
const late = new Promise<never>((_, reject) => {
|
|
55
|
-
cancel = schedule(
|
|
70
|
+
cancel = schedule(
|
|
71
|
+
() => reject(rateLimitStoreTimeout(statement, deadlineMs, owner)),
|
|
72
|
+
deadlineMs,
|
|
73
|
+
);
|
|
56
74
|
});
|
|
57
75
|
return Promise.race([sending, late]).finally(() => cancel());
|
|
58
76
|
},
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
// Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
|
|
2
|
-
// dev/tests and against a shared tier in a multi-replica deployment —
|
|
3
|
-
// `
|
|
4
|
-
//
|
|
5
|
-
import { type Clock,
|
|
2
|
+
// dev/tests and against a shared tier in a multi-replica deployment — named as `rateLimitStore` in
|
|
3
|
+
// `apps/<app>/runtime.ts`, and refused at boot when its scope cannot keep the app's declaration;
|
|
4
|
+
// the bucket maths lives here so every driver agrees on the numbers.
|
|
5
|
+
import { addressNetwork, type Clock, liveClock } from '@ultimat3/core';
|
|
6
6
|
import { httpCountInvalid } from './errors';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
rateLimitInvalid,
|
|
10
|
-
rateLimitNotShared,
|
|
11
|
-
rateLimitScopeUnset,
|
|
12
|
-
tenantBucketUnknown,
|
|
13
|
-
} from './rate-limit-errors';
|
|
7
|
+
import { DEFAULT_RATE_LIMIT } from './rate-limit-config';
|
|
8
|
+
import { rateLimited, rateLimitNotShared } from './rate-limit-errors';
|
|
14
9
|
import { rateLimitHeaders } from './rate-limit-headers';
|
|
15
10
|
import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
16
11
|
|
|
@@ -73,98 +68,6 @@ export interface RateLimitConfig {
|
|
|
73
68
|
readonly scope: RateLimitScope;
|
|
74
69
|
}
|
|
75
70
|
|
|
76
|
-
/**
|
|
77
|
-
* Everything the framework can decide on its own. `scope` is NOT here: one process is the only
|
|
78
|
-
* thing a framework can promise without being told, and defaulting to it made "we did not ask"
|
|
79
|
-
* indistinguishable from "the app said one replica" — so the chart's `replicas: 3` enforced every
|
|
80
|
-
* number three times over, silently, and the boot check that exists for this
|
|
81
|
-
* (`assertRateLimitScope`) never fired because it only reads a `'shared'` declaration. The
|
|
82
|
-
* comment that used to sit here was right about the fact and wrong about the conclusion: ask.
|
|
83
|
-
*/
|
|
84
|
-
export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
|
|
85
|
-
enabled: true,
|
|
86
|
-
defaultBucket: 'default',
|
|
87
|
-
tenantBucket: null,
|
|
88
|
-
buckets: {
|
|
89
|
-
default: { capacity: 120, refillPerSecond: 2 },
|
|
90
|
-
// Login/signup style endpoints: slow, no burst.
|
|
91
|
-
auth: { capacity: 10, refillPerSecond: 0.2 },
|
|
92
|
-
mutation: { capacity: 30, refillPerSecond: 1 },
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* `defineHttpConfig`'s one resolver for this slice, so the refusal happens where an author can
|
|
98
|
-
* act on it rather than at the first request. A limiter that is switched off has nothing to be
|
|
99
|
-
* wrong about, so `enabled: false` needs no declaration — and reads as `'process'`.
|
|
100
|
-
*/
|
|
101
|
-
export const resolveRateLimitConfig = (
|
|
102
|
-
input: Partial<RateLimitConfig> | undefined,
|
|
103
|
-
): RateLimitConfig => {
|
|
104
|
-
const merged = { ...DEFAULT_RATE_LIMIT, ...input };
|
|
105
|
-
// Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
|
|
106
|
-
// through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
|
|
107
|
-
// be the 120-burst read bucket — looser than what the author declared, and invisible.
|
|
108
|
-
if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
|
|
109
|
-
throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
|
|
110
|
-
}
|
|
111
|
-
if (input?.scope !== undefined) return { ...merged, scope: input.scope };
|
|
112
|
-
if (!merged.enabled) return { ...merged, scope: 'process' };
|
|
113
|
-
throw rateLimitScopeUnset();
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
/** How a route, an action or a query spells a limit before it becomes a `Bucket`. */
|
|
117
|
-
export interface RateLimitDeclaration {
|
|
118
|
-
/** The burst a caller may spend at once. */
|
|
119
|
-
readonly limit: number;
|
|
120
|
-
/** The window that refills it. */
|
|
121
|
-
readonly windowMs: number;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* `{ limit, windowMs }` as the limiter's own vocabulary: `5 / 600_000ms` is five held, one back
|
|
126
|
-
* every two minutes. The only conversion between a declaration and the enforcement, so the
|
|
127
|
-
* numbers an OpenAPI operation publishes and the numbers `withRouteBuckets` registers cannot
|
|
128
|
-
* drift. It lives HERE, beside `Bucket` and the maths, because `@ultimat3/action` and
|
|
129
|
-
* `@ultimat3/query` are the same tier and can never import each other — a copy in one of them is
|
|
130
|
-
* a second answer to "what does this limit mean" for the other.
|
|
131
|
-
*
|
|
132
|
-
* **The COMPUTED rate is validated, not just the two declared halves.** The division is where a
|
|
133
|
-
* pair that reads fine becomes one the limiter cannot run on, in both directions:
|
|
134
|
-
* `{ limit: Number.MAX_VALUE, windowMs: 1 }` computes to `Infinity` — a bucket that never empties,
|
|
135
|
-
* which is the same "declared a limit, enforced nothing" as `windowMs: 0` — and a tiny limit over
|
|
136
|
-
* a huge window underflows to `0`, a bucket that never refills, so the endpoint is closed after
|
|
137
|
-
* its first burst rather than limited.
|
|
138
|
-
*/
|
|
139
|
-
export const toBucket = (owner: string, declared: RateLimitDeclaration): Bucket => {
|
|
140
|
-
const refuse = (reason: string): never => {
|
|
141
|
-
throw rateLimitInvalid({
|
|
142
|
-
owner,
|
|
143
|
-
limit: declared.limit,
|
|
144
|
-
windowMs: declared.windowMs,
|
|
145
|
-
reason,
|
|
146
|
-
});
|
|
147
|
-
};
|
|
148
|
-
// A capacity under one token cannot admit a single request, so the endpoint is closed, not
|
|
149
|
-
// limited — a policy's job, never a rate limit's.
|
|
150
|
-
if (!Number.isFinite(declared.limit) || declared.limit < 1) {
|
|
151
|
-
refuse('limit must be a finite number of at least 1 request');
|
|
152
|
-
}
|
|
153
|
-
if (!Number.isFinite(declared.windowMs) || declared.windowMs <= 0) {
|
|
154
|
-
refuse('windowMs must be finite and greater than zero');
|
|
155
|
-
}
|
|
156
|
-
const refillPerSecond = declared.limit / (declared.windowMs / 1000);
|
|
157
|
-
// Kept though the two checks above make it unreachable today: with `limit >= 1` and a finite
|
|
158
|
-
// window the smallest rate is ~5.6e-306, which is normal, not zero. It is the guard that has to
|
|
159
|
-
// move first if `limit >= 1` is ever relaxed.
|
|
160
|
-
if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
|
|
161
|
-
refuse(
|
|
162
|
-
`the refill rate it computes to is ${refillPerSecond} per second, which is a bucket that never empties — nothing would be enforced`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
return { capacity: declared.limit, refillPerSecond };
|
|
166
|
-
};
|
|
167
|
-
|
|
168
71
|
interface BucketState {
|
|
169
72
|
tokens: number;
|
|
170
73
|
lastMs: number;
|
|
@@ -255,8 +158,9 @@ const assertFiniteKeyCap = (option: string, value: number): number => {
|
|
|
255
158
|
/**
|
|
256
159
|
* Default driver: correct for one process, which is exactly dev and tests.
|
|
257
160
|
*
|
|
258
|
-
* Bounded, because the key falls back to the connection address: a scan rotating through
|
|
259
|
-
* IPv6 /
|
|
161
|
+
* Bounded, because the key falls back to the connection address: a scan rotating through
|
|
162
|
+
* IPv6 /64s (one key each since v31, core's `addressNetwork`) mints a fresh key per prefix, and an
|
|
163
|
+
* unbounded map turns that into an OOM. Two
|
|
260
164
|
* rules keep it flat. A refilled bucket is *forgotten*, not evicted — it answers exactly as a
|
|
261
165
|
* missing one, so dropping it costs nothing and a scanner's one-request buckets qualify within
|
|
262
166
|
* a second. Only if that is not enough does the cap evict live state, and then the entries
|
|
@@ -378,7 +282,7 @@ export const rateLimitSpends = (
|
|
|
378
282
|
: parts.orgId !== null
|
|
379
283
|
? `org:${parts.orgId}`
|
|
380
284
|
: parts.ip !== null
|
|
381
|
-
? `ip:${parts.ip}`
|
|
285
|
+
? `ip:${addressNetwork(parts.ip)}`
|
|
382
286
|
: (parts.unattributed ?? 'ip:unknown');
|
|
383
287
|
const spends: RateLimitSpend[] =
|
|
384
288
|
buckets.route === null ? [] : [{ key: `${parts.routeName}|${subject}`, bucket: buckets.route }];
|
|
@@ -425,8 +329,9 @@ export const rateLimiter = (options: {
|
|
|
425
329
|
clock?: Clock;
|
|
426
330
|
}): RateLimiter => {
|
|
427
331
|
const store = options.store ?? memoryRateLimitStore();
|
|
428
|
-
|
|
429
|
-
|
|
332
|
+
// Read at each call, never captured: with no clock passed, the limiter follows the `clock` port
|
|
333
|
+
// (`runtime.ts`, a test's `installClock`) even when that is installed after it was built.
|
|
334
|
+
const now = (): number => (options.clock ?? liveClock).now().getTime();
|
|
430
335
|
// `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and
|
|
431
336
|
// `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it
|
|
432
337
|
// holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of
|