@goodandready/dsh-key-rotation 0.8.18 → 0.8.20

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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.20 - 2026-09-22
7
+
8
+ ### Fixed
9
+ - **Settings card on current DSH** (#345): the client reads its settings namespace through `configForms` instead of the removed `settingsScope` service. The package manifest declares the settings and slots providers the card injects, so the fiber activates and the card mounts again.
10
+
11
+ ## 0.8.19 - 2026-09-22
12
+
13
+ ### Fixed
14
+ - **Restored pool state hydration & telemetry preservation (GitHub PR #14)**: extracted `initializePoolState()` and `hasPoolActivity()` into `lib/pool-state.js`. `StatePersistence.restorePools()` now hydrates existing runtime pools in-place, ensuring transient fields like `events` array are never undefined. `pushEvent()` defensively guards `events` array so telemetry errors cannot throw `TypeError` and mask underlying provider errors as `TRANSPORT`.
15
+ - **Circuit breaker probe lifecycle & stream accounting (GitHub PR #15)**: added once-settled request permits with generation tokens in `CircuitBreaker`. Correctly counts the first half-open probe, checks admission before each key attempt, prevents late failures while open from extending cooldowns, and distinguishes error/aborted terminals from successes in `rotate()`.
16
+ - **Accurate quota failure classification & backoff overflow saturation (GitHub PR #16)**: narrowed `penalizeRef()` quota detection regex in `lib/rotate.js` to explicit quota-exhaustion phrases (`insufficient_quota`, `quota exhausted`, `You exceeded your current quota`), preventing ordinary rate limits (e.g. `rate limit exceeded`, `requests per minute exceeded`, `deadline exceeded`) from being held until midnight UTC. Replaced 32-bit signed integer shifts in `computeBackoff()` with `Math.pow(2, failCount - 1)`, preventing negative delay wrap-around on 32+ failures.
17
+ - **Quarantined key auto-unbreak isolation (GitHub PR #17)**: in `setupAutoUnbreakEffect()`, quarantined keys are now probed using the un-wrapped original credentials resolver handle, ensuring the exact broken key is tested rather than a healthy neighbor chosen by the rotation wrapper. Added concurrency lock and state-observation checks to prevent stale probes from clearing newer failures.
18
+ - **Client error message visibility & empty ref validation (GitHub Issue #13)**: `validateBeforeSave()` in `lib/client.js` now normalizes and extracts `vres.message ?? vres.error?.message` from pre-save validation failures, displaying the server-provided reason instead of the generic "validation failed" toast. `buildPoolItem()` in `lib/pool-builder.js` filters out empty, whitespace-only, and invalid ref names using `isValidRef()`.
19
+
6
20
  ## 0.8.18 - 2026-09-21
7
21
 
8
22
  ### Fixed
@@ -11,18 +11,24 @@ export class CircuitBreaker {
11
11
  * @param {{ threshold?: number, openMs?: number, halfOpenProbes?: number, now?: () => number }} opts
12
12
  */
13
13
  constructor({ threshold = 5, openMs = 30_000, halfOpenProbes = 1, now = Date.now } = {}) {
14
- this.threshold = Math.max(1, threshold | 0);
15
- this.openMs = Math.max(100, openMs | 0);
16
- this.halfOpenProbes = Math.max(1, halfOpenProbes | 0);
14
+ this.configure({ threshold, openMs, halfOpenProbes });
17
15
  this._now = now;
18
- /** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number}>} */
16
+ /** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number, generation:number}>} */
19
17
  this._st = new Map();
20
18
  }
21
19
 
20
+ configure({ threshold = 5, openMs = 30_000, halfOpenProbes = 1 } = {}) {
21
+ const integer = (value, fallback, min) => Number.isFinite(value)
22
+ ? Math.max(min, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(value))) : fallback;
23
+ this.threshold = integer(threshold, 5, 1);
24
+ this.openMs = integer(openMs, 30_000, 100);
25
+ this.halfOpenProbes = integer(halfOpenProbes, 1, 1);
26
+ }
27
+
22
28
  _entry(provider) {
23
29
  let e = this._st.get(provider);
24
30
  if (!e) {
25
- e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0 };
31
+ e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0, generation: 0 };
26
32
  this._st.set(provider, e);
27
33
  }
28
34
  return e;
@@ -36,9 +42,10 @@ export class CircuitBreaker {
36
42
  if (now - e.openedAt >= this.openMs) {
37
43
  e.state = BREAKER_HALF_OPEN;
38
44
  e.probes = 0;
39
- return true;
45
+ e.generation += 1;
46
+ } else {
47
+ return false;
40
48
  }
41
- return false;
42
49
  }
43
50
  if (e.state === BREAKER_HALF_OPEN) {
44
51
  if (e.probes < this.halfOpenProbes) {
@@ -50,26 +57,58 @@ export class CircuitBreaker {
50
57
  return true;
51
58
  }
52
59
 
60
+ // A permit is settled exactly once. Late outcomes from an older circuit
61
+ // generation (or a removed provider) cannot close/reopen a newer circuit.
62
+ acquire(provider) {
63
+ if (!this.canRequest(provider)) return null;
64
+ const entry = this._entry(provider);
65
+ const generation = entry.generation;
66
+ const probing = entry.state === BREAKER_HALF_OPEN;
67
+ let settled = false;
68
+ const settle = (fn) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ if (this._st.get(provider) === entry && entry.generation === generation) fn();
72
+ };
73
+ return {
74
+ success: () => settle(() => this.onSuccess(provider)),
75
+ failure: () => settle(() => this.onFailure(provider)),
76
+ release: () => settle(() => {
77
+ if (probing && entry.state === BREAKER_HALF_OPEN) {
78
+ entry.probes = Math.max(0, entry.probes - 1);
79
+ }
80
+ }),
81
+ };
82
+ }
83
+
53
84
  onSuccess(provider) {
54
85
  const e = this._entry(provider);
86
+ if (e.state !== BREAKER_CLOSED) e.generation += 1;
55
87
  e.fails = 0;
56
88
  e.probes = 0;
89
+ e.openedAt = 0;
57
90
  e.state = BREAKER_CLOSED;
58
91
  }
59
92
 
60
93
  onFailure(provider) {
61
94
  const e = this._entry(provider);
62
95
  const now = this._now();
96
+ // In-flight failures arriving after opening must not extend the cooldown.
97
+ if (e.state === BREAKER_OPEN) return e.state;
63
98
  if (e.state === BREAKER_HALF_OPEN) {
64
99
  e.state = BREAKER_OPEN;
65
100
  e.openedAt = now;
66
101
  e.fails = this.threshold;
102
+ e.probes = 0;
103
+ e.generation += 1;
67
104
  return e.state;
68
105
  }
69
106
  e.fails += 1;
70
107
  if (e.fails >= this.threshold) {
71
108
  e.state = BREAKER_OPEN;
72
109
  e.openedAt = now;
110
+ e.probes = 0;
111
+ e.generation += 1;
73
112
  }
74
113
  return e.state;
75
114
  }
@@ -81,7 +120,7 @@ export class CircuitBreaker {
81
120
  snapshot() {
82
121
  const out = {};
83
122
  for (const [k, e] of this._st) {
84
- out[k] = { state: e.state, fails: e.fails, openedAt: e.openedAt || null };
123
+ out[k] = { state: e.state, fails: e.fails, openedAt: e.state === BREAKER_CLOSED ? null : e.openedAt };
85
124
  }
86
125
  return out;
87
126
  }
@@ -92,20 +131,22 @@ export class CircuitBreaker {
92
131
  }
93
132
 
94
133
  /** Restore entries from a persistence snapshot (#287). */
95
- restore(snapshot) {
96
- if (!snapshot || typeof snapshot !== 'object') return 0;
134
+ restore(snapshot, { preserveExisting = false } = {}) {
135
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return 0;
97
136
  let n = 0;
98
137
  for (const [provider, e] of Object.entries(snapshot)) {
99
- if (!e || typeof e !== 'object') continue;
138
+ if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
139
+ if (preserveExisting && this._st.has(provider)) continue;
100
140
  const state = e.state === BREAKER_OPEN || e.state === BREAKER_HALF_OPEN ? e.state : BREAKER_CLOSED;
101
141
  this._st.set(provider, {
102
142
  state,
103
143
  fails: Number.isFinite(e.fails) ? e.fails : 0,
104
144
  openedAt: Number.isFinite(e.openedAt) ? e.openedAt : 0,
105
145
  probes: 0,
146
+ generation: 0,
106
147
  });
107
148
  n += 1;
108
149
  }
109
150
  return n;
110
151
  }
111
- }
152
+ }
package/lib/client.js CHANGED
@@ -644,7 +644,7 @@ window.__ModuleLoader__.load({
644
644
  );
645
645
  }
646
646
 
647
- // Provider registration is not part of settingsScope's value. Fetch it
647
+ // Provider registration is not part of configForms's value. Fetch it
648
648
  // independently, and never turn an unavailable catalog into proof of absence.
649
649
  function useProviderCatalog() {
650
650
  const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
@@ -687,10 +687,10 @@ window.__ModuleLoader__.load({
687
687
 
688
688
  function KeyRotationSection(props) {
689
689
  const t = resolveT(props);
690
- const settingsScope = React.useMemo(() => {
690
+ const configForms = React.useMemo(() => {
691
691
  try {
692
- if (props.ctx && props.ctx.settingsScope) {
693
- return props.ctx.settingsScope.bind({ namespace: NS });
692
+ if (props.ctx && props.ctx.configForms) {
693
+ return props.ctx.configForms.get(NS);
694
694
  }
695
695
  } catch (_) {
696
696
  return null;
@@ -700,10 +700,10 @@ window.__ModuleLoader__.load({
700
700
  // #273: getSnapshot must be referentially stable or React 18 unmounts the tree
701
701
  const scopeCacheRef = React.useRef({ has: false, value: null });
702
702
  const getScopeSnapshot = React.useCallback(() => {
703
- if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
703
+ if (!configForms || typeof configForms.getSnapshot !== 'function') return null;
704
704
  let next = null;
705
705
  try {
706
- next = settingsScope.getSnapshot();
706
+ next = configForms.getSnapshot();
707
707
  } catch (_) {
708
708
  return null;
709
709
  }
@@ -718,21 +718,21 @@ window.__ModuleLoader__.load({
718
718
  }
719
719
  scopeCacheRef.current = { has: true, value: next };
720
720
  return next;
721
- }, [settingsScope]);
721
+ }, [configForms]);
722
722
  const scopeSnapshot = React.useSyncExternalStore(
723
- React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
723
+ React.useMemo(() => (cb) => (configForms && typeof configForms.subscribe === 'function' ? configForms.subscribe(cb) : () => {}), [configForms]),
724
724
  getScopeSnapshot
725
725
  );
726
726
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
727
727
  const catalog = useProviderCatalog();
728
728
  const latestScopeSnapshot = React.useRef(scopeSnapshot);
729
729
  latestScopeSnapshot.current = scopeSnapshot;
730
- // #290: surface settingsScope availability as card state
730
+ // #290: surface configForms availability as card state
731
731
  React.useEffect(() => {
732
- if (!settingsScope) {
732
+ if (!configForms) {
733
733
  setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
734
734
  }
735
- }, [settingsScope]);
735
+ }, [configForms]);
736
736
  const [draft, setDraft] = React.useState(null);
737
737
  // ── all hooks live ABOVE any early return (React error 310 otherwise) ──
738
738
  const [search, setSearch] = React.useState('');
@@ -947,9 +947,15 @@ window.__ModuleLoader__.load({
947
947
  headers: { 'content-type': 'application/json' },
948
948
  body: JSON.stringify({ ref, value }),
949
949
  })
950
- .then((r) => r.json())
951
- .then((data) => { setValidationResult((m) => ({ ...m, [ref]: data })); return data; })
952
- .catch(() => null)
950
+ .then(async (r) => {
951
+ const data = (await r.json().catch(() => null)) ?? {};
952
+ const resData = !r.ok && data.error
953
+ ? { ok: false, code: data.error.code, message: data.error.message }
954
+ : { ok: r.ok, ...data };
955
+ setValidationResult((m) => ({ ...m, [ref]: resData }));
956
+ return resData;
957
+ })
958
+ .catch((e) => ({ ok: false, message: String(e?.message ?? e) }))
953
959
  .finally(() => setValidating(''));
954
960
  };
955
961
  const saveSecret = async (ref, rowKey) => {
@@ -963,7 +969,8 @@ window.__ModuleLoader__.load({
963
969
  if (vres && vres.ok === false && vres.code === 'no-credential') {
964
970
  // No credential yet is fine for a new key being saved
965
971
  } else if (vres && !vres.ok) {
966
- setSecretError(t('keyWriteFailed').replace('{msg}', vres.message || 'validation failed'));
972
+ const msg = vres.message ?? vres.error?.message ?? 'validation failed';
973
+ setSecretError(t('keyWriteFailed').replace('{msg}', msg));
967
974
  return;
968
975
  }
969
976
  fetch('/dsh-key-rotation/key', {
@@ -1007,7 +1014,7 @@ window.__ModuleLoader__.load({
1007
1014
  React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
1008
1015
  );
1009
1016
  }
1010
- if (state.status === 'unavailable' || !settingsScope) {
1017
+ if (state.status === 'unavailable' || !configForms) {
1011
1018
  return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
1012
1019
  React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
1013
1020
  React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
@@ -1120,11 +1127,11 @@ window.__ModuleLoader__.load({
1120
1127
  })
1121
1128
  .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
1122
1129
  };
1123
- if (settingsScope && typeof settingsScope.set === 'function') {
1130
+ if (configForms && typeof configForms.set === 'function') {
1124
1131
  (async () => {
1125
1132
  try {
1126
1133
  for (const [k, v] of Object.entries(draft)) {
1127
- await settingsScope.set(k, v);
1134
+ await configForms.set(k, v);
1128
1135
  }
1129
1136
  setState((s) => ({ ...s, status: 'ready', value: draft, error: '' }));
1130
1137
  setDraft(null);
@@ -1929,7 +1936,7 @@ window.__ModuleLoader__.load({
1929
1936
  }
1930
1937
  }
1931
1938
 
1932
- module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] };
1939
+ module.exports = { apply, inject: ['slots', 'locale', 'configForms'] };
1933
1940
  return module.exports;
1934
1941
  },
1935
1942
  updateCheck: 'Check for updates',
package/lib/heal.js CHANGED
@@ -42,9 +42,10 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
42
42
  * @param {Array} pools
43
43
  * @param {Function} probeFn async (ref) => { ok: boolean, code?: string, status?: number }
44
44
  * @param {number} [now=Date.now()]
45
+ * @param {{ isActive?: () => boolean }} [options] lifecycle guard for delayed results
45
46
  * @returns {Promise<Array<{ ref: string, poolBase: string, ok: boolean }>>}
46
47
  */
47
- export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
48
+ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), { isActive = () => true } = {}) {
48
49
  if (!Array.isArray(pools) || pools.length === 0 || typeof probeFn !== 'function') return [];
49
50
  const results = [];
50
51
  for (const pool of pools) {
@@ -58,8 +59,18 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
58
59
  }
59
60
  }
60
61
  for (const ref of brokenRefs) {
62
+ if (!isActive()) return results;
63
+ const state = pool.state;
64
+ const fields = ['brokenUntil', 'failedUntil', 'failCounts', 'authFailCounts'];
65
+ const observed = fields.map(field => [field, state[field], state[field]?.get(ref)]);
66
+ // An earlier await may have removed this key from the captured worklist.
67
+ if (state.brokenUntil !== bu || !bu.has(ref)) continue;
61
68
  try {
62
69
  const res = await probeFn(ref);
70
+ if (!isActive()) return results;
71
+ // Do not let a late success erase a newer failure or replaced state.
72
+ if (pool.state !== state || observed.some(([field, map, value]) =>
73
+ state[field] !== map || map?.get(ref) !== value)) continue;
63
74
  if (res && res.ok) {
64
75
  bu.delete(ref);
65
76
  pool.state.failedUntil?.delete(ref);
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { bucketAllow, bucketRetryMs } from './bucket.js';
22
22
  import { compactUsage } from './usage-report.js';
23
23
  import { json, handleConfigBridge } from './http-bridge.js';
24
24
  import { createRotate } from './rotate.js';
25
+ import { initializePoolState } from './pool-state.js';
25
26
  import { nowMono } from './clock.js';
26
27
  import { CircuitBreaker } from './circuit-breaker.js';
27
28
  import { NotifyQueue } from './notify-queue.js';
@@ -219,6 +220,11 @@ export function apply(ctx, config = {}) {
219
220
  lastConfigRef = cfg;
220
221
  lastProfilesRef = profilesSnapshot;
221
222
 
223
+ moduleBreaker.configure({
224
+ threshold: cfg.circuitBreakerThreshold ?? 5,
225
+ openMs: cfg.circuitBreakerOpenMs ?? 30000,
226
+ halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
227
+ });
222
228
  const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
223
229
  const cooldownMs = cfg.cooldownMs ?? 60000;
224
230
  const maxCooldownMs = cfg.maxCooldownMs;
@@ -239,25 +245,10 @@ export function apply(ctx, config = {}) {
239
245
  const makeState = (base) => {
240
246
  let st = poolState.get(base);
241
247
  if (!st) {
242
- st = {
243
- failedUntil: new Map(),
244
- failCounts: new Map(),
245
- authFailCounts: new Map(),
246
- brokenUntil: new Map(),
247
- costPerKey: new Map(),
248
- lastUsedAt: new Map(),
249
- usageCounts: new Map(),
250
- byModel: new Map(),
251
- usageDays: new Map(),
252
- quotaWindows: new Map(),
253
- costDays: new Map(),
254
- events: [],
255
- pointer: 0,
256
- lastUsed: null,
257
- };
248
+ st = initializePoolState();
258
249
  poolState.set(base, st);
259
250
  }
260
- return st;
251
+ return initializePoolState(st);
261
252
  };
262
253
 
263
254
  const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
@@ -529,7 +520,7 @@ export function apply(ctx, config = {}) {
529
520
  const cls = classifyFailure(payload);
530
521
  const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
531
522
  if (!switchable) return next();
532
- if (moduleBreaker) moduleBreaker.onFailure(provider);
523
+ if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
533
524
  const ref = pool.state.lastUsed;
534
525
  if (ref) {
535
526
  const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft, true);
package/lib/lifecycle.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
3
3
  import { StatePersistence, resolveStatePath } from './persistence.js';
4
4
  import path from 'node:path';
5
+ import { envValue } from './pool.js';
5
6
 
6
7
  export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
7
8
  return ctx.effect(() => {
@@ -30,21 +31,33 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
30
31
  const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
31
32
  if (!intervalMin || intervalMin <= 0) return () => {};
32
33
  const intervalMs = intervalMin * 60 * 1000;
34
+ let running = false;
35
+ let disposed = false;
36
+ const isActive = () => !disposed && (getConfig()?.selfHealingIntervalMinutes ?? 0) > 0;
33
37
  const timer = setInterval(async () => {
38
+ if (running || disposed) return;
39
+ running = true;
34
40
  try {
35
41
  const c = getConfig();
36
42
  if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
37
43
  const { pools } = buildRuntime();
38
44
  const runner = ensureSandboxRunner(ctx);
39
45
  await autoUnbreakBrokenKeys(pools, async (ref) => {
40
- let val = (await ctx.credentials?.resolve?.(ref))?.value;
41
- if (!val) return { ok: false };
42
- return runner.probeModels(ref, val);
43
- });
46
+ // A rotating resolver can substitute a healthy neighbor for this
47
+ // quarantined ref, falsely proving that the broken key recovered.
48
+ const credentials = ctx.get?.('credentials') ?? ctx.credentials;
49
+ const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
50
+ const hit = await resolve?.call(credentials, ref);
51
+ const value = typeof hit?.value === 'string' && hit.value.length > 0
52
+ ? hit.value : envValue(ref);
53
+ if (!value || !isActive()) return { ok: false };
54
+ return runner.probeModels(ref, value);
55
+ }, Date.now(), { isActive });
44
56
  } catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
57
+ finally { running = false; }
45
58
  }, intervalMs);
46
59
  if (typeof timer.unref === 'function') timer.unref();
47
- return () => clearInterval(timer);
60
+ return () => { disposed = true; clearInterval(timer); };
48
61
  }, 'dsh-key-rotation: auto-unbreak');
49
62
  }
50
63
 
@@ -65,7 +78,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
65
78
  if (!snap) return;
66
79
  try {
67
80
  StatePersistence.restorePools(poolState, snap);
68
- if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
81
+ if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit, { preserveExisting: true });
69
82
  if (verboseLoggingOn?.()) {
70
83
  logger?.warn?.(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
71
84
  }
@@ -32,6 +32,8 @@ export function notifySwitch(runtime, pool, info, hooks = { webhookSender: defau
32
32
 
33
33
  export function pushEvent(pool, ref, reason, cooldownMs, type) {
34
34
  const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
35
+ // Legacy/partially restored state must not replace the provider's error.
36
+ if (!Array.isArray(pool.state.events)) pool.state.events = [];
35
37
  pool.state.events.push(ev);
36
38
  if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
37
39
  }
@@ -3,6 +3,7 @@
3
3
  // reload would otherwise forget cooldowns and stampede recovered keys.
4
4
 
5
5
  import path from 'node:path';
6
+ import { initializePoolState, hasPoolActivity } from './pool-state.js';
6
7
  import { atomicWriteJson, safeReadJson } from './atomic-io.js';
7
8
 
8
9
  const DEFAULT_FILE = 'dsh-key-rotation-state.json';
@@ -100,21 +101,24 @@ export class StatePersistence {
100
101
 
101
102
  /** Restore poolState Map from a loaded snapshot. */
102
103
  static restorePools(poolState, snapshot) {
103
- if (!poolState || !snapshot || !snapshot.pools || typeof snapshot.pools !== 'object') return 0;
104
+ if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
104
105
  let n = 0;
105
- for (const [base, st] of Object.entries(snapshot.pools)) {
106
- if (!st || typeof st !== 'object') continue;
107
- const failedUntil = new Map();
108
- if (st.failedUntil && typeof st.failedUntil === 'object') {
109
- for (const [ref, until] of Object.entries(st.failedUntil)) {
110
- if (Number.isFinite(until)) failedUntil.set(ref, until);
106
+ for (const [base, saved] of Object.entries(snapshot.pools)) {
107
+ if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
108
+ const existing = poolState.get(base);
109
+ const state = initializePoolState(existing);
110
+ // Never replace objects retained by buildRuntime(), nor roll back a pool
111
+ // already used while the asynchronous load was in flight.
112
+ if (!existing || !hasPoolActivity(state)) {
113
+ if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
114
+ for (const [ref, until] of Object.entries(saved.failedUntil)) {
115
+ if (Number.isFinite(until)) state.failedUntil.set(ref, until);
116
+ }
111
117
  }
118
+ state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
119
+ state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
112
120
  }
113
- poolState.set(base, {
114
- failedUntil,
115
- pointer: Number.isFinite(st.pointer) ? st.pointer : 0,
116
- lastUsed: typeof st.lastUsed === 'string' ? st.lastUsed : undefined,
117
- });
121
+ poolState.set(base, state);
118
122
  n += 1;
119
123
  }
120
124
  return n;
@@ -1,5 +1,6 @@
1
1
  // lib/pool-builder.js — provider & per-model pool assembly and cleanup
2
2
  import { bucketSweep } from './bucket.js';
3
+ import { isValidRef } from './pool.js';
3
4
 
4
5
  export function parseExpiry(v) {
5
6
  if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
@@ -22,7 +23,7 @@ export function buildPoolItem({
22
23
  rpmLimit,
23
24
  makeState,
24
25
  }) {
25
- const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
26
+ const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && isValidRef(ref.trim()));
26
27
  if (refs.length === 0) return null;
27
28
  const w = Array.isArray(weights) ? weights : [];
28
29
  const weightedRefs = [];
@@ -0,0 +1,24 @@
1
+ // Shared runtime shape for fresh and persisted pools. Hydrate in place so cached
2
+ // runtime pools, persistence and timers continue to observe the same object.
3
+ const MAP_FIELDS = [
4
+ 'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
5
+ 'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
6
+ ];
7
+
8
+ export function initializePoolState(state = {}) {
9
+ for (const key of MAP_FIELDS) {
10
+ if (!(state[key] instanceof Map)) state[key] = new Map();
11
+ }
12
+ if (!Array.isArray(state.events)) state.events = [];
13
+ if (!Number.isSafeInteger(state.pointer) || state.pointer < 0) state.pointer = 0;
14
+ if (typeof state.lastUsed !== 'string') state.lastUsed = null;
15
+ return state;
16
+ }
17
+
18
+ // Startup disk I/O can finish after requests have already used a pool. In that
19
+ // case the live state wins: replaying an older cooldown/cursor can undo success.
20
+ export function hasPoolActivity(state) {
21
+ return state.pointer !== 0 || state.lastUsed !== null || state.events.length > 0 ||
22
+ MAP_FIELDS.some((key) => state[key].size > 0) ||
23
+ (state.lastSuccessAt?.size ?? 0) > 0;
24
+ }
package/lib/pool.js CHANGED
@@ -122,7 +122,9 @@ export function computeBackoff(baseMs, failCount, maxMs, isSoft = false) {
122
122
  }
123
123
  const cap = maxMs ?? baseMs * 8;
124
124
  if (failCount <= 1) return Math.min(baseMs, cap);
125
- const backoff = baseMs * (1 << (failCount - 1)); // 2^(n-1)
125
+ // Bit shifts wrap/sign-flip at 32 failures. Floating-point exponentiation
126
+ // saturates at Infinity, which the configured cap safely bounds.
127
+ const backoff = baseMs === 0 ? 0 : baseMs * (2 ** (failCount - 1));
126
128
  return Math.min(backoff, cap);
127
129
  }
128
130
 
package/lib/rotate.js CHANGED
@@ -47,6 +47,8 @@ export function createRotate(deps) {
47
47
  const runtime0 = buildRuntime();
48
48
  const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
49
49
  let lastFailure = null;
50
+ const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
51
+ let activePermit = null;
50
52
  const reqStore = { pool, pickedRef: undefined, startMs: now() };
51
53
  setRotateStartMs(reqStore.startMs);
52
54
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
@@ -82,7 +84,10 @@ export function createRotate(deps) {
82
84
  const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
83
85
 
84
86
  // #337: connect quota-window calendar reset for quota exhaustion
85
- const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' || /quota|exceeded/i.test(errMsg);
87
+ // "rate limit exceeded" and "deadline exceeded" are not evidence
88
+ // of an exhausted billing quota. Keep those on ordinary cooldowns.
89
+ const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' ||
90
+ /\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
86
91
  const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
87
92
  if (isQuota && qWindow) {
88
93
  const quotaResetAt = nextQuotaReset(qWindow, now());
@@ -93,7 +98,7 @@ export function createRotate(deps) {
93
98
  }
94
99
  }
95
100
 
96
- if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
101
+ activePermit?.failure();
97
102
  pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
98
103
  if (typeof schedulePersist === 'function') {
99
104
  try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
@@ -113,173 +118,192 @@ export function createRotate(deps) {
113
118
  }
114
119
  };
115
120
 
116
- // #260: fail fast when provider circuit is open
117
- if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
118
- logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
119
- yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
120
- return;
121
- }
122
-
123
121
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
124
- let yielded = false;
125
- let switching = false;
126
- let inner;
127
- try {
128
- // mark the internal dispatch so the interceptor does not re-rotate
129
- inner = dispatchStorage.run(reqStore, () => {
130
- const llm = ctx.get('llm');
131
- if (!llm || typeof llm.stream !== 'function') {
132
- throw new Error('dsh-key-rotation: llm service unavailable');
133
- }
134
- return llm.stream({ ...options, [MARKER]: true });
135
- });
136
- } catch (e) {
137
- const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
138
- penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
139
- lastFailure = finishError(e?.code ?? 'TRANSPORT',
140
- `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
141
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
142
- continue;
122
+ activePermit = breaker ? breaker.acquire(options.provider) : null;
123
+ if (breaker && !activePermit) {
124
+ logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
125
+ yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
126
+ return;
143
127
  }
144
-
145
- const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
146
- const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
147
128
  try {
129
+ let yielded = false;
130
+ let switching = false;
131
+ let inner;
148
132
  try {
149
- for await (const chunk of inner) {
150
- // Only actual content deltas lock the stream (no more rotation).
151
- // Structural/metadata chunks (block-start/end, usage) do not.
152
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
153
- yielded = true;
154
- yield chunk;
155
- continue;
133
+ // mark the internal dispatch so the interceptor does not re-rotate
134
+ inner = dispatchStorage.run(reqStore, () => {
135
+ const llm = ctx.get('llm');
136
+ if (!llm || typeof llm.stream !== 'function') {
137
+ throw new Error('dsh-key-rotation: llm service unavailable');
156
138
  }
157
- if (chunk && chunk.type === 'finish') {
158
- const kind = chunk.reason?.kind;
159
- const failure = chunk.reason?.failure;
160
- const code = failure?.code;
161
- const message = failure?.message ?? '';
162
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
163
- const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
164
- const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
165
- if (switchable) {
166
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
167
- pool.state.switches = (pool.state.switches ?? 0) + 1;
168
- pool.state.lastReason = String(code ?? 'UNKNOWN');
169
- pool.state.lastSwitchAt = now();
170
- lastFailure = chunk;
171
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
172
- // #216: per-switch webhook (opt-in switchNotify), deduped per provider
173
- if (switchNotify && activeRef) {
174
- notifySwitch(runtime0, pool, {
175
- provider: options.provider,
176
- from: activeRef,
177
- code: String(code ?? 'UNKNOWN'),
178
- at: pool.state.lastSwitchAt,
179
- });
180
- }
181
- switching = true;
182
- break;
139
+ return llm.stream({ ...options, [MARKER]: true });
140
+ });
141
+ } catch (e) {
142
+ const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
143
+ penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
144
+ lastFailure = finishError(e?.code ?? 'TRANSPORT',
145
+ `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
146
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
147
+ continue;
148
+ }
149
+
150
+ const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
151
+ const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
152
+ try {
153
+ try {
154
+ for await (const chunk of inner) {
155
+ // Only actual content deltas lock the stream (no more rotation).
156
+ // Structural/metadata chunks (block-start/end, usage) do not.
157
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
158
+ yielded = true;
159
+ yield chunk;
160
+ continue;
183
161
  }
184
- // cost tracking if provider returns usage.cost
185
- const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
186
- if (chunk.usage?.cost != null && activeRef) {
187
- const c = Number(chunk.usage.cost);
188
- if (!isNaN(c)) {
189
- if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
190
- pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
191
- // #208: cost per day per key (mirrors usageDays) for budget checks
192
- if (!pool.state.costDays) pool.state.costDays = new Map();
193
- const cMap = pool.state.costDays.get(activeRef) || new Map();
194
- cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
195
- pool.state.costDays.set(activeRef, cMap);
162
+ if (chunk && chunk.type === 'finish') {
163
+ const kind = chunk.reason?.kind;
164
+ const failure = chunk.reason?.failure;
165
+ const code = failure?.code;
166
+ const message = failure?.message ?? '';
167
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
168
+ const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
169
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
170
+ if (switchable) {
171
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message);
172
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
173
+ pool.state.lastReason = String(code ?? 'UNKNOWN');
174
+ pool.state.lastSwitchAt = now();
175
+ lastFailure = chunk;
176
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
177
+ // #216: per-switch webhook (opt-in switchNotify), deduped per provider
178
+ if (switchNotify && activeRef) {
179
+ notifySwitch(runtime0, pool, {
180
+ provider: options.provider,
181
+ from: activeRef,
182
+ code: String(code ?? 'UNKNOWN'),
183
+ at: pool.state.lastSwitchAt,
184
+ });
185
+ }
186
+ switching = true;
187
+ break;
188
+ }
189
+ // Error/abort terminals are not successful responses. After a
190
+ // content delta we may penalize, but must never replay the request.
191
+ if (kind === 'error' || kind === 'aborted') {
192
+ if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
193
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message);
194
+ }
195
+ yield chunk;
196
+ return;
197
+ }
198
+ // cost tracking if provider returns usage.cost
199
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
200
+ if (chunk.usage?.cost != null && activeRef) {
201
+ const c = Number(chunk.usage.cost);
202
+ if (!isNaN(c)) {
203
+ if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
204
+ pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
205
+ // #208: cost per day per key (mirrors usageDays) for budget checks
206
+ if (!pool.state.costDays) pool.state.costDays = new Map();
207
+ const cMap = pool.state.costDays.get(activeRef) || new Map();
208
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
209
+ pool.state.costDays.set(activeRef, cMap);
210
+ }
211
+ }
212
+ // Usage by day (#119)
213
+ if (activeRef) {
214
+ if (!pool.state.usageDays) pool.state.usageDays = new Map();
215
+ const dayMap = pool.state.usageDays.get(activeRef) || new Map();
216
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
217
+ pool.state.usageDays.set(activeRef, dayMap);
218
+ }
219
+ // Per-model request detail (#121)
220
+ if (activeRef && options.model) {
221
+ if (!pool.state.byModel) pool.state.byModel = new Map();
222
+ let byRef = pool.state.byModel.get(activeRef);
223
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
224
+ byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
225
+ }
226
+ // #336: record success for the active key upon successful stream finish
227
+ if (activeRef) {
228
+ recordSuccess(pool, activeRef, now());
196
229
  }
197
- }
198
- // Usage by day (#119)
199
- if (activeRef) {
200
- if (!pool.state.usageDays) pool.state.usageDays = new Map();
201
- const dayMap = pool.state.usageDays.get(activeRef) || new Map();
202
- dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
203
- pool.state.usageDays.set(activeRef, dayMap);
204
- }
205
- // Per-model request detail (#121)
206
- if (activeRef && options.model) {
207
- if (!pool.state.byModel) pool.state.byModel = new Map();
208
- let byRef = pool.state.byModel.get(activeRef);
209
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
210
- byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
211
- }
212
- // #336: record success for the active key upon successful stream finish
213
- if (activeRef) {
214
- recordSuccess(pool, activeRef, now());
215
- }
216
230
 
217
- // Proactive rate-limit (#115, #303): if response headers say this key is near
218
- // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
219
- // We do NOT re-run this (already successful) request — that would double-send.
220
- const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
221
- const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
222
- if (guardEnabled && rate && activeRef) {
223
- if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
224
- let cool;
225
- if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
226
- cool = rate.retryAfter * 1000;
227
- } else if (rate.reset && rate.reset > now()) {
228
- cool = rate.reset - now();
229
- } else {
230
- cool = pool.cooldownMs ?? cooldownMs;
231
+ // Proactive rate-limit (#115, #303): if response headers say this key is near
232
+ // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
233
+ // We do NOT re-run this (already successful) request — that would double-send.
234
+ const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
235
+ const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
236
+ if (guardEnabled && rate && activeRef) {
237
+ if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
238
+ let cool;
239
+ if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
240
+ cool = rate.retryAfter * 1000;
241
+ } else if (rate.reset && rate.reset > now()) {
242
+ cool = rate.reset - now();
243
+ } else {
244
+ cool = pool.cooldownMs ?? cooldownMs;
245
+ }
246
+ const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
247
+ const effCool = Math.min(cool, maxCool ?? cool);
248
+ recordFailure(pool, activeRef, now(), effCool, maxCool);
249
+ pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
250
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
231
251
  }
232
- const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
233
- const effCool = Math.min(cool, maxCool ?? cool);
234
- recordFailure(pool, activeRef, now(), effCool, maxCool);
235
- pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
236
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
237
252
  }
238
- }
239
- // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
240
- if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
241
- quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
253
+ // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
254
+ if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
255
+ quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
256
+ }
257
+ recordLatency(pool, reqStore);
258
+ activePermit?.success();
259
+ yield chunk;
260
+ return;
242
261
  }
243
262
  yield chunk;
244
- recordLatency(pool, reqStore);
245
- if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
246
- return;
247
263
  }
248
- yield chunk;
249
- }
250
- } catch (e) {
251
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
252
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
253
- if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
254
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
255
- pool.state.switches = (pool.state.switches ?? 0) + 1;
256
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
257
- pool.state.lastSwitchAt = now();
258
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
259
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
260
- if (switchNotify && activeRef) {
261
- notifySwitch(runtime0, pool, {
262
- provider: options.provider,
263
- from: activeRef,
264
- code: String(e?.code ?? 'TRANSPORT'),
265
- at: pool.state.lastSwitchAt,
266
- });
264
+ } catch (e) {
265
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
266
+ const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
267
+ if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
268
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
269
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
270
+ pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
271
+ pool.state.lastSwitchAt = now();
272
+ lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
273
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
274
+ if (switchNotify && activeRef) {
275
+ notifySwitch(runtime0, pool, {
276
+ provider: options.provider,
277
+ from: activeRef,
278
+ code: String(e?.code ?? 'TRANSPORT'),
279
+ at: pool.state.lastSwitchAt,
280
+ });
281
+ }
282
+ continue; // Failover to next key!
283
+ }
284
+ if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
285
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
267
286
  }
268
- continue; // Failover to next key!
287
+ yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
288
+ return;
269
289
  }
270
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
271
- return;
272
- }
273
290
 
274
- if (switching) continue; // try the next key
275
- if (_pickedRef && yielded) {
276
- recordSuccess(pool, _pickedRef, now());
291
+ if (switching) continue; // try the next key
292
+ if (yielded) {
293
+ const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
294
+ if (activeRef) recordSuccess(pool, activeRef, now());
295
+ recordLatency(pool, reqStore);
296
+ activePermit?.success();
297
+ }
298
+ return; // clean end — served
299
+ } finally {
300
+ if (acquired && _pickedRef) {
301
+ concurrencyTracker.release(_pickedRef);
302
+ }
277
303
  }
278
- return; // clean end — served
279
304
  } finally {
280
- if (acquired && _pickedRef) {
281
- concurrencyTracker.release(_pickedRef);
282
- }
305
+ // Also runs on consumer return(), abort, empty EOF and local errors.
306
+ activePermit?.release();
283
307
  }
284
308
  }
285
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
6
6
  "keywords": [
@@ -46,7 +46,8 @@
46
46
  "platform": "web",
47
47
  "inject": [
48
48
  "@deepseek-ai/dsh-client-locale",
49
- "@deepseek-ai/dsh-client-ui-settings"
49
+ "@deepseek-ai/dsh-client-ui-settings",
50
+ "@deepseek-ai/dsh-client-ui-slots"
50
51
  ]
51
52
  }
52
53
  },