@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 +22 -1
- package/README.ru.md +28 -0
- package/README.zh.md +25 -0
- package/lib/atomic-io.js +60 -0
- package/lib/bounded-map.js +65 -0
- package/lib/circuit-breaker.js +93 -0
- package/lib/client.js +53 -4
- package/lib/clock.js +24 -0
- package/lib/error-taxonomy.js +66 -0
- package/lib/index.js +60 -7
- package/lib/notify-queue.js +75 -0
- package/lib/pool.js +2 -1
- package/lib/rotate.js +38 -14
- package/lib/routes-ops.js +18 -1
- package/package.json +1 -1
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` |
|
|
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` | 半开探测次数 |
|
package/lib/atomic-io.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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 >
|
|
49
|
+
if (fu > now()) return false;
|
|
36
50
|
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
37
|
-
if (exp !== undefined &&
|
|
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
|
|
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,
|
|
65
|
-
pool.state.failedUntil.set(targetRef,
|
|
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 =
|
|
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 >
|
|
161
|
-
recordFailure(pool, activeRef,
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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.
|
|
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": [
|