@ultimat3/http 27.10.3 → 27.11.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.10.3",
3
+ "version": "27.11.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.10.3",
39
- "@ultimat3/i18n": "27.10.3",
40
- "@ultimat3/schema": "27.10.3",
41
- "@ultimat3/time": "27.10.3"
38
+ "@ultimat3/core": "27.11.0",
39
+ "@ultimat3/i18n": "27.11.0",
40
+ "@ultimat3/schema": "27.11.0",
41
+ "@ultimat3/time": "27.11.0"
42
42
  }
43
43
  }
@@ -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
- const rows = await exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
173
- key,
174
- bucket.capacity,
175
- bucket.refillPerSecond,
176
- cost,
177
- Math.floor(nowMs),
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
- const row = (await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]))[0];
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