svelte-adapter-uws-extensions 0.6.0-next.21 → 0.6.0-next.22
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 +1 -1
- package/src/redis/ratelimit.d.ts +16 -8
- package/src/redis/ratelimit.js +54 -22
- package/src/shared/breaker.d.ts +23 -13
- package/src/shared/breaker.js +103 -60
- package/src/shared/caps.js +8 -0
package/package.json
CHANGED
package/src/redis/ratelimit.d.ts
CHANGED
|
@@ -11,6 +11,14 @@ export interface RedisRateLimitOptions {
|
|
|
11
11
|
blockDuration?: number;
|
|
12
12
|
/** Key extraction mode. @default 'ip' */
|
|
13
13
|
keyBy?: 'ip' | 'connection' | ((ws: any) => string);
|
|
14
|
+
/**
|
|
15
|
+
* Optional per-connection tenant resolver. When set, the bucket key is scoped by
|
|
16
|
+
* the returned tenant id (joined to the key with a NUL, so it stays unambiguous even
|
|
17
|
+
* for IPv6 keys), so two tenants sharing an IP / connection / custom key get
|
|
18
|
+
* independent buckets. Return null/undefined for an unscoped connection; omit for a
|
|
19
|
+
* single-tenant deploy (byte-identical).
|
|
20
|
+
*/
|
|
21
|
+
tenant?: (ws: any) => string | null | undefined;
|
|
14
22
|
/** Prometheus metrics registry. */
|
|
15
23
|
metrics?: MetricsRegistry;
|
|
16
24
|
/** Circuit breaker instance. */
|
|
@@ -29,14 +37,14 @@ export interface ConsumeResult {
|
|
|
29
37
|
export interface RedisRateLimiter {
|
|
30
38
|
/** Attempt to consume tokens. */
|
|
31
39
|
consume(ws: any, cost?: number): Promise<ConsumeResult>;
|
|
32
|
-
/** Clear the bucket for a key. */
|
|
33
|
-
reset(key: string): Promise<void>;
|
|
34
|
-
/** Manually ban a key. */
|
|
35
|
-
ban(key: string, duration?: number): Promise<void>;
|
|
36
|
-
/** Remove a ban. */
|
|
37
|
-
unban(key: string): Promise<void>;
|
|
38
|
-
/** Reset all state. */
|
|
39
|
-
clear(): Promise<void>;
|
|
40
|
+
/** Clear the bucket for a key (optionally scoped to a tenant). */
|
|
41
|
+
reset(key: string, tenant?: string | null): Promise<void>;
|
|
42
|
+
/** Manually ban a key (optionally scoped to a tenant). */
|
|
43
|
+
ban(key: string, duration?: number, tenant?: string | null): Promise<void>;
|
|
44
|
+
/** Remove a ban (optionally scoped to a tenant). */
|
|
45
|
+
unban(key: string, tenant?: string | null): Promise<void>;
|
|
46
|
+
/** Reset all state, or only one tenant's buckets when a tenant id is given. */
|
|
47
|
+
clear(tenant?: string | null): Promise<void>;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
package/src/redis/ratelimit.js
CHANGED
|
@@ -42,6 +42,13 @@ return 1
|
|
|
42
42
|
* @property {number} interval - Refill interval in milliseconds. Must be positive.
|
|
43
43
|
* @property {number} [blockDuration=0] - Auto-ban duration in ms when exhausted. 0 = no ban.
|
|
44
44
|
* @property {'ip' | 'connection' | ((ws: any) => string)} [keyBy='ip'] - Key extraction mode.
|
|
45
|
+
* @property {(ws: any) => (string | null | undefined)} [tenant] - Optional per-connection
|
|
46
|
+
* tenant resolver. When set, the bucket key is scoped by the returned tenant id, so two
|
|
47
|
+
* tenants sharing an IP / connection / custom key get independent buckets and a tenant's
|
|
48
|
+
* admin ops (`reset` / `ban` / `unban` / `clear`) touch only that tenant. Return
|
|
49
|
+
* null/undefined for an unscoped connection. Omit for a single-tenant deploy (byte-identical
|
|
50
|
+
* to before). The id should be a delimiter-safe slug - it is joined to the key with a NUL,
|
|
51
|
+
* so it stays unambiguous even when the key is an IPv6 address.
|
|
45
52
|
*/
|
|
46
53
|
|
|
47
54
|
/**
|
|
@@ -54,10 +61,10 @@ return 1
|
|
|
54
61
|
/**
|
|
55
62
|
* @typedef {Object} RedisRateLimiter
|
|
56
63
|
* @property {(ws: any, cost?: number) => Promise<ConsumeResult>} consume
|
|
57
|
-
* @property {(key: string) => Promise<void>} reset
|
|
58
|
-
* @property {(key: string, duration?: number) => Promise<void>} ban
|
|
59
|
-
* @property {(key: string) => Promise<void>} unban
|
|
60
|
-
* @property {() => Promise<void>} clear
|
|
64
|
+
* @property {(key: string, tenant?: string | null) => Promise<void>} reset
|
|
65
|
+
* @property {(key: string, duration?: number, tenant?: string | null) => Promise<void>} ban
|
|
66
|
+
* @property {(key: string, tenant?: string | null) => Promise<void>} unban
|
|
67
|
+
* @property {(tenant?: string | null) => Promise<void>} clear
|
|
61
68
|
*/
|
|
62
69
|
|
|
63
70
|
/**
|
|
@@ -72,7 +79,7 @@ export function createRateLimit(client, options) {
|
|
|
72
79
|
throw new Error('redis ratelimit: options object is required');
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
const { points, interval, blockDuration = 0, keyBy = 'ip' } = options;
|
|
82
|
+
const { points, interval, blockDuration = 0, keyBy = 'ip', tenant } = options;
|
|
76
83
|
|
|
77
84
|
if (!Number.isInteger(points) || points <= 0) {
|
|
78
85
|
throw new Error('redis ratelimit: points must be a positive integer');
|
|
@@ -86,6 +93,9 @@ export function createRateLimit(client, options) {
|
|
|
86
93
|
if (keyBy !== 'ip' && keyBy !== 'connection' && typeof keyBy !== 'function') {
|
|
87
94
|
throw new Error("redis ratelimit: keyBy must be 'ip', 'connection', or a function");
|
|
88
95
|
}
|
|
96
|
+
if (tenant !== undefined && typeof tenant !== 'function') {
|
|
97
|
+
throw new Error('redis ratelimit: tenant must be a function (ws) => id | null');
|
|
98
|
+
}
|
|
89
99
|
|
|
90
100
|
const redis = client.redis;
|
|
91
101
|
|
|
@@ -96,9 +106,15 @@ export function createRateLimit(client, options) {
|
|
|
96
106
|
|
|
97
107
|
const b = options.breaker;
|
|
98
108
|
const m = options.metrics;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
// When a tenant resolver is set, label the rate-limit counters by tenant so an
|
|
110
|
+
// operator can see per-tenant allow/deny/ban rates. Opt-in (no resolver -> no
|
|
111
|
+
// label, byte-identical series); the label is bounded by the metric's default
|
|
112
|
+
// max-series cardinality cap, so a tenant burst cannot blow up the registry.
|
|
113
|
+
const labelTenants = typeof tenant === 'function';
|
|
114
|
+
const tlabels = labelTenants ? ['tenant_id'] : undefined;
|
|
115
|
+
const mAllowed = m?.counter('ratelimit_allowed_total', 'Requests allowed', tlabels);
|
|
116
|
+
const mDenied = m?.counter('ratelimit_denied_total', 'Requests denied', tlabels);
|
|
117
|
+
const mBans = m?.counter('ratelimit_bans_total', 'Bans applied', tlabels);
|
|
102
118
|
|
|
103
119
|
// Per-connection keying uses a WeakMap to avoid leaks
|
|
104
120
|
const wsKeys = new WeakMap();
|
|
@@ -129,8 +145,18 @@ export function createRateLimit(client, options) {
|
|
|
129
145
|
return 'unknown';
|
|
130
146
|
}
|
|
131
147
|
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
// The tenant segment (when a `tenant` resolver is set) is FIRST and NUL-delimited,
|
|
149
|
+
// so a validated id stays unambiguous even when the key is an IPv6 address (colons).
|
|
150
|
+
// Null tenant -> no segment, byte-identical to the single-tenant key space. The id is
|
|
151
|
+
// rejected if it contains the NUL delimiter (the one char that would let two distinct
|
|
152
|
+
// tenants collide on one bucket); this is the injection-safety the realtime tier
|
|
153
|
+
// validates at its own boundary, enforced here too so the property does not silently
|
|
154
|
+
// depend on the caller's resolver. The check short-circuits on the null (default) path.
|
|
155
|
+
function bucketKey(key, tenantId) {
|
|
156
|
+
if (tenantId && tenantId.indexOf('\0') !== -1) {
|
|
157
|
+
throw new Error('redis ratelimit: tenant id must not contain a NUL byte (it is the bucket-key delimiter)');
|
|
158
|
+
}
|
|
159
|
+
return client.key(SCRIPT_VERSION + ':ratelimit:' + (tenantId ? tenantId + '\0' : '') + key);
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
return {
|
|
@@ -139,16 +165,18 @@ export function createRateLimit(client, options) {
|
|
|
139
165
|
throw new Error('redis ratelimit: cost must be a positive integer');
|
|
140
166
|
}
|
|
141
167
|
const key = resolveKey(ws);
|
|
168
|
+
const tenantId = tenant ? tenant(ws) : null;
|
|
142
169
|
|
|
143
170
|
const result = await withBreaker(b, () =>
|
|
144
|
-
redis.eval(CONSUME_SCRIPT, 1, bucketKey(key), points, interval, cost, blockDuration)
|
|
171
|
+
redis.eval(CONSUME_SCRIPT, 1, bucketKey(key, tenantId), points, interval, cost, blockDuration)
|
|
145
172
|
);
|
|
146
173
|
|
|
147
174
|
const allowed = result[0] === 1;
|
|
175
|
+
const labels = labelTenants ? { tenant_id: tenantId || '' } : undefined;
|
|
148
176
|
if (allowed) {
|
|
149
|
-
mAllowed?.inc();
|
|
177
|
+
mAllowed?.inc(labels);
|
|
150
178
|
} else {
|
|
151
|
-
mDenied?.inc();
|
|
179
|
+
mDenied?.inc(labels);
|
|
152
180
|
}
|
|
153
181
|
|
|
154
182
|
return {
|
|
@@ -158,24 +186,28 @@ export function createRateLimit(client, options) {
|
|
|
158
186
|
};
|
|
159
187
|
},
|
|
160
188
|
|
|
161
|
-
async reset(key) {
|
|
162
|
-
await withBreaker(b, () => redis.del(bucketKey(key)));
|
|
189
|
+
async reset(key, tenantId) {
|
|
190
|
+
await withBreaker(b, () => redis.del(bucketKey(key, tenantId)));
|
|
163
191
|
},
|
|
164
192
|
|
|
165
|
-
async ban(key, duration) {
|
|
193
|
+
async ban(key, duration, tenantId) {
|
|
166
194
|
const dur = duration ?? (blockDuration || 60000);
|
|
167
195
|
if (dur <= 0) throw new Error('redis ratelimit: ban duration must be positive');
|
|
168
|
-
const bk = bucketKey(key);
|
|
196
|
+
const bk = bucketKey(key, tenantId);
|
|
169
197
|
await withBreaker(b, () => redis.eval(BAN_SCRIPT, 1, bk, dur, points, interval));
|
|
170
|
-
mBans?.inc();
|
|
198
|
+
mBans?.inc(labelTenants ? { tenant_id: tenantId || '' } : undefined);
|
|
171
199
|
},
|
|
172
200
|
|
|
173
|
-
async unban(key) {
|
|
174
|
-
await withBreaker(b, () => redis.hset(bucketKey(key), 'bannedUntil', 0));
|
|
201
|
+
async unban(key, tenantId) {
|
|
202
|
+
await withBreaker(b, () => redis.hset(bucketKey(key, tenantId), 'bannedUntil', 0));
|
|
175
203
|
},
|
|
176
204
|
|
|
177
|
-
|
|
178
|
-
|
|
205
|
+
// No tenant -> clears the whole key space (every tenant's buckets too, since the
|
|
206
|
+
// glob `*` spans the NUL-delimited tenant segments). Pass a tenant id to clear
|
|
207
|
+
// only that tenant's buckets.
|
|
208
|
+
async clear(tenantId) {
|
|
209
|
+
const suffix = tenantId ? tenantId + '\0*' : '*';
|
|
210
|
+
await withBreaker(b, () => scanAndUnlink(redis, client.key(SCRIPT_VERSION + ':ratelimit:' + suffix)));
|
|
179
211
|
}
|
|
180
212
|
};
|
|
181
213
|
}
|
package/src/shared/breaker.d.ts
CHANGED
|
@@ -12,20 +12,29 @@ export interface CircuitBreakerOptions {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface CircuitBreaker {
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* State is partitioned by an optional string `key`, so one key (e.g. a tenant) can
|
|
17
|
+
* break without tripping the others. The default key `''` is the single global
|
|
18
|
+
* breaker - every method is byte-identical for callers that pass no key.
|
|
19
|
+
*/
|
|
20
|
+
/** State of the default (`''`) key. */
|
|
16
21
|
readonly state: 'healthy' | 'broken' | 'probing';
|
|
17
|
-
/** True only when state is healthy. */
|
|
22
|
+
/** True only when the default key's state is healthy. */
|
|
18
23
|
readonly isHealthy: boolean;
|
|
19
|
-
/**
|
|
24
|
+
/** Default key's consecutive failure count. */
|
|
20
25
|
readonly failures: number;
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
26
|
+
/** State for a given key (default `''`). */
|
|
27
|
+
stateOf(key?: string): 'healthy' | 'broken' | 'probing';
|
|
28
|
+
/** Failure count for a given key (default `''`). */
|
|
29
|
+
failuresOf(key?: string): number;
|
|
30
|
+
/** Throws CircuitBrokenError if the circuit for `key` (default `''`) is broken. */
|
|
31
|
+
guard(key?: string): void;
|
|
32
|
+
/** Record a successful operation for `key`. May transition to healthy. */
|
|
33
|
+
success(key?: string): void;
|
|
34
|
+
/** Record a failed operation for `key`. May transition to broken. */
|
|
35
|
+
failure(err?: any, key?: string): void;
|
|
36
|
+
/** Force `key` (default `''`) back to healthy state. */
|
|
37
|
+
reset(key?: string): void;
|
|
29
38
|
/**
|
|
30
39
|
* Register a state-transition listener. Multiple subscribers are
|
|
31
40
|
* supported and the constructor-time `onStateChange` callback (if
|
|
@@ -40,9 +49,10 @@ export interface CircuitBreaker {
|
|
|
40
49
|
|
|
41
50
|
/**
|
|
42
51
|
* Run an async operation through a breaker. Guards before, records
|
|
43
|
-
* success/failure after. Pass null/undefined breaker to skip.
|
|
52
|
+
* success/failure after. Pass null/undefined breaker to skip. The optional `key`
|
|
53
|
+
* partitions the breaker state (default `''` = the global breaker).
|
|
44
54
|
*/
|
|
45
|
-
export function withBreaker<T>(breaker: CircuitBreaker | null | undefined, fn: () => Promise<T
|
|
55
|
+
export function withBreaker<T>(breaker: CircuitBreaker | null | undefined, fn: () => Promise<T>, key?: string): Promise<T>;
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Create a circuit breaker.
|
package/src/shared/breaker.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @module svelte-adapter-uws-extensions/breaker
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { MAX_BREAKER_LISTENERS } from './caps.js';
|
|
17
|
+
import { MAX_BREAKER_LISTENERS, MAX_BREAKER_KEYS } from './caps.js';
|
|
18
18
|
import { setTimer, clearTimer } from './runtime.js';
|
|
19
19
|
|
|
20
20
|
export class CircuitBrokenError extends Error {
|
|
@@ -32,16 +32,24 @@ export class CircuitBrokenError extends Error {
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
+
* Per-KEY circuit breaker. State is partitioned by an optional string key, so one
|
|
36
|
+
* key (e.g. a tenant) can break without tripping the others. The default key `''`
|
|
37
|
+
* is the single global breaker - every method is byte-identical for callers that
|
|
38
|
+
* pass no key, so existing shared-infra call sites are unchanged. A caller
|
|
39
|
+
* protecting a tenant-specific resource passes the tenant id to isolate its state.
|
|
40
|
+
*
|
|
35
41
|
* @typedef {Object} CircuitBreaker
|
|
36
|
-
* @property {'healthy' | 'broken' | 'probing'} state
|
|
37
|
-
* @property {boolean} isHealthy - True only when state === 'healthy'
|
|
38
|
-
* @property {number} failures -
|
|
39
|
-
* @property {() =>
|
|
40
|
-
* @property {() =>
|
|
41
|
-
* @property {(
|
|
42
|
-
* @property {() => void}
|
|
42
|
+
* @property {'healthy' | 'broken' | 'probing'} state - State of the default (`''`) key
|
|
43
|
+
* @property {boolean} isHealthy - True only when the default key's state === 'healthy'
|
|
44
|
+
* @property {number} failures - Default key's consecutive failure count
|
|
45
|
+
* @property {(key?: string) => ('healthy' | 'broken' | 'probing')} stateOf - State for a given key
|
|
46
|
+
* @property {(key?: string) => number} failuresOf - Failure count for a given key
|
|
47
|
+
* @property {(key?: string) => void} guard - Throws CircuitBrokenError if that key is broken
|
|
48
|
+
* @property {(key?: string) => void} success - Record a successful operation for a key
|
|
49
|
+
* @property {(err?: any, key?: string) => void} failure - Record a failed operation for a key
|
|
50
|
+
* @property {(key?: string) => void} reset - Force a key back to healthy
|
|
43
51
|
* @property {(handler: (from: string, to: string) => void) => () => void} subscribe - Register a state-transition listener; returns an unsubscribe function
|
|
44
|
-
* @property {() => void} destroy - Clear internal timers
|
|
52
|
+
* @property {() => void} destroy - Clear internal timers (all keys)
|
|
45
53
|
*/
|
|
46
54
|
|
|
47
55
|
/**
|
|
@@ -63,17 +71,20 @@ export class CircuitBrokenError extends Error {
|
|
|
63
71
|
*/
|
|
64
72
|
/**
|
|
65
73
|
* Run an async operation through a breaker. Guards before, records
|
|
66
|
-
* success/failure after. Pass null/undefined breaker to skip.
|
|
74
|
+
* success/failure after. Pass null/undefined breaker to skip. The optional `key`
|
|
75
|
+
* partitions the breaker state (default `''` = the global breaker) - shared-infra
|
|
76
|
+
* call sites pass no key; a caller protecting a tenant-specific resource passes the
|
|
77
|
+
* tenant id so its failures cannot trip another tenant's breaker.
|
|
67
78
|
*/
|
|
68
|
-
export async function withBreaker(b, fn) {
|
|
79
|
+
export async function withBreaker(b, fn, key) {
|
|
69
80
|
if (!b) return fn();
|
|
70
|
-
b.guard();
|
|
81
|
+
b.guard(key);
|
|
71
82
|
try {
|
|
72
83
|
const result = await fn();
|
|
73
|
-
b.success();
|
|
84
|
+
b.success(key);
|
|
74
85
|
return result;
|
|
75
86
|
} catch (err) {
|
|
76
|
-
b.failure(err);
|
|
87
|
+
b.failure(err, key);
|
|
77
88
|
throw err;
|
|
78
89
|
}
|
|
79
90
|
}
|
|
@@ -90,75 +101,105 @@ export function createCircuitBreaker(options = {}) {
|
|
|
90
101
|
throw new Error('circuit breaker: resetTimeout must be a non-negative number');
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
// Per-key state. The default key '' is the single global breaker - every existing
|
|
105
|
+
// (no-key) caller hits exactly this one slot, so behavior is byte-identical.
|
|
106
|
+
/** @type {Map<string, { state: string, failures: number, probeAllowed: boolean, resetTimer: any }>} */
|
|
107
|
+
const states = new Map();
|
|
108
|
+
function stateFor(key) {
|
|
109
|
+
const k = key || '';
|
|
110
|
+
let s = states.get(k);
|
|
111
|
+
if (!s) {
|
|
112
|
+
// Backstop against unbounded key growth: at the cap, evict the oldest
|
|
113
|
+
// non-default keyed state (the '' global key is never evicted). A healthy
|
|
114
|
+
// evicted key simply recreates on next access; an evicted broken key is
|
|
115
|
+
// treated as healthy until it re-breaks - bounded graceful degradation,
|
|
116
|
+
// the same trade-off as the rate-limiter's maxBuckets.
|
|
117
|
+
if (states.size >= MAX_BREAKER_KEYS) {
|
|
118
|
+
for (const existing of states.keys()) {
|
|
119
|
+
if (existing !== '') {
|
|
120
|
+
const old = states.get(existing);
|
|
121
|
+
if (old && old.resetTimer) clearTimer(old.resetTimer);
|
|
122
|
+
states.delete(existing);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
s = { state: 'healthy', failures: 0, probeAllowed: false, resetTimer: null };
|
|
128
|
+
states.set(k, s);
|
|
129
|
+
}
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
97
132
|
|
|
98
133
|
const listeners = new Set();
|
|
99
134
|
if (onStateChange) listeners.add(onStateChange);
|
|
100
135
|
|
|
101
|
-
function transition(to) {
|
|
102
|
-
const from = state;
|
|
136
|
+
function transition(s, to) {
|
|
137
|
+
const from = s.state;
|
|
103
138
|
if (from === to) return;
|
|
104
|
-
state = to;
|
|
139
|
+
s.state = to;
|
|
105
140
|
for (const listener of listeners) {
|
|
106
141
|
try { listener(from, to); } catch { /* don't let one listener break the others */ }
|
|
107
142
|
}
|
|
108
143
|
}
|
|
109
144
|
|
|
110
|
-
function scheduleProbe() {
|
|
111
|
-
clearTimer(resetTimer);
|
|
112
|
-
resetTimer = setTimer(() => {
|
|
113
|
-
resetTimer = null;
|
|
114
|
-
probeAllowed = true;
|
|
115
|
-
transition('probing');
|
|
145
|
+
function scheduleProbe(s) {
|
|
146
|
+
clearTimer(s.resetTimer);
|
|
147
|
+
s.resetTimer = setTimer(() => {
|
|
148
|
+
s.resetTimer = null;
|
|
149
|
+
s.probeAllowed = true;
|
|
150
|
+
transition(s, 'probing');
|
|
116
151
|
}, resetTimeout);
|
|
117
|
-
if (resetTimer.unref) resetTimer.unref();
|
|
152
|
+
if (s.resetTimer.unref) s.resetTimer.unref();
|
|
118
153
|
}
|
|
119
154
|
|
|
120
155
|
return {
|
|
121
|
-
get state() { return state; },
|
|
122
|
-
get isHealthy() { return state === 'healthy'; },
|
|
123
|
-
get failures() { return failures; },
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
156
|
+
get state() { return stateFor('').state; },
|
|
157
|
+
get isHealthy() { return stateFor('').state === 'healthy'; },
|
|
158
|
+
get failures() { return stateFor('').failures; },
|
|
159
|
+
stateOf(key) { return stateFor(key).state; },
|
|
160
|
+
failuresOf(key) { return stateFor(key).failures; },
|
|
161
|
+
|
|
162
|
+
guard(key) {
|
|
163
|
+
const s = stateFor(key);
|
|
164
|
+
if (s.state === 'healthy') return;
|
|
165
|
+
if (s.state === 'probing' && s.probeAllowed) {
|
|
166
|
+
s.probeAllowed = false;
|
|
129
167
|
return;
|
|
130
168
|
}
|
|
131
169
|
throw new CircuitBrokenError();
|
|
132
170
|
},
|
|
133
171
|
|
|
134
|
-
success() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
resetTimer
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
success(key) {
|
|
173
|
+
const s = stateFor(key);
|
|
174
|
+
if (s.state === 'probing') {
|
|
175
|
+
clearTimer(s.resetTimer);
|
|
176
|
+
s.resetTimer = null;
|
|
177
|
+
s.failures = 0;
|
|
178
|
+
transition(s, 'healthy');
|
|
179
|
+
} else if (s.state === 'healthy') {
|
|
180
|
+
s.failures = 0;
|
|
142
181
|
}
|
|
143
182
|
},
|
|
144
183
|
|
|
145
|
-
failure() {
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
failure(err, key) {
|
|
185
|
+
const s = stateFor(key);
|
|
186
|
+
if (s.failures < failureThreshold) s.failures++;
|
|
187
|
+
if (s.state === 'probing') {
|
|
188
|
+
transition(s, 'broken');
|
|
189
|
+
scheduleProbe(s);
|
|
190
|
+
} else if (s.state === 'healthy' && s.failures >= failureThreshold) {
|
|
191
|
+
transition(s, 'broken');
|
|
192
|
+
scheduleProbe(s);
|
|
153
193
|
}
|
|
154
194
|
},
|
|
155
195
|
|
|
156
|
-
reset() {
|
|
157
|
-
|
|
158
|
-
resetTimer
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
reset(key) {
|
|
197
|
+
const s = stateFor(key);
|
|
198
|
+
clearTimer(s.resetTimer);
|
|
199
|
+
s.resetTimer = null;
|
|
200
|
+
s.failures = 0;
|
|
201
|
+
s.probeAllowed = false;
|
|
202
|
+
transition(s, 'healthy');
|
|
162
203
|
},
|
|
163
204
|
|
|
164
205
|
subscribe(handler) {
|
|
@@ -176,8 +217,10 @@ export function createCircuitBreaker(options = {}) {
|
|
|
176
217
|
},
|
|
177
218
|
|
|
178
219
|
destroy() {
|
|
179
|
-
|
|
180
|
-
|
|
220
|
+
for (const s of states.values()) {
|
|
221
|
+
clearTimer(s.resetTimer);
|
|
222
|
+
s.resetTimer = null;
|
|
223
|
+
}
|
|
181
224
|
}
|
|
182
225
|
};
|
|
183
226
|
}
|
package/src/shared/caps.js
CHANGED
|
@@ -100,6 +100,14 @@ export const MAX_TASK_HANDLERS = 10_000;
|
|
|
100
100
|
/** `breaker.subscribe(handler)` listeners on a single breaker. */
|
|
101
101
|
export const MAX_BREAKER_LISTENERS = 10_000;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Distinct per-key state slots on a single breaker (the default `''` global key
|
|
105
|
+
* plus one per caller-supplied key, e.g. a tenant). A backstop against unbounded
|
|
106
|
+
* growth if a caller ever keys the breaker by an untrusted/unbounded value; the
|
|
107
|
+
* intended key sources (tenant ids) are bounded well below this.
|
|
108
|
+
*/
|
|
109
|
+
export const MAX_BREAKER_KEYS = 10_000;
|
|
110
|
+
|
|
103
111
|
/**
|
|
104
112
|
* Defense-in-depth cap on the idempotency-store key after framework-level
|
|
105
113
|
* namespacing. Matches the worst-case `rpc:<path>:<256-char-user-key>` /
|