@ultimat3/http 27.10.4 → 27.12.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.12.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "27.
|
|
39
|
-
"@ultimat3/i18n": "27.
|
|
40
|
-
"@ultimat3/schema": "27.
|
|
41
|
-
"@ultimat3/time": "27.
|
|
38
|
+
"@ultimat3/core": "27.12.0",
|
|
39
|
+
"@ultimat3/i18n": "27.12.0",
|
|
40
|
+
"@ultimat3/schema": "27.12.0",
|
|
41
|
+
"@ultimat3/time": "27.12.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/error-map-tier-4.ts
CHANGED
|
@@ -140,4 +140,8 @@ export const TIER_4_ERROR_STATUS = {
|
|
|
140
140
|
X_AGENT_TRANSCRIPT_INVALID: 422,
|
|
141
141
|
// @ultimat3/ai — the upstream ended a turn with a reason nobody knows and an incomplete answer
|
|
142
142
|
X_AI_PROVIDER_UNEXPECTED: 502,
|
|
143
|
+
// @ultimat3/mcp — a meta-split action was called through the wrong dispatcher
|
|
144
|
+
X_MCP_WRONG_TOOL: 400,
|
|
145
|
+
// @ultimat3/mcp — a delete_resource commit did not carry confirmed_by_user: true
|
|
146
|
+
X_MCP_USER_CONFIRMATION_REQUIRED: 428,
|
|
143
147
|
} satisfies Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What the shared rate-limit store does when the DATABASE is down: decide from this process's
|
|
2
|
+
// memory instead, and say so once per window. Before, `take` rethrew `X_DB_UNAVAILABLE` and every
|
|
3
|
+
// request a web pod served — a prerendered `GET /` included — answered 500 for the whole blip.
|
|
4
|
+
//
|
|
5
|
+
// WHY degrade rather than refuse: a per-process bucket still limits. The alternative was a total
|
|
6
|
+
// outage of every route to protect a counter. Every method degrades, auth routes included.
|
|
7
|
+
//
|
|
8
|
+
// WHAT the weakening is, exactly: the fallback is a SEPARATE budget, not a copy of the shared one.
|
|
9
|
+
// It cannot be seeded — the database it would be seeded from is the thing that is down — so each
|
|
10
|
+
// pod's fallback starts full. Behind N replicas a key may spend N × the declared capacity during
|
|
11
|
+
// the outage, ON TOP of whatever it had already spent in Postgres before it: a key that had just
|
|
12
|
+
// emptied its shared bucket gets a fresh one per pod at the transition. When the database answers
|
|
13
|
+
// again, decisions return to the shared row, which never saw the degraded takes. Weaker, bounded by
|
|
14
|
+
// the outage and logged — never no limit. `reset(key)` clears both, so a key the fallback exhausted
|
|
15
|
+
// is not still denied in the next outage after a reset that succeeded.
|
|
16
|
+
//
|
|
17
|
+
// WHY only connection-class failures: a store that answers WRONGLY (bad SQL, a missing table, an
|
|
18
|
+
// executor bug) is not a store that is down, and silently switching it to memory would hide that
|
|
19
|
+
// forever. Those still refuse, exactly as before.
|
|
20
|
+
|
|
21
|
+
import { isUltimateError, logger } from '@ultimat3/core';
|
|
22
|
+
import type { RateLimitStore } from './rate-limit';
|
|
23
|
+
import { memoryRateLimitStore } from './rate-limit';
|
|
24
|
+
|
|
25
|
+
/** Codes `@ultimat3/db` raises when no connection could run the statement at all. */
|
|
26
|
+
const UNREACHABLE_CODES: ReadonlySet<string> = new Set(['X_DB_UNAVAILABLE', 'X_DB_POOL_EXHAUSTED']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SQLSTATEs that mean "this connection, right now", carried on `meta.sqlState` of an
|
|
30
|
+
* `X_DB_STATEMENT_FAILED`: class 08 (connection exception), `57P01`–`57P03` (the server is
|
|
31
|
+
* shutting down, crashed, or not yet accepting — a failover), `53300` (too many connections).
|
|
32
|
+
*/
|
|
33
|
+
const isConnectionSqlState = (state: string): boolean =>
|
|
34
|
+
state.startsWith('08') || ['57P01', '57P02', '57P03', '53300'].includes(state);
|
|
35
|
+
|
|
36
|
+
/** Read by the store's `catch`: true only for a failure that says the database is unreachable. */
|
|
37
|
+
export function isRateLimitStoreUnreachable(error: unknown): boolean {
|
|
38
|
+
if (!isUltimateError(error)) return false;
|
|
39
|
+
if (UNREACHABLE_CODES.has(error.code)) return true;
|
|
40
|
+
const state = error.meta?.['sqlState'];
|
|
41
|
+
return typeof state === 'string' && isConnectionSqlState(state);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How often a still-degraded store says so again: a long outage stays visible, a blip is one line. */
|
|
45
|
+
export const DEGRADED_WARN_EVERY_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
export interface DegradedFallback {
|
|
48
|
+
/** The per-process store a degraded decision is taken from — one per shared store. */
|
|
49
|
+
readonly store: RateLimitStore;
|
|
50
|
+
/** Log the degradation, at most once per `DEGRADED_WARN_EVERY_MS` of the caller's clock. */
|
|
51
|
+
note(error: unknown, nowMs: number): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One fallback per shared store: an empty memory store costs a `Map` until the first degraded
|
|
56
|
+
* decision. The warn is throttled on the CALLER's clock — the one every take is measured against —
|
|
57
|
+
* so a frozen test clock and a real one both get one line per window, never one per request.
|
|
58
|
+
*/
|
|
59
|
+
export function degradedFallback(): DegradedFallback {
|
|
60
|
+
const store = memoryRateLimitStore();
|
|
61
|
+
let lastWarnMs: number | undefined;
|
|
62
|
+
return {
|
|
63
|
+
store,
|
|
64
|
+
note(error, nowMs) {
|
|
65
|
+
if (lastWarnMs !== undefined && nowMs - lastWarnMs < DEGRADED_WARN_EVERY_MS) return;
|
|
66
|
+
lastWarnMs = nowMs;
|
|
67
|
+
logger.warn('http.rate_limit.degraded', {
|
|
68
|
+
code: isUltimateError(error) ? error.code : 'unknown',
|
|
69
|
+
cause:
|
|
70
|
+
'the shared rate-limit store could not reach the database, so this pod is counting limits in its own memory until it can — each limit is per pod, not per fleet, meanwhile',
|
|
71
|
+
fix: 'x doctor --json # names the database binding this pod cannot reach',
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { PgExecutor } from '@ultimat3/core';
|
|
7
7
|
import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
|
|
8
8
|
import { rateLimitDecision } from './rate-limit';
|
|
9
|
+
import { degradedFallback, isRateLimitStoreUnreachable } from './rate-limit-degrade';
|
|
9
10
|
import { rateLimitStoreUnavailable } from './rate-limit-errors';
|
|
10
11
|
import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
11
12
|
|
|
@@ -164,18 +165,32 @@ export function postgresRateLimitStore(
|
|
|
164
165
|
options: PostgresRateLimitStoreOptions,
|
|
165
166
|
): PostgresRateLimitStore {
|
|
166
167
|
const exec = options.executor;
|
|
168
|
+
// Where a decision is taken while the database is unreachable (`rate-limit-degrade.ts`). Still
|
|
169
|
+
// `scope: 'shared'` below: the store IS the fleet's, and the degraded window is logged, bounded
|
|
170
|
+
// by the outage, and a weaker limit rather than none.
|
|
171
|
+
const fallback = degradedFallback();
|
|
172
|
+
const degrade = <T>(error: unknown, nowMs: number, decide: () => Promise<T>): Promise<T> => {
|
|
173
|
+
if (!isRateLimitStoreUnreachable(error)) throw error;
|
|
174
|
+
fallback.note(error, nowMs);
|
|
175
|
+
return decide();
|
|
176
|
+
};
|
|
167
177
|
|
|
168
178
|
return {
|
|
169
179
|
scope: 'shared',
|
|
170
180
|
|
|
171
181
|
async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
let rows: readonly TakeRow[];
|
|
183
|
+
try {
|
|
184
|
+
rows = await exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
|
|
185
|
+
key,
|
|
186
|
+
bucket.capacity,
|
|
187
|
+
bucket.refillPerSecond,
|
|
188
|
+
cost,
|
|
189
|
+
Math.floor(nowMs),
|
|
190
|
+
]);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return degrade(error, nowMs, () => fallback.store.take(key, bucket, cost, nowMs));
|
|
193
|
+
}
|
|
179
194
|
const row = rows[0];
|
|
180
195
|
// An upsert with `returning` answers exactly one row, so none means the executor is not
|
|
181
196
|
// running the statement it was handed. Refusing loudly beats inventing a decision: the
|
|
@@ -185,7 +200,13 @@ export function postgresRateLimitStore(
|
|
|
185
200
|
},
|
|
186
201
|
|
|
187
202
|
async peek(key, bucket, nowMs) {
|
|
188
|
-
|
|
203
|
+
let rows: readonly PeekRow[];
|
|
204
|
+
try {
|
|
205
|
+
rows = await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return degrade(error, nowMs, () => fallback.store.peek(key, bucket, nowMs));
|
|
208
|
+
}
|
|
209
|
+
const row = rows[0];
|
|
189
210
|
const tokens =
|
|
190
211
|
row === undefined
|
|
191
212
|
? bucket.capacity
|
|
@@ -194,6 +215,10 @@ export function postgresRateLimitStore(
|
|
|
194
215
|
},
|
|
195
216
|
|
|
196
217
|
async reset(key): Promise<void> {
|
|
218
|
+
// Both places a decision for this key can come from: a key exhausted in the fallback during
|
|
219
|
+
// one outage would otherwise still be denied in the NEXT one, after a reset that succeeded.
|
|
220
|
+
// The fallback first, so a reset the database refuses still clears what this pod holds.
|
|
221
|
+
await fallback.store.reset(key);
|
|
197
222
|
await exec.query(SQL_RATE_LIMIT_RESET, [key]);
|
|
198
223
|
},
|
|
199
224
|
|