@goodandready/dsh-key-rotation 0.7.40 → 0.8.1

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/README.md CHANGED
@@ -28,6 +28,17 @@
28
28
 
29
29
  ## ⚡ Overview & The Problem
30
30
 
31
+ ### 🛠️ What's New in v0.8.0 (Stability)
32
+ - **🔌 Circuit breaker**: after N consecutive provider failures the circuit opens and requests fail fast (`CIRCUIT_OPEN`) until a cool-down; half-open probes recover automatically.
33
+ - **🕒 Monotonic clock**: cooldown/breaker durations use process monotonic time so NTP steps cannot invert remaining times.
34
+ - **📮 Non-blocking webhooks**: alerts go through a bounded queue with backoff — stream rotation never waits on webhook HTTP.
35
+ - **🧱 Atomic I/O helpers**: crash-safe writes; corrupt JSON never overwrites previous in-memory state.
36
+ - **🧹 Clone-route GC**: orphaned auto-created clone routes are dropped from the runtime set.
37
+ - **🧭 Error taxonomy**: explicit switch/surface/soft classification for 408/425/429/5xx, sockets and gRPC codes.
38
+ - **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
39
+ - **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
40
+
41
+
31
42
  ### 🛠️ What's New in v0.7.33 (Stability & Bugfix Release)
32
43
  - **🔍 Resolved Key Probing BaseURL**: Fixed `resolveBaseUrl` to map key credential refs to owning provider pools, restoring live `probeModels` testing.
33
44
  - **🛡️ Guarded Cascade Recursion**: Prevented call stack overflow in cross-provider failover when circular cascade chains occur.
@@ -192,6 +203,11 @@ dsh-key-rotation:
192
203
  - UNKNOWN_MODEL
193
204
  - AUTH
194
205
  cooldownMs: 60000
206
+ # v0.8.0 circuit breaker
207
+ circuitBreakerEnabled: true
208
+ circuitBreakerThreshold: 5
209
+ circuitBreakerOpenMs: 30000
210
+ circuitBreakerHalfOpenProbes: 1
195
211
  concurrencyLimit: 5
196
212
  quotaResetWindow:
197
213
  type: midnight_utc
@@ -220,6 +236,11 @@ dsh-key-rotation:
220
236
  |---|---|---|---|
221
237
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | List of error codes that immediately trigger failover. |
222
238
  | `cooldownMs` | `number` | `60000` (1 min) | Base penalty duration (in ms) for quarantined keys. |
239
+ | `circuitBreakerEnabled` | `boolean` | `true` | Enable per-provider circuit breaker (v0.8.0). |
240
+ | `circuitBreakerThreshold` | `number` | `5` | Consecutive failures before opening the circuit. |
241
+ | `circuitBreakerOpenMs` | `number` | `30000` | How long the circuit stays open (ms). |
242
+ | `circuitBreakerHalfOpenProbes` | `number` | `1` | Probe requests allowed in half-open state. |
243
+ | `verboseLogging` | `boolean` | `false` | Per-request rotation logs (noisy; off by default). |
223
244
  | `concurrencyLimit` | `number` | `0` (disabled) | Max concurrent in-flight streams per key (0 = unlimited). |
224
245
  | `quotaResetWindow` | `object` | `null` | Calendar reset alignment (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
225
246
  | `cascade` | `array` | `[]` | Fallback provider chain when primary pool is completely exhausted. |
@@ -234,7 +255,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
234
255
 
235
256
  | Route | Method | Description |
236
257
  |---|---|---|
237
- | `/dsh-key-rotation/status` | `GET` | Returns real-time health snapshots, active keys, and cooldown states. |
258
+ | `/dsh-key-rotation/status` | `GET` | Real-time health, keys, cooldowns. Since v0.8.0 also `providers[].circuit` and `meta` (`expectedClones`, `notifyQueue`). |
238
259
  | `/dsh-key-rotation/config` | `GET` / `PUT` | Read and update active key rotation settings and provider pools. |
239
260
  | `/dsh-key-rotation/key` | `PUT` / `DELETE` | Add, update, or remove credentials in host storage and pool. |
240
261
  | `/dsh-key-rotation/reset` | `POST` | Instantly resets all cooldowns and restores all keys to `ready`. |
package/README.ru.md CHANGED
@@ -28,6 +28,17 @@
28
28
 
29
29
  ## ⚡ Обзор и решаемая проблема
30
30
 
31
+ ### 🛠️ Что нового в версии 0.8.0 (Стабильность)
32
+ - **🔌 Автомат выключения провайдера**: после N подряд ошибок контур открывается, запросы быстро завершаются (`CIRCUIT_OPEN`); half-open пробы восстанавливают сервис.
33
+ - **🕒 Монотонные часы**: cooldown/breaker считаются по процессным часам, NTP не ломает оставшееся время.
34
+ - **📮 Неблокирующие webhook**: очередь с backoff — `rotate()` не ждёт HTTP.
35
+ - **🧱 Атомарная запись файлов**: повреждённый JSON не затирает прежнее состояние.
36
+ - **🧹 GC clone-маршрутов**: осиротевшие авто-роуты убираются из runtime-набора.
37
+ - **🧭 Таксономия ошибок**: явная классификация switch/surface/soft (408/425/429/5xx, сокеты, gRPC).
38
+ - **📡 Status**: `circuit` у провайдера + `meta.expectedClones` / `meta.notifyQueue`.
39
+ - **🧪 Smoke**: сценарий 429 → следующий ключ → успех.
40
+
41
+
31
42
  ### 🛠️ Что нового в версии 0.7.33 (Хотфикс и повышение стабильности)
32
43
  - **🔍 Исправление проверки ключей**: Функция `resolveBaseUrl` теперь сопоставляет ref ключа с его пулом, восстанавливая работу живого тестирования моделей.
33
44
  - **🛡️ Защита от зацикливания каскада**: Устранена возможность переполнения стека при взаимных кольцевых цепочках failover.
@@ -192,6 +203,10 @@ dsh-key-rotation:
192
203
  - UNKNOWN_MODEL
193
204
  - AUTH
194
205
  cooldownMs: 60000
206
+ circuitBreakerEnabled: true
207
+ circuitBreakerThreshold: 5
208
+ circuitBreakerOpenMs: 30000
209
+ circuitBreakerHalfOpenProbes: 1
195
210
  concurrencyLimit: 5
196
211
  quotaResetWindow:
197
212
  type: midnight_utc
@@ -282,3 +297,16 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
282
297
  - **Архитектура настроек**: Добавлена нативная интеграция со снимками `settingsScope` в карточке настроек с безопасным фоллбеком на HTTP-мост (#235).
283
298
  - **Локализация**: Фоллбек секции настроек `settings.section` переведён на локализованную метку `t('title')` со слотом `locale: NS`, а китайский словарь `zh` зарегистрирован в `ctx.locale` наряду с `en` и `ru` (#236).
284
299
  - **Удаление мёртвого кода**: Удалена неиспользуемая функция `mountDashboard` после перехода на header chip (#240).
300
+
301
+ ### Параметры circuit breaker (Changed in v0.8.0)
302
+
303
+ | Параметр | Тип | По умолчанию | Описание |
304
+ |---|---|---|---|
305
+ | `circuitBreakerEnabled` | boolean | `true` | Включить/выключить breaker |
306
+ | `circuitBreakerThreshold` | number | `5` | Число подряд ошибок до открытия |
307
+ | `circuitBreakerOpenMs` | number | `30000` | Длительность open, мс |
308
+ | `circuitBreakerHalfOpenProbes` | number | `1` | Проб в half-open |
309
+ | `verboseLogging` | boolean | `false` | Подробные логи rotation (шумно) |
310
+
311
+ Status API (v0.8.0): у каждого провайдера `circuit`, в корне ответа `meta.expectedClones` и `meta.notifyQueue`.
312
+
package/README.zh.md CHANGED
@@ -28,6 +28,17 @@
28
28
 
29
29
  ## ⚡ 概述与核心痛点
30
30
 
31
+ ### 🛠️ v0.8.0 版本新特性(稳定性)
32
+ - **🔌 熔断器**:连续失败后快速失败(`CIRCUIT_OPEN`),半开探测自动恢复。
33
+ - **🕒 单调时钟**:冷却/熔断使用进程单调时间,NTP 校时不会颠倒剩余时间。
34
+ - **📮 非阻塞 Webhook**:有界队列 + backoff,轮换不等待 HTTP。
35
+ - **🧱 原子写文件**:损坏 JSON 不会覆盖既有状态。
36
+ - **🧹 克隆路由 GC**:清理孤儿自动路由。
37
+ - **🧭 错误分类**:408/425/429/5xx、套接字与 gRPC 的 switch/surface/soft。
38
+ - **📡 Status**:提供商 `circuit` + `meta`。
39
+ - **🧪 Smoke**:429 → 切换密钥 → 成功。
40
+
41
+
31
42
  ### 🛠️ v0.7.33 版本新特性 (稳定性与问题修复)
32
43
  - **🔍 修复密钥探测 BaseURL 解析**:`resolveBaseUrl` 现已支持从密钥 ref 反查归属提供商池,恢复在线模型连通性探测。
33
44
  - **🛡️ 防御级联无限递归**:在跨提供商故障转移中增加递归深度防护,彻底杜绝循环级联导致的堆栈溢出。
@@ -184,6 +195,10 @@ dsh-key-rotation:
184
195
  - UNKNOWN_MODEL
185
196
  - AUTH
186
197
  cooldownMs: 60000
198
+ circuitBreakerEnabled: true
199
+ circuitBreakerThreshold: 5
200
+ circuitBreakerOpenMs: 30000
201
+ circuitBreakerHalfOpenProbes: 1
187
202
  concurrencyLimit: 5
188
203
  quotaResetWindow:
189
204
  type: midnight_utc
@@ -246,3 +261,13 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
246
261
  - **设置架构与状态**: 在设置卡片中增加原生 `settingsScope` 绑定支持,保留 HTTP 桥接安全回退机制 (#235)。
247
262
  - **本地化与文案**: 侧边栏备用项 `settings.section` 标签支持本地化 `t('title')` 并配置 `locale: NS`,并在 `ctx.locale` 中注册 `zh` 中文字典 (#236)。
248
263
  - **死代码清理**: 移除 header-chip 迁移后残留的废弃 `mountDashboard` 函数 (#240)。
264
+
265
+
266
+ ### 熔断器参数(v0.8.0)
267
+
268
+ | 参数 | 类型 | 默认 | 说明 |
269
+ |---|---|---|---|
270
+ | `circuitBreakerEnabled` | boolean | `true` | 启用熔断 |
271
+ | `circuitBreakerThreshold` | number | `5` | 连续失败阈值 |
272
+ | `circuitBreakerOpenMs` | number | `30000` | 打开时长 ms |
273
+ | `circuitBreakerHalfOpenProbes` | number | `1` | 半开探测次数 |
@@ -0,0 +1,60 @@
1
+ // lib/atomic-io.js — crash-safe file writes and safe JSON loads (#264).
2
+ // Never overwrite a non-empty destination with empty/corrupt parse results.
3
+
4
+ import { promises as fs } from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import crypto from 'node:crypto';
8
+
9
+ /**
10
+ * Atomically write data to filePath (temp + rename in same directory).
11
+ * @param {string} filePath
12
+ * @param {string|Buffer} data
13
+ */
14
+ export async function atomicWriteFile(filePath, data) {
15
+ const dir = path.dirname(filePath);
16
+ await fs.mkdir(dir, { recursive: true });
17
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
18
+ const fh = await fs.open(tmp, 'w');
19
+ try {
20
+ await fh.writeFile(data);
21
+ await fh.sync();
22
+ } finally {
23
+ await fh.close();
24
+ }
25
+ await fs.rename(tmp, filePath);
26
+ }
27
+
28
+ /**
29
+ * Parse JSON safely. On failure returns fallback — never null-by-default wipe.
30
+ * @param {string|Buffer|null|undefined} text
31
+ * @param {any} fallback
32
+ */
33
+ export function safeParseJson(text, fallback = null) {
34
+ if (text == null || text === '') return fallback;
35
+ try {
36
+ const v = JSON.parse(typeof text === 'string' ? text : String(text));
37
+ return v == null ? fallback : v;
38
+ } catch {
39
+ return fallback;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Read JSON file; corrupt/missing → fallback (previous in-memory value).
45
+ * @param {string} filePath
46
+ * @param {any} fallback
47
+ */
48
+ export async function safeReadJson(filePath, fallback = null) {
49
+ try {
50
+ const raw = await fs.readFile(filePath, 'utf8');
51
+ return safeParseJson(raw, fallback);
52
+ } catch {
53
+ return fallback;
54
+ }
55
+ }
56
+
57
+ /** Write JSON atomically. */
58
+ export async function atomicWriteJson(filePath, value) {
59
+ await atomicWriteFile(filePath, JSON.stringify(value, null, 2));
60
+ }
@@ -0,0 +1,65 @@
1
+ // lib/bounded-map.js — Map with max size + optional TTL (#262).
2
+
3
+ export class BoundedMap {
4
+ /** @param {{ max?: number, ttlMs?: number|null }} opts */
5
+ constructor({ max = 1000, ttlMs = null } = {}) {
6
+ if (!Number.isFinite(max) || max < 1) throw new Error('BoundedMap: max must be >= 1');
7
+ this.max = max;
8
+ this.ttlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : null;
9
+ this._m = new Map(); // insertion order = LRU order when we re-insert on get/set
10
+ }
11
+
12
+ get size() { return this._m.size; }
13
+
14
+ _expired(entry, now) {
15
+ if (!this.ttlMs || !entry) return false;
16
+ return now - entry.at >= this.ttlMs;
17
+ }
18
+
19
+ get(key, now = Date.now()) {
20
+ const e = this._m.get(key);
21
+ if (!e) return undefined;
22
+ if (this._expired(e, now)) {
23
+ this._m.delete(key);
24
+ return undefined;
25
+ }
26
+ // refresh LRU
27
+ this._m.delete(key);
28
+ this._m.set(key, e);
29
+ return e.v;
30
+ }
31
+
32
+ set(key, value, now = Date.now()) {
33
+ if (this._m.has(key)) this._m.delete(key);
34
+ this._m.set(key, { v: value, at: now });
35
+ while (this._m.size > this.max) {
36
+ const oldest = this._m.keys().next().value;
37
+ this._m.delete(oldest);
38
+ }
39
+ return this;
40
+ }
41
+
42
+ has(key, now = Date.now()) {
43
+ return this.get(key, now) !== undefined;
44
+ }
45
+
46
+ delete(key) { return this._m.delete(key); }
47
+ clear() { this._m.clear(); }
48
+
49
+ *entries(now = Date.now()) {
50
+ for (const [k, e] of [...this._m.entries()]) {
51
+ if (this._expired(e, now)) { this._m.delete(k); continue; }
52
+ yield [k, e.v];
53
+ }
54
+ }
55
+
56
+ *keys(now = Date.now()) {
57
+ for (const [k] of this.entries(now)) yield k;
58
+ }
59
+
60
+ toObject(now = Date.now()) {
61
+ const out = {};
62
+ for (const [k, v] of this.entries(now)) out[k] = v;
63
+ return out;
64
+ }
65
+ }
@@ -0,0 +1,93 @@
1
+ // lib/circuit-breaker.js — per-provider circuit breaker (#260).
2
+ // closed --(threshold consecutive failures)--> open --(openMs)--> half-open
3
+ // half-open: allow limited probes; success => closed, failure => open again.
4
+
5
+ export const BREAKER_CLOSED = 'closed';
6
+ export const BREAKER_OPEN = 'open';
7
+ export const BREAKER_HALF_OPEN = 'half_open';
8
+
9
+ export class CircuitBreaker {
10
+ /**
11
+ * @param {{ threshold?: number, openMs?: number, halfOpenProbes?: number, now?: () => number }} opts
12
+ */
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);
17
+ this._now = now;
18
+ /** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number}>} */
19
+ this._st = new Map();
20
+ }
21
+
22
+ _entry(provider) {
23
+ let e = this._st.get(provider);
24
+ if (!e) {
25
+ e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0 };
26
+ this._st.set(provider, e);
27
+ }
28
+ return e;
29
+ }
30
+
31
+ /** @returns {boolean} whether a request may be dispatched to this provider */
32
+ canRequest(provider) {
33
+ const e = this._entry(provider);
34
+ const now = this._now();
35
+ if (e.state === BREAKER_OPEN) {
36
+ if (now - e.openedAt >= this.openMs) {
37
+ e.state = BREAKER_HALF_OPEN;
38
+ e.probes = 0;
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ if (e.state === BREAKER_HALF_OPEN) {
44
+ if (e.probes < this.halfOpenProbes) {
45
+ e.probes += 1;
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+
53
+ onSuccess(provider) {
54
+ const e = this._entry(provider);
55
+ e.fails = 0;
56
+ e.probes = 0;
57
+ e.state = BREAKER_CLOSED;
58
+ }
59
+
60
+ onFailure(provider) {
61
+ const e = this._entry(provider);
62
+ const now = this._now();
63
+ if (e.state === BREAKER_HALF_OPEN) {
64
+ e.state = BREAKER_OPEN;
65
+ e.openedAt = now;
66
+ e.fails = this.threshold;
67
+ return e.state;
68
+ }
69
+ e.fails += 1;
70
+ if (e.fails >= this.threshold) {
71
+ e.state = BREAKER_OPEN;
72
+ e.openedAt = now;
73
+ }
74
+ return e.state;
75
+ }
76
+
77
+ state(provider) {
78
+ return this._entry(provider).state;
79
+ }
80
+
81
+ snapshot() {
82
+ const out = {};
83
+ for (const [k, e] of this._st) {
84
+ out[k] = { state: e.state, fails: e.fails, openedAt: e.openedAt || null };
85
+ }
86
+ return out;
87
+ }
88
+
89
+ reset(provider) {
90
+ if (provider) this._st.delete(provider);
91
+ else this._st.clear();
92
+ }
93
+ }
package/lib/client.js CHANGED
@@ -240,6 +240,33 @@ window.__ModuleLoader__.load({
240
240
  // отдельными отмеченными галочками, чтобы правило нельзя было потерять.
241
241
  const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
242
242
 
243
+ // #273: keep the settings list item mounted if the section throws.
244
+ class KeyRotationErrorBoundary extends React.Component {
245
+ constructor(props) {
246
+ super(props);
247
+ this.state = { error: null };
248
+ }
249
+ static getDerivedStateFromError(error) {
250
+ return { error };
251
+ }
252
+ componentDidCatch(error, info) {
253
+ try { console.error('[dsh-key-rotation] settings card crashed', error, info); } catch (_) {}
254
+ }
255
+ render() {
256
+ if (this.state.error) {
257
+ return React.createElement('div', { className: 'krot krot-err', style: { padding: 12 } },
258
+ React.createElement('p', null, 'Key Rotation settings failed to render.'),
259
+ React.createElement('p', { style: { fontSize: 11, opacity: 0.8 } }, String(this.state.error?.message || this.state.error)),
260
+ React.createElement('button', {
261
+ type: 'button',
262
+ className: 'krot-btn',
263
+ onClick: () => this.setState({ error: null }),
264
+ }, 'Retry'));
265
+ }
266
+ return this.props.children;
267
+ }
268
+ }
269
+
243
270
  /** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
244
271
  function useRotationStatus() {
245
272
  const [byProvider, setByProvider] = React.useState({});
@@ -414,9 +441,31 @@ window.__ModuleLoader__.load({
414
441
  }
415
442
  return null;
416
443
  }, [props.ctx]);
444
+ // #273: getSnapshot must be referentially stable or React 18 unmounts the tree
445
+ const scopeCacheRef = React.useRef({ has: false, value: null });
446
+ const getScopeSnapshot = React.useCallback(() => {
447
+ if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
448
+ let next = null;
449
+ try {
450
+ next = settingsScope.getSnapshot();
451
+ } catch (_) {
452
+ return null;
453
+ }
454
+ const prev = scopeCacheRef.current;
455
+ if (prev.has && Object.is(prev.value, next)) return prev.value;
456
+ // shallow-compare common snapshot shape to avoid identity thrash
457
+ if (prev.has && prev.value && next
458
+ && prev.value.status === next.status
459
+ && prev.value.revision === next.revision
460
+ && prev.value.value === next.value) {
461
+ return prev.value;
462
+ }
463
+ scopeCacheRef.current = { has: true, value: next };
464
+ return next;
465
+ }, [settingsScope]);
417
466
  const scopeSnapshot = React.useSyncExternalStore(
418
- React.useMemo(() => (cb) => (settingsScope ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
419
- React.useCallback(() => (settingsScope ? settingsScope.getSnapshot() : null), [settingsScope])
467
+ React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
468
+ getScopeSnapshot
420
469
  );
421
470
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
422
471
  const [draft, setDraft] = React.useState(null);
@@ -1185,7 +1234,7 @@ window.__ModuleLoader__.load({
1185
1234
  h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
1186
1235
  h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
1187
1236
  h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
1188
- open ? h('div', { className: 'krot-card-body' }, h(KeyRotationSection, { ...props, locale })) : null);
1237
+ open ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
1189
1238
  }
1190
1239
  const tryPluginItem = () => {
1191
1240
  try {
@@ -1209,7 +1258,7 @@ window.__ModuleLoader__.load({
1209
1258
  label: () => (t ? t('title') : 'Key Rotation') || 'Key Rotation',
1210
1259
  inject: () => ({ ctx }),
1211
1260
  },
1212
- (props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
1261
+ (props) => h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale: useLocale() })),
1213
1262
  ));
1214
1263
  }
1215
1264
  }
package/lib/clock.js ADDED
@@ -0,0 +1,24 @@
1
+ // lib/clock.js — monotonic process clock for durations (#261).
2
+ // performance.timeOrigin + performance.now() tracks process time and does not
3
+ // jump when NTP adjusts the wall clock. Wall clock remains for display/cron.
4
+
5
+ const hasPerformance = typeof performance !== 'undefined'
6
+ && typeof performance.now === 'function'
7
+ && Number.isFinite(performance.timeOrigin);
8
+
9
+ /** Wall-clock epoch ms (Date.now). Use only for display / calendar buckets. */
10
+ export function nowWall() {
11
+ return Date.now();
12
+ }
13
+
14
+ /**
15
+ * Monotonic-in-process epoch ms. Stable under NTP steps for the process lifetime.
16
+ * Falls back to Date.now() if performance is unavailable.
17
+ */
18
+ export function nowMono() {
19
+ if (!hasPerformance) return Date.now();
20
+ return performance.timeOrigin + performance.now();
21
+ }
22
+
23
+ /** Default clock injectable into pure helpers. */
24
+ export const defaultClock = { nowWall, nowMono };
@@ -0,0 +1,66 @@
1
+ // lib/error-taxonomy.js — complete failure classification (#267).
2
+ // action: 'switch' | 'surface' | 'cooldown'
3
+
4
+ const SWITCH_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
5
+ const SURFACE_STATUS = new Set([400, 404, 422]);
6
+ const AUTH_STATUS = new Set([401, 403]);
7
+
8
+ const SWITCH_CODES = new Set([
9
+ 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE',
10
+ 'UNKNOWN_MODEL', 'AUTH', 'RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'INTERNAL',
11
+ 'DEADLINE_EXCEEDED', 'UNAUTHENTICATED', 'PERMISSION_DENIED', 'ABORTED',
12
+ ]);
13
+ const SOCKET_SWITCH = new Set([
14
+ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
15
+ 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
16
+ ]);
17
+
18
+ /**
19
+ * Classify a failure for rotation.
20
+ * @param {any} failureOrPayload
21
+ * @returns {{ action: 'switch'|'surface'|'cooldown', code: string, soft: boolean, reason: string }}
22
+ */
23
+ export function classifyFailure(failureOrPayload) {
24
+ if (!failureOrPayload) {
25
+ return { action: 'surface', code: 'UNKNOWN', soft: false, reason: 'empty' };
26
+ }
27
+ const failure = failureOrPayload.failure ?? failureOrPayload;
28
+ const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
29
+ const code = String(failure.code ?? failure.reason ?? failure.name ?? '').toUpperCase();
30
+ const message = String(failure.message ?? failureOrPayload.message ?? '');
31
+
32
+ if (AUTH_STATUS.has(status) || code === 'AUTH' || code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED') {
33
+ return { action: 'switch', code: status ? String(status) : (code || 'AUTH'), soft: false, reason: 'auth' };
34
+ }
35
+ if (status === 429 || code === 'RATE_LIMIT' || code === 'RESOURCE_EXHAUSTED' || code === 'QUOTA') {
36
+ return { action: 'switch', code: status ? '429' : (code || 'RATE_LIMIT'), soft: false, reason: 'quota' };
37
+ }
38
+ if (SWITCH_STATUS.has(status)) {
39
+ const soft = status === 500 || status === 502 || status === 503 || status === 504;
40
+ return { action: 'switch', code: String(status), soft, reason: 'http' };
41
+ }
42
+ if (SURFACE_STATUS.has(status)) {
43
+ return { action: 'surface', code: String(status), soft: false, reason: 'client' };
44
+ }
45
+ if (code === 'TIMEOUT' || code === 'DEADLINE_EXCEEDED' || /timeout/i.test(message)) {
46
+ return { action: 'switch', code: 'TIMEOUT', soft: true, reason: 'timeout' };
47
+ }
48
+ if (SOCKET_SWITCH.has(code) || /ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|premature close/i.test(message)) {
49
+ return { action: 'switch', code: 'TRANSPORT', soft: true, reason: 'socket' };
50
+ }
51
+ if (code === 'UNAVAILABLE' || code === 'INTERNAL' || code === 'SERVER') {
52
+ return { action: 'switch', code: code || 'SERVER', soft: true, reason: 'grpc' };
53
+ }
54
+ if (code === 'TRANSPORT' || code === 'EMPTY_RESPONSE' || code === 'UNKNOWN_MODEL') {
55
+ return { action: 'switch', code, soft: code !== 'UNKNOWN_MODEL', reason: 'named' };
56
+ }
57
+ if (SWITCH_CODES.has(code)) {
58
+ return { action: 'switch', code, soft: false, reason: 'named' };
59
+ }
60
+ return { action: 'surface', code: code || 'UNKNOWN', soft: false, reason: 'unclassified' };
61
+ }
62
+
63
+ /** True when classification says switch to next key. */
64
+ export function shouldSwitch(failureOrPayload) {
65
+ return classifyFailure(failureOrPayload).action === 'switch';
66
+ }
package/lib/index.js CHANGED
@@ -42,6 +42,12 @@ const dispatchStorage = new AsyncLocalStorage();
42
42
  import { findSecrets, looksLikeApiSecret } from './keycheck.js';
43
43
  import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
44
44
  import { createRotate } from './rotate.js';
45
+ import { nowMono } from './clock.js';
46
+ import { CircuitBreaker } from './circuit-breaker.js';
47
+ import { NotifyQueue } from './notify-queue.js';
48
+ import { BoundedMap } from './bounded-map.js';
49
+ import { classifyFailure } from './error-taxonomy.js';
50
+ import { safeParseJson } from './atomic-io.js';
45
51
  import { registerOpsRoutes } from './routes-ops.js';
46
52
 
47
53
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
@@ -67,7 +73,11 @@ export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now:
67
73
  const now = hooks.now();
68
74
  if (now - last < throttle) return;
69
75
  switchNotifiedAt.set(info.provider, now);
70
- hooks.webhookSender.send(runtime.notifyWebhook, {
76
+ // #263: non-blocking — enqueue never awaits webhook I/O
77
+ const send = hooks.notifyQueue
78
+ ? (url, payload) => { hooks.notifyQueue.enqueue(url, payload); return { sent: true, queued: true }; }
79
+ : (url, payload) => hooks.webhookSender.send(url, payload);
80
+ send(runtime.notifyWebhook, {
71
81
  title: `Key switched: ${info.provider}`,
72
82
  text: `${info.from} failed (${info.code}) - next key in pool`,
73
83
  provider: info.provider,
@@ -98,11 +108,18 @@ let lastTestCacheRunnerCtx = null;
98
108
  const lastTestCache = new LastTestCache();
99
109
  const latencyHistogram = new LatencyHistogram();
100
110
  const quotaStore = new QuotaStore();
111
+ // #260/#263 module-scope infra (process-wide, reset on reload via buildRuntime)
112
+ let moduleBreaker = null;
113
+ let moduleNotifyQueue = null;
101
114
  // Global config accessor safe against early initialization
102
115
  let getConfig = () => null;
103
116
  let getRuntime = () => null;
104
117
  let sandboxRunner = null;
105
118
  const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
119
+ // #263: queue webhook I/O off the hot path
120
+ moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => webhookSender.send(url, payload) });
121
+ // #260: process-wide breaker; thresholds re-read from runtime when dispatching
122
+ moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
106
123
  const concurrencyTracker = new ConcurrencyTracker();
107
124
  function ensureSandboxRunner(ctx) {
108
125
  if (sandboxRunner) return sandboxRunner;
@@ -182,6 +199,11 @@ export const Config = Schema.object({
182
199
  expiryWarnDays: Schema.number().default(7),
183
200
  switchNotify: Schema.boolean().default(false),
184
201
  verboseLogging: Schema.boolean().default(false),
202
+ // #260 circuit breaker
203
+ circuitBreakerEnabled: Schema.boolean().default(true),
204
+ circuitBreakerThreshold: Schema.number().default(5),
205
+ circuitBreakerOpenMs: Schema.number().default(30000),
206
+ circuitBreakerHalfOpenProbes: Schema.number().default(1),
185
207
  switchNotifyThrottleMs: Schema.number().default(60000),
186
208
  warnBelowHealthy: Schema.number().default(0),
187
209
  latencySloMs: Schema.number().default(0),
@@ -443,6 +465,8 @@ export function apply(ctx, config = {}) {
443
465
  byModel: new Map(),
444
466
  usageDays: new Map(),
445
467
  quotaWindows: new Map(),
468
+ // #260 per-provider circuit breaker (lazy)
469
+ breaker: null,
446
470
  pointer: 0,
447
471
  lastUsed: undefined,
448
472
  switches: 0,
@@ -537,7 +561,30 @@ export function apply(ctx, config = {}) {
537
561
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
538
562
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
539
563
  }
540
- cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
564
+ // #266: orphan clone-route GC expected clones only for live multi-key providers
565
+ const expectedClones = new Set();
566
+ for (const p of cfg.providers ?? []) {
567
+ const n = (p.keys ?? []).filter((k) => typeof k === 'string' && k.length > 0).length;
568
+ for (let i = 1; i < n; i++) expectedClones.add(`${p.provider}-${i + 1}`);
569
+ }
570
+ // drop breaker entries for removed providers
571
+ if (moduleBreaker) {
572
+ for (const key of Object.keys(moduleBreaker.snapshot())) {
573
+ if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
574
+ // keep until TTL; only reset if provider gone from config entirely
575
+ const still = (cfg.providers ?? []).some((p) => p.provider === key);
576
+ if (!still) moduleBreaker.reset(key);
577
+ }
578
+ }
579
+ }
580
+ cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
581
+ circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
582
+ circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
583
+ circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
584
+ circuitBreakerHalfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
585
+ breaker: moduleBreaker,
586
+ notifyQueue: moduleNotifyQueue,
587
+ };
541
588
  lastConfigRef = rawConfig;
542
589
  lastProfilesRef = currentProfiles;
543
590
  return cachedRuntime;
@@ -671,18 +718,21 @@ export function apply(ctx, config = {}) {
671
718
  }
672
719
 
673
720
  // rotate() factory (#253): dependencies injected for testability
674
- const rotate = createRotate({
721
+ const rotate = createRotate({
675
722
  ctx,
676
723
  dispatchStorage,
677
724
  buildRuntime,
678
725
  pushEvent,
679
- notifySwitch,
726
+ notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
680
727
  notifyExhaustion,
681
728
  recordLatency,
682
729
  concurrencyTracker,
683
730
  MARKER,
684
731
  finishError,
685
732
  setRotateStartMs: (v) => { _rotateStartMs = v; },
733
+ quotaStore,
734
+ circuitBreaker: moduleBreaker,
735
+ now: nowMono,
686
736
  });
687
737
 
688
738
  // Retry one request on the next pool key when the current key fails with a
@@ -727,11 +777,13 @@ export function apply(ctx, config = {}) {
727
777
  const code = String(payload?.failure?.code ?? payload?.code ?? '');
728
778
  const message = String(payload?.failure?.message ?? payload?.message ?? '');
729
779
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
730
- const switchable = isSwitchableError(payload, effectiveSwitchCodes);
780
+ const cls = classifyFailure(payload);
781
+ const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
731
782
  if (!switchable) return next();
783
+ if (moduleBreaker) moduleBreaker.onFailure(provider);
732
784
  const ref = pool.state.lastUsed;
733
785
  if (ref) {
734
- const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000);
786
+ const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft);
735
787
  pushEvent(pool, ref, code || 'UNKNOWN', backoff);
736
788
  pool.state.switches = (pool.state.switches ?? 0) + 1;
737
789
  pool.state.lastReason = code || 'UNKNOWN';
@@ -773,7 +825,8 @@ export function notifyExhaustion(runtime, pool, options, hooks = { webhookSender
773
825
  { id: `pause-${options.provider}`, label: 'Pause 1h' },
774
826
  ] : undefined,
775
827
  };
776
- hooks.webhookSender.send(runtime.notifyWebhook, payload);
828
+ if (hooks.notifyQueue) hooks.notifyQueue.enqueue(runtime.notifyWebhook, payload);
829
+ else hooks.webhookSender.send(runtime.notifyWebhook, payload);
777
830
  }
778
831
  } catch (_) { /* ponytail: never crash rotate() */ }
779
832
  }
@@ -0,0 +1,75 @@
1
+ // lib/notify-queue.js — non-blocking webhook dispatch with backoff (#263).
2
+ // rotate() must never await webhook I/O.
3
+
4
+ export class NotifyQueue {
5
+ /**
6
+ * @param {{ send: (url:string, payload:object)=>Promise<any>, maxDepth?: number, baseBackoffMs?: number, maxBackoffMs?: number }} opts
7
+ */
8
+ constructor({ send, maxDepth = 50, baseBackoffMs = 2000, maxBackoffMs = 60000 } = {}) {
9
+ if (typeof send !== 'function') throw new Error('NotifyQueue: send required');
10
+ this._send = send;
11
+ this.maxDepth = maxDepth;
12
+ this.baseBackoffMs = baseBackoffMs;
13
+ this.maxBackoffMs = maxBackoffMs;
14
+ this._q = [];
15
+ this._busy = false;
16
+ this._backoffByUrl = new Map();
17
+ this._dropped = 0;
18
+ this._sent = 0;
19
+ this._failed = 0;
20
+ }
21
+
22
+ enqueue(url, payload) {
23
+ if (!url) return { queued: false, reason: 'no-url' };
24
+ if (this._q.length >= this.maxDepth) {
25
+ this._dropped += 1;
26
+ return { queued: false, reason: 'full', dropped: this._dropped };
27
+ }
28
+ this._q.push({ url, payload });
29
+ this._pump();
30
+ return { queued: true };
31
+ }
32
+
33
+ _pump() {
34
+ if (this._busy) return;
35
+ const job = this._q.shift();
36
+ if (!job) return;
37
+ this._busy = true;
38
+ const delay = this._backoffByUrl.get(job.url) ?? 0;
39
+ const run = async () => {
40
+ try {
41
+ const res = await this._send(job.url, job.payload);
42
+ if (res && res.sent === false) {
43
+ this._failed += 1;
44
+ this._bumpBackoff(job.url);
45
+ } else {
46
+ this._sent += 1;
47
+ this._backoffByUrl.delete(job.url);
48
+ }
49
+ } catch {
50
+ this._failed += 1;
51
+ this._bumpBackoff(job.url);
52
+ } finally {
53
+ this._busy = false;
54
+ if (this._q.length > 0) this._pump();
55
+ }
56
+ };
57
+ if (delay > 0) {
58
+ const t = setTimeout(run, delay);
59
+ if (typeof t.unref === 'function') t.unref();
60
+ } else {
61
+ // fire and forget — do not return a promise to callers
62
+ run();
63
+ }
64
+ }
65
+
66
+ _bumpBackoff(url) {
67
+ const cur = this._backoffByUrl.get(url) ?? 0;
68
+ const next = cur === 0 ? this.baseBackoffMs : Math.min(cur * 2, this.maxBackoffMs);
69
+ this._backoffByUrl.set(url, next);
70
+ }
71
+
72
+ stats() {
73
+ return { depth: this._q.length, dropped: this._dropped, sent: this._sent, failed: this._failed, busy: this._busy };
74
+ }
75
+ }
package/lib/pool.js CHANGED
@@ -288,7 +288,8 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
288
288
  const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
289
289
  const message = String(failure.message ?? failureOrPayload.message ?? '');
290
290
 
291
- // 1. Direct HTTP status codes
291
+ // 1. Direct HTTP status codes (#267: also 408/425 transient)
292
+ 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;
292
293
  if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
293
294
  if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
294
295
  if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
package/lib/rotate.js CHANGED
@@ -1,5 +1,16 @@
1
1
  // lib/rotate.js — stream rotation / failover generator (#253).
2
2
  // Pure wiring helper: dependencies are injected so unit tests can supply mocks.
3
+ import { nowMono } from './clock.js';
4
+ import { classifyFailure } from './error-taxonomy.js';
5
+ import {
6
+ isSwitchableError,
7
+ recordFailure,
8
+ parseRetryAfter,
9
+ extractRateLimit,
10
+ isRateLimited,
11
+ formatExhaustionMessage,
12
+ } from './pool.js';
13
+ import { pickCascadeFallback } from './cascade.js';
3
14
 
4
15
  /**
5
16
  * Create the rotate(options, pool) async generator used by llm/stream.
@@ -18,6 +29,9 @@ export function createRotate(deps) {
18
29
  MARKER,
19
30
  finishError,
20
31
  setRotateStartMs,
32
+ quotaStore,
33
+ circuitBreaker,
34
+ now = nowMono,
21
35
  } = deps;
22
36
 
23
37
  function rotate(options, pool) {
@@ -25,16 +39,16 @@ export function createRotate(deps) {
25
39
  const runtime0 = buildRuntime();
26
40
  const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
27
41
  let lastFailure = null;
28
- const reqStore = { pool, pickedRef: undefined, startMs: Date.now() };
42
+ const reqStore = { pool, pickedRef: undefined, startMs: now() };
29
43
  setRotateStartMs(reqStore.startMs);
30
44
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
31
45
  if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
32
46
  // #193: prefer least-loaded key within limit
33
47
  const available = attemptList.filter((r) => {
34
48
  const fu = pool.state.failedUntil.get(r) ?? 0;
35
- if (fu > Date.now()) return false;
49
+ if (fu > now()) return false;
36
50
  const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
37
- if (exp !== undefined && Date.now() >= exp) return false;
51
+ if (exp !== undefined && now() >= exp) return false;
38
52
  return true;
39
53
  });
40
54
  const preferred = concurrencyTracker.pickLeastLoaded(available);
@@ -52,7 +66,9 @@ export function createRotate(deps) {
52
66
  const _base = pool.cooldownMs ?? cooldownMs;
53
67
  const _max = pool.maxCooldownMs ?? maxCooldownMs;
54
68
  const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
55
- const _b = recordFailure(pool, targetRef, Date.now(), _effBase, _max);
69
+ const cls = classifyFailure({ code: errCode, message: errMsg });
70
+ const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft);
71
+ if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
56
72
  pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
57
73
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
58
74
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
@@ -61,14 +77,21 @@ export function createRotate(deps) {
61
77
  const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
62
78
  pool.state.authFailCounts.set(targetRef, _c2);
63
79
  if (_c2 >= 3) {
64
- pool.state.brokenUntil.set(targetRef, Date.now() + 86400000 * 30);
65
- pool.state.failedUntil.set(targetRef, Date.now() + 86400000 * 30);
80
+ pool.state.brokenUntil.set(targetRef, now() + 86400000 * 30);
81
+ pool.state.failedUntil.set(targetRef, now() + 86400000 * 30);
66
82
  }
67
83
  } else {
68
84
  pool.state.authFailCounts.delete(targetRef);
69
85
  }
70
86
  };
71
87
 
88
+ // #260: fail fast when provider circuit is open
89
+ if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
90
+ console.warn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
91
+ yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
92
+ return;
93
+ }
94
+
72
95
  for (let attempt = 0; attempt < attemptList.length; attempt++) {
73
96
  let yielded = false;
74
97
  let switching = false;
@@ -108,7 +131,7 @@ export function createRotate(deps) {
108
131
  penalizeRef(activeRef, code ?? 'UNKNOWN', message);
109
132
  pool.state.switches = (pool.state.switches ?? 0) + 1;
110
133
  pool.state.lastReason = String(code ?? 'UNKNOWN');
111
- pool.state.lastSwitchAt = Date.now();
134
+ pool.state.lastSwitchAt = now();
112
135
  lastFailure = chunk;
113
136
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
114
137
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
@@ -157,18 +180,19 @@ export function createRotate(deps) {
157
180
  const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
158
181
  if (rate && activeRef) {
159
182
  if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
160
- const cool = rate.reset && rate.reset > Date.now() ? (rate.reset - Date.now()) : pool.cooldownMs;
161
- recordFailure(pool, activeRef, Date.now(), cool, pool.maxCooldownMs);
183
+ const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
184
+ recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
162
185
  pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
163
186
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
164
187
  }
165
188
  }
166
189
  // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
167
- if (rate && activeRef && Number.isFinite(rate.remaining)) {
168
- quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: Date.now() });
190
+ if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
191
+ quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
169
192
  }
170
193
  yield chunk;
171
194
  recordLatency(pool, reqStore);
195
+ if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
172
196
  return;
173
197
  }
174
198
  yield chunk;
@@ -181,7 +205,7 @@ export function createRotate(deps) {
181
205
  penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
182
206
  pool.state.switches = (pool.state.switches ?? 0) + 1;
183
207
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
184
- pool.state.lastSwitchAt = Date.now();
208
+ pool.state.lastSwitchAt = now();
185
209
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
186
210
  console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
187
211
  if (switchNotify && activeRef) {
@@ -204,7 +228,7 @@ export function createRotate(deps) {
204
228
  }
205
229
 
206
230
  // pool exhausted — all keys cooling or missing
207
- pool.state.lastExhaustionAt = Date.now();
231
+ pool.state.lastExhaustionAt = now();
208
232
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
209
233
  console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
210
234
  const runtime = buildRuntime();
@@ -218,7 +242,7 @@ export function createRotate(deps) {
218
242
  if (fb && fb.pool && fb.pool !== pool) {
219
243
  console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
220
244
  pool.state.lastReason = 'CASCADE';
221
- pool.state.lastSwitchAt = Date.now();
245
+ pool.state.lastSwitchAt = now();
222
246
  // Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
223
247
  const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
224
248
  for await (const chunk of innerCascade) {
package/lib/routes-ops.js CHANGED
@@ -21,6 +21,7 @@ import { bucketInfo } from './bucket.js';
21
21
  import { usageRows, usageCsv } from './usage-report.js';
22
22
  import { findSecrets, looksLikeApiSecret } from './keycheck.js';
23
23
  import { nextQuotaReset } from './quota-window.js';
24
+ import { classifyFailure } from './error-taxonomy.js';
24
25
 
25
26
  const STATUS_PATH = '/dsh-key-rotation/status';
26
27
  const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
@@ -131,6 +132,13 @@ export function registerOpsRoutes(ctx, deps) {
131
132
  provider: pool.base,
132
133
  keys,
133
134
  tags: providerTags.get(pool.base) ?? [],
135
+ // #260 circuit breaker state (may be null if not yet tripped)
136
+ circuit: (() => {
137
+ const br = runtime.breaker;
138
+ if (!br) return null;
139
+ const st = br.state(pool.base);
140
+ return { state: st, threshold: br.threshold, openMs: br.openMs };
141
+ })(),
134
142
  switches: pool.state.switches ?? 0,
135
143
  lastReason: pool.state.lastReason ?? null,
136
144
  lastSwitchAt: pool.state.lastSwitchAt ?? null,
@@ -157,7 +165,16 @@ export function registerOpsRoutes(ctx, deps) {
157
165
  providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
158
166
  }
159
167
  }
160
- json(res, 200, { providers });
168
+ json(res, 200, {
169
+ providers,
170
+ // #266/#263 operational extras (additive)
171
+ meta: {
172
+ expectedClones: [...(runtime.expectedClones ?? [])],
173
+ notifyQueue: runtime.notifyQueue?.stats?.() ?? null,
174
+ breakerEnabled: runtime.circuitBreakerEnabled !== false,
175
+ at: now,
176
+ },
177
+ });
161
178
  },
162
179
  }), 'dsh-key-rotation: status route');
163
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.40",
3
+ "version": "0.8.1",
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": [