@goodandready/dsh-key-rotation 0.8.17 → 0.8.19

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,25 @@
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.19 - 2026-09-22
7
+
8
+ ### Fixed
9
+ - **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`.
10
+ - **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()`.
11
+ - **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.
12
+ - **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.
13
+ - **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()`.
14
+
15
+ ## 0.8.18 - 2026-09-21
16
+
17
+ ### Fixed
18
+ - **Stream success failCount reset (#336)**: `rotate()` now invokes `recordSuccess(pool, activeRef, now())` upon successful stream completion and clean EOF, clearing accumulated `failCounts`, removing stale `failedUntil`, and updating `lastSuccessAt`.
19
+ - **Calendar-based quota reset connection (#337)**: connected `quota-window.js` (`nextQuotaReset`) to `penalizeRef` in `lib/rotate.js` when `QUOTA` / `RESOURCE_EXHAUSTED` / quota errors occur and `quotaResetWindow` is configured. Ensures exhausted keys remain penalized until calendar reset window instead of rapidly failing over back into penalty. Enhanced `nextQuotaReset` to safely handle both object and string window configurations.
20
+ - **Penalty decay during maintenance sweeps (#338)**: `sweepExpired` in `lib/pool.js` now invokes `decayPenalties({ state: st }, now)`, allowing keys with historic transient failures that have run stably to gradually recover their base backoff levels. Also cleans up expired `quotaWindows`. Removed redundant duplicate check in `isSwitchableError`.
21
+ - **State persistence during failover (#339)**: `schedulePersist()` is now passed into `createRotate()` and invoked whenever a key failover occurs, ensuring key penalty and switch state is promptly written to disk across restarts.
22
+ - **Dead imports cleanup (#340)**: removed 16 unused named imports from `lib/index.js` (`pickCascadeFallback`, `nextQuotaReset`, `usageRows`, `usageCsv`, `findSecrets`, `readJson`, `writeSection`, etc.) ensuring lean bundle evaluation.
23
+ - **Remote configuration import timeout (#341)**: added 10-second timeout via `AbortSignal.timeout(10000)` to remote `fetch(url)` in `/dsh-key-rotation/import` route (`lib/ops-keys.js`), returning HTTP 504 Gateway Timeout with structured error code `'timeout'` on network stalls.
24
+
6
25
  ## 0.8.17 - 2026-09-21
7
26
 
8
27
  ### 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
@@ -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', {
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
@@ -15,22 +15,18 @@ import {
15
15
  budgetVerdict, sortAttemptList,
16
16
  } from './pool.js';
17
17
  import { LatencyHistogram } from './histogram.js';
18
- import { pickCascadeFallback } from './cascade.js';
19
18
  import { ConcurrencyTracker } from './concurrency.js';
20
- import { nextQuotaReset } from './quota-window.js';
21
19
  import { QuotaStore } from './quota.js';
22
20
  import { WebhookSender } from './webhook.js';
23
- import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
24
- import { usageRows, usageCsv, compactUsage } from './usage-report.js';
25
- import { findSecrets, looksLikeApiSecret } from './keycheck.js';
26
- import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
21
+ import { bucketAllow, bucketRetryMs } from './bucket.js';
22
+ import { compactUsage } from './usage-report.js';
23
+ import { json, handleConfigBridge } from './http-bridge.js';
27
24
  import { createRotate } from './rotate.js';
25
+ import { initializePoolState } from './pool-state.js';
28
26
  import { nowMono } from './clock.js';
29
27
  import { CircuitBreaker } from './circuit-breaker.js';
30
28
  import { NotifyQueue } from './notify-queue.js';
31
- import { BoundedMap } from './bounded-map.js';
32
29
  import { classifyFailure } from './error-taxonomy.js';
33
- import { safeParseJson } from './atomic-io.js';
34
30
  import { registerOpsRoutes } from './routes-ops.js';
35
31
  import { registerPluginUpdater } from './plugin-updater.js';
36
32
  import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
@@ -224,6 +220,11 @@ export function apply(ctx, config = {}) {
224
220
  lastConfigRef = cfg;
225
221
  lastProfilesRef = profilesSnapshot;
226
222
 
223
+ moduleBreaker.configure({
224
+ threshold: cfg.circuitBreakerThreshold ?? 5,
225
+ openMs: cfg.circuitBreakerOpenMs ?? 30000,
226
+ halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
227
+ });
227
228
  const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
228
229
  const cooldownMs = cfg.cooldownMs ?? 60000;
229
230
  const maxCooldownMs = cfg.maxCooldownMs;
@@ -244,25 +245,10 @@ export function apply(ctx, config = {}) {
244
245
  const makeState = (base) => {
245
246
  let st = poolState.get(base);
246
247
  if (!st) {
247
- st = {
248
- failedUntil: new Map(),
249
- failCounts: new Map(),
250
- authFailCounts: new Map(),
251
- brokenUntil: new Map(),
252
- costPerKey: new Map(),
253
- lastUsedAt: new Map(),
254
- usageCounts: new Map(),
255
- byModel: new Map(),
256
- usageDays: new Map(),
257
- quotaWindows: new Map(),
258
- costDays: new Map(),
259
- events: [],
260
- pointer: 0,
261
- lastUsed: null,
262
- };
248
+ st = initializePoolState();
263
249
  poolState.set(base, st);
264
250
  }
265
- return st;
251
+ return initializePoolState(st);
266
252
  };
267
253
 
268
254
  const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
@@ -472,6 +458,7 @@ export function apply(ctx, config = {}) {
472
458
  ctx,
473
459
  dispatchStorage,
474
460
  buildRuntime,
461
+ schedulePersist,
475
462
  pushEvent,
476
463
  notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
477
464
  notifyExhaustion,
@@ -533,7 +520,7 @@ export function apply(ctx, config = {}) {
533
520
  const cls = classifyFailure(payload);
534
521
  const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
535
522
  if (!switchable) return next();
536
- if (moduleBreaker) moduleBreaker.onFailure(provider);
523
+ if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
537
524
  const ref = pool.state.lastUsed;
538
525
  if (ref) {
539
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
  }
package/lib/ops-keys.js CHANGED
@@ -167,7 +167,7 @@ ctx.effect(() => ctx.webServer.register({
167
167
  const url = typeof body?.url === 'string' ? body.url.trim() : '';
168
168
  if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
169
169
  try {
170
- const resp = await fetch(url);
170
+ const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
171
171
  if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
172
172
  const data = await resp.json();
173
173
  if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
@@ -181,7 +181,10 @@ ctx.effect(() => ctx.webServer.register({
181
181
  const mergedArr = [...merged.values()];
182
182
  await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
183
183
  json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
184
- } catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
184
+ } catch (e) {
185
+ const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
186
+ json(res, isTimeout ? 504 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
187
+ }
185
188
  },
186
189
  }), 'dsh-key-rotation: import route');
187
190
 
@@ -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
 
@@ -185,10 +187,13 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
185
187
  let cleared = 0;
186
188
  const activeSet = activeRefs ? new Set(activeRefs) : null;
187
189
  for (const st of poolState.values()) {
190
+ // #338: decay penalty failCounts for stable keys in maintenance sweep
191
+ decayPenalties({ state: st }, now);
188
192
  for (const [ref, until] of [...st.failedUntil.entries()]) {
189
193
  if (until <= now) {
190
194
  st.failedUntil.delete(ref);
191
195
  st.failCounts?.delete(ref);
196
+ st.quotaWindows?.delete(ref);
192
197
  cleared++;
193
198
  }
194
199
  }
@@ -372,7 +377,6 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
372
377
 
373
378
  // 1. Direct HTTP status codes (#267: also 408/425 transient)
374
379
  if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
375
- if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
376
380
  if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
377
381
  if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
378
382
 
@@ -4,9 +4,10 @@ export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'
4
4
 
5
5
  export function nextQuotaReset(quotaResetWindow, now) {
6
6
  now = now || Date.now();
7
- if (!quotaResetWindow || typeof quotaResetWindow !== 'object') return null;
8
- const type = quotaResetWindow.type;
9
- const hour = Number.isFinite(quotaResetWindow.hour) ? quotaResetWindow.hour : 0;
7
+ const win = typeof quotaResetWindow === 'string' ? { type: quotaResetWindow === 'daily' ? 'midnight_utc' : quotaResetWindow } : quotaResetWindow;
8
+ if (!win || typeof win !== 'object') return null;
9
+ const type = win.type === 'daily' ? 'midnight_utc' : win.type;
10
+ const hour = Number.isFinite(win.hour) ? win.hour : 0;
10
11
 
11
12
  if (type === 'rolling_24h') {
12
13
  const d = new Date(now);
package/lib/rotate.js CHANGED
@@ -5,6 +5,7 @@ import { classifyFailure } from './error-taxonomy.js';
5
5
  import {
6
6
  isSwitchableError,
7
7
  recordFailure,
8
+ recordSuccess,
8
9
  parseRetryAfter,
9
10
  extractRateLimit,
10
11
  isRateLimited,
@@ -12,6 +13,7 @@ import {
12
13
  sortAttemptList,
13
14
  } from './pool.js';
14
15
  import { pickCascadeFallback } from './cascade.js';
16
+ import { nextQuotaReset } from './quota-window.js';
15
17
 
16
18
  /**
17
19
  * Create the rotate(options, pool) async generator used by llm/stream.
@@ -34,6 +36,7 @@ export function createRotate(deps) {
34
36
  quotaStore,
35
37
  circuitBreaker,
36
38
  logger,
39
+ schedulePersist,
37
40
  now = nowMono,
38
41
  } = deps;
39
42
 
@@ -44,6 +47,8 @@ export function createRotate(deps) {
44
47
  const runtime0 = buildRuntime();
45
48
  const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
46
49
  let lastFailure = null;
50
+ const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
51
+ let activePermit = null;
47
52
  const reqStore = { pool, pickedRef: undefined, startMs: now() };
48
53
  setRotateStartMs(reqStore.startMs);
49
54
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
@@ -77,8 +82,27 @@ export function createRotate(deps) {
77
82
  const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
78
83
  const cls = classifyFailure({ code: errCode, message: errMsg });
79
84
  const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
80
- if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
85
+
86
+ // #337: connect quota-window calendar reset for quota exhaustion
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);
91
+ const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
92
+ if (isQuota && qWindow) {
93
+ const quotaResetAt = nextQuotaReset(qWindow, now());
94
+ if (quotaResetAt && quotaResetAt > now()) {
95
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
96
+ pool.state.quotaWindows.set(targetRef, quotaResetAt);
97
+ pool.state.failedUntil.set(targetRef, quotaResetAt);
98
+ }
99
+ }
100
+
101
+ activePermit?.failure();
81
102
  pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
103
+ if (typeof schedulePersist === 'function') {
104
+ try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
105
+ }
82
106
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
83
107
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
84
108
  const _cStr = String(errCode ?? '');
@@ -94,165 +118,192 @@ export function createRotate(deps) {
94
118
  }
95
119
  };
96
120
 
97
- // #260: fail fast when provider circuit is open
98
- if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
99
- logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
100
- yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
101
- return;
102
- }
103
-
104
121
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
105
- let yielded = false;
106
- let switching = false;
107
- let inner;
108
- try {
109
- // mark the internal dispatch so the interceptor does not re-rotate
110
- inner = dispatchStorage.run(reqStore, () => {
111
- const llm = ctx.get('llm');
112
- if (!llm || typeof llm.stream !== 'function') {
113
- throw new Error('dsh-key-rotation: llm service unavailable');
114
- }
115
- return llm.stream({ ...options, [MARKER]: true });
116
- });
117
- } catch (e) {
118
- const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
119
- penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
120
- lastFailure = finishError(e?.code ?? 'TRANSPORT',
121
- `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
122
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
123
- 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;
124
127
  }
125
-
126
- const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
127
- const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
128
128
  try {
129
+ let yielded = false;
130
+ let switching = false;
131
+ let inner;
129
132
  try {
130
- for await (const chunk of inner) {
131
- // Only actual content deltas lock the stream (no more rotation).
132
- // Structural/metadata chunks (block-start/end, usage) do not.
133
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
134
- yielded = true;
135
- yield chunk;
136
- 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');
137
138
  }
138
- if (chunk && chunk.type === 'finish') {
139
- const kind = chunk.reason?.kind;
140
- const failure = chunk.reason?.failure;
141
- const code = failure?.code;
142
- const message = failure?.message ?? '';
143
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
144
- const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
145
- const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
146
- if (switchable) {
147
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
148
- pool.state.switches = (pool.state.switches ?? 0) + 1;
149
- pool.state.lastReason = String(code ?? 'UNKNOWN');
150
- pool.state.lastSwitchAt = now();
151
- lastFailure = chunk;
152
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
153
- // #216: per-switch webhook (opt-in switchNotify), deduped per provider
154
- if (switchNotify && activeRef) {
155
- notifySwitch(runtime0, pool, {
156
- provider: options.provider,
157
- from: activeRef,
158
- code: String(code ?? 'UNKNOWN'),
159
- at: pool.state.lastSwitchAt,
160
- });
161
- }
162
- switching = true;
163
- 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;
164
161
  }
165
- // cost tracking if provider returns usage.cost
166
- const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
167
- if (chunk.usage?.cost != null && activeRef) {
168
- const c = Number(chunk.usage.cost);
169
- if (!isNaN(c)) {
170
- if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
171
- pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
172
- // #208: cost per day per key (mirrors usageDays) for budget checks
173
- if (!pool.state.costDays) pool.state.costDays = new Map();
174
- const cMap = pool.state.costDays.get(activeRef) || new Map();
175
- cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
176
- 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;
177
188
  }
178
- }
179
- // Usage by day (#119)
180
- if (activeRef) {
181
- if (!pool.state.usageDays) pool.state.usageDays = new Map();
182
- const dayMap = pool.state.usageDays.get(activeRef) || new Map();
183
- dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
184
- pool.state.usageDays.set(activeRef, dayMap);
185
- }
186
- // Per-model request detail (#121)
187
- if (activeRef && options.model) {
188
- if (!pool.state.byModel) pool.state.byModel = new Map();
189
- let byRef = pool.state.byModel.get(activeRef);
190
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
191
- byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
192
- }
193
- // Proactive rate-limit (#115, #303): if response headers say this key is near
194
- // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
195
- // We do NOT re-run this (already successful) request — that would double-send.
196
- const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
197
- const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
198
- if (guardEnabled && rate && activeRef) {
199
- if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
200
- let cool;
201
- if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
202
- cool = rate.retryAfter * 1000;
203
- } else if (rate.reset && rate.reset > now()) {
204
- cool = rate.reset - now();
205
- } else {
206
- cool = pool.cooldownMs ?? cooldownMs;
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);
207
194
  }
208
- const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
209
- const effCool = Math.min(cool, maxCool ?? cool);
210
- recordFailure(pool, activeRef, now(), effCool, maxCool);
211
- pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
212
- 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`);
195
+ yield chunk;
196
+ return;
213
197
  }
214
- }
215
- // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
216
- if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
217
- quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
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());
229
+ }
230
+
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`);
251
+ }
252
+ }
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;
218
261
  }
219
262
  yield chunk;
220
- recordLatency(pool, reqStore);
221
- if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
222
- return;
223
263
  }
224
- yield chunk;
225
- }
226
- } catch (e) {
227
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
228
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
229
- if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
230
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
231
- pool.state.switches = (pool.state.switches ?? 0) + 1;
232
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
233
- pool.state.lastSwitchAt = now();
234
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
235
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
236
- if (switchNotify && activeRef) {
237
- notifySwitch(runtime0, pool, {
238
- provider: options.provider,
239
- from: activeRef,
240
- code: String(e?.code ?? 'TRANSPORT'),
241
- at: pool.state.lastSwitchAt,
242
- });
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));
243
286
  }
244
- continue; // Failover to next key!
287
+ yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
288
+ return;
245
289
  }
246
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
247
- return;
248
- }
249
290
 
250
- if (switching) continue; // try the next key
251
- return; // clean end — served
252
- } finally {
253
- if (acquired && _pickedRef) {
254
- concurrencyTracker.release(_pickedRef);
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
+ }
255
303
  }
304
+ } finally {
305
+ // Also runs on consumer return(), abort, empty EOF and local errors.
306
+ activePermit?.release();
256
307
  }
257
308
  }
258
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
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": [