@goodandready/dsh-key-rotation 0.7.39 → 0.8.0

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.
@@ -57,7 +68,7 @@ Unlike naive routing proxies that alter provider identifiers, `dsh-key-rotation`
57
68
  * **The provider identity never changes**: Agent replay states, multi-call turns, and tool schemas remain 100% consistent.
58
69
  * **Pre-emptive Token Bucket**: Throttled keys are skipped *before* issuing network calls, eliminating retry latency.
59
70
  * **Least-Connections Concurrency Control**: Balances in-flight streams across keys to prevent burst saturation.
60
- * **Autonomous Self-Healing & Cascades**: Proactively tests quarantined keys via canary probes and smoothly escalates to fallback providers if an entire pool is exhausted.
71
+ * **Autonomous Self-Healing & Cascades**: Lifts expired quarantines on idle keys and smoothly escalates to fallback providers if an entire pool is exhausted.
61
72
 
62
73
  ---
63
74
 
@@ -85,8 +96,8 @@ graph LR
85
96
  Failover -.->|All Pool Keys Exhausted| CascadeEngine["Cross-Provider Cascade"]
86
97
 
87
98
  BackoffCalc --> QuotaWindow["Calendar Reset / Midnight Window"]
88
- BackoffCalc --> CanaryProbe["Active Canary Prober (Sandbox Ping)"]
89
- CanaryProbe -->|Verified Healthy| PoolReady["Restored to Ready Pool"]
99
+ BackoffCalc --> SelfHeal["Self-Heal Idle Sweep"]
100
+ SelfHeal -->|Cooldown Expired| PoolReady["Restored to Ready Pool"]
90
101
  end
91
102
 
92
103
  subgraph UpstreamLayer ["Model Provider Endpoints"]
@@ -117,21 +128,18 @@ graph LR
117
128
 
118
129
  ### 🛡️ 3. Autonomous Healing & Cascade Escalation
119
130
  * **Cross-Provider Failover Cascade (`lib/cascade.js`)**: If all keys for a selected provider are in cooldown, requests automatically cascade to an alternative fallback provider pool (e.g., primary provider → fallback proxy / secondary provider).
120
- * **Active Canary Prober (`lib/canary.js`)**: Before releasing a key from quarantine, a lightweight background probe (`/models` probe or single-token check via `SandboxRunner`) validates upstream availability without exposing real user traffic to risk.
131
+ * **Sandbox Key Probes (`lib/sandbox.js`)**: On-demand `/models` probes validate a key before returning it to rotation; idle cooldowns are lifted by the self-heal sweep.
121
132
  * **Calendar & Rolling Quota Reset Windows (`lib/quota-window.js`)**: Supports scheduled quota reset alignments (`midnight_utc`, `midnight_pst`, and `rolling_24h`) so daily free/tier quotas unfreeze exactly when upstream resets them.
122
133
  * **Adaptive Exponential Backoff (`lib/pool.js`)**: Successive failures on a key double its quarantine duration (base → ×2 → ×4 → cap ×8). Successful requests gradually restore healthy status.
123
134
 
124
- ### 🎯 4. Model-Aware & Geolocation Routing
135
+ ### 🎯 4. Model-Aware Routing
125
136
  * **Model Sub-Pools (`lib/pool.js`)**: Configure dedicated key pools for specific model tiers (e.g. reasoning/heavy models vs fast/cheap utility models).
126
137
  * **Tag-Based Routing**: Assign operational tags (`production`, `background`, `eval`) to match key usage with workload priorities.
127
- * **Region Mapping (`lib/region.js`)**: Route queries through geographically optimal credentials and endpoints.
128
138
 
129
139
  ### 📊 5. Observability, Telemetry & Webhooks
130
140
  * **Interactive Multi-Platform Webhooks (`lib/webhook.js`)**: Dispatches rich notifications with HMAC-signed action buttons for **Telegram** (Inline Keyboards), **Discord** (Action Rows), and **Slack** (Block Kit). Administrators can click buttons to reset cooldowns or pause providers directly from their mobile chat.
131
141
  * **Usage & Cost Reporting (`lib/usage-report.js`)**: Per-key daily request counters and estimated cost breakdown with one-click CSV/JSON export (`GET /dsh-key-rotation/usage-report`).
132
142
  * **Latency SLO & Histogram (`lib/histogram.js`)**: Tracks Time-To-First-Token (TTFT) and stream durations with health score degradation scoring (`0..100`).
133
- * **Automated Incident Reporting (`lib/incident.js`)**: Lazily creates structured GitHub Issues on sustained upstream outages.
134
- * **Shadow Traffic Routing (`lib/shadow.js`)**: Fork a configurable percentage of live requests to evaluate secondary providers in shadow mode.
135
143
 
136
144
  ---
137
145
 
@@ -195,7 +203,11 @@ dsh-key-rotation:
195
203
  - UNKNOWN_MODEL
196
204
  - AUTH
197
205
  cooldownMs: 60000
198
- canaryProbing: true
206
+ # v0.8.0 circuit breaker
207
+ circuitBreakerEnabled: true
208
+ circuitBreakerThreshold: 5
209
+ circuitBreakerOpenMs: 30000
210
+ circuitBreakerHalfOpenProbes: 1
199
211
  concurrencyLimit: 5
200
212
  quotaResetWindow:
201
213
  type: midnight_utc
@@ -224,7 +236,11 @@ dsh-key-rotation:
224
236
  |---|---|---|---|
225
237
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | List of error codes that immediately trigger failover. |
226
238
  | `cooldownMs` | `number` | `60000` (1 min) | Base penalty duration (in ms) for quarantined keys. |
227
- | `canaryProbing` | `boolean` | `true` | Runs background ping probe before restoring 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). |
228
244
  | `concurrencyLimit` | `number` | `0` (disabled) | Max concurrent in-flight streams per key (0 = unlimited). |
229
245
  | `quotaResetWindow` | `object` | `null` | Calendar reset alignment (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
230
246
  | `cascade` | `array` | `[]` | Fallback provider chain when primary pool is completely exhausted. |
@@ -239,7 +255,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
239
255
 
240
256
  | Route | Method | Description |
241
257
  |---|---|---|
242
- | `/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`). |
243
259
  | `/dsh-key-rotation/config` | `GET` / `PUT` | Read and update active key rotation settings and provider pools. |
244
260
  | `/dsh-key-rotation/key` | `PUT` / `DELETE` | Add, update, or remove credentials in host storage and pool. |
245
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.
@@ -57,7 +68,7 @@
57
68
  * **Идентичность провайдера остаётся неизменной**: Внутреннее Replay-состояние агента `pi-ai` и контекст инструментов остаются на 100% консистентными.
58
69
  * **Предиктивный Token Bucket**: Перегруженные ключи пропускаются **до** выполнения сетевого запроса, устраняя задержку на сетевой ретрай.
59
70
  * **Балансировка Least-Connections**: Запросы равномерно распределяются по свободным ключам с контролем параллелизма (`maxConcurrency`).
60
- * **Автономное самовосстановление и каскад**: Фоновые canary-зонды проверяют заблокированные ключи, а при полном исчерпании пула запрос бесшовно передаётся запасному провайдеру.
71
+ * **Автономное самовосстановление и каскад**: Просроченные кулдауны простаивающих ключей снимаются автоматически, а при полном исчерпании пула запрос бесшовно передаётся запасному провайдеру.
61
72
 
62
73
  ---
63
74
 
@@ -85,8 +96,8 @@ graph LR
85
96
  Failover -.->|Все ключи в кулдауне| CascadeEngine["Межпровайдерный каскад"]
86
97
 
87
98
  BackoffCalc --> QuotaWindow["Календарный сброс / Полночь UTC/PST"]
88
- BackoffCalc --> CanaryProbe["Active Canary-зонд (Sandbox Ping)"]
89
- CanaryProbe -->|Ключ работоспособен| PoolReady["Возврат в пул готовых ключей"]
99
+ BackoffCalc --> SelfHeal["Self-Heal sweep простаивания"]
100
+ SelfHeal -->|Кулдаун истёк| PoolReady["Возврат в пул готовых ключей"]
90
101
  end
91
102
 
92
103
  subgraph UpstreamLayer ["Эндпоинты провайдеров"]
@@ -117,21 +128,18 @@ graph LR
117
128
 
118
129
  ### 🛡️ 3. Автономное самовосстановление и каскадный Failover
119
130
  * **Межпровайдерный каскад (`lib/cascade.js`)**: При исчерпании всех ключей выбранного провайдера запрос автоматически каскадируется на настроенного резервного провайдера (`cascade: [{ provider, model }]`).
120
- * **Active Canary Prober (`lib/canary.js`)**: Перед выводом ключа из кулдауна плагин выполняет легкий фоновый зонд (`/models` probe через `SandboxRunner`), защищая боевой трафик от повторных сбоев.
131
+ * **Sandbox-пробы ключей (`lib/sandbox.js`)**: По запросу выполняется `/models`-проба ключа перед возвратом в ротацию; простаивающие кулдауны снимаются self-heal sweep.
121
132
  * **Календарный сброс квот (`lib/quota-window.js`)**: Учитывает окна сброса суточных квот провайдеров (`midnight_utc`, `midnight_pst`, `rolling_24h`), снимая карантин ровно в момент обновления лимитов у апстрима.
122
133
  * **Экспоненциальный бэкофф (`lib/pool.js`)**: Повторные сбои на ключе прогрессивно увеличивают время кулдауна (базовое → ×2 → ×4 → максимум ×8).
123
134
 
124
- ### 🎯 4. Маршрутизация по моделям и гео-регионам
135
+ ### 🎯 4. Маршрутизация по моделям
125
136
  * **Модельные подпулы (`lib/pool.js`)**: Назначение выделенных ключей под конкретные модели (например, отдельные ключи для тяжелых reasoning-моделей и дешевые ключи для утилит).
126
137
  * **Тегирование ключей**: Метки приоритета (`production`, `background`, `eval`) для разделения квот между интерактивными и фоновыми задачами.
127
- * **Гео-роутинг (`lib/region.js`)**: Маршрутизация запросов через оптимальные региональные эндпоинты.
128
138
 
129
139
  ### 📊 5. Телеметрия, аналитика и интерактивные вебхуки
130
140
  * **Интерактивные вебхуки (`lib/webhook.js`)**: Отправка форматированных алертов с кнопками действий в **Telegram** (Inline Keyboards), **Discord** (Action Rows) и **Slack** (Block Kit). Администратор может сбросить кулдаун или отключить провайдер прямо из мессенджера.
131
141
  * **Отчеты об использовании и расходах (`lib/usage-report.js`)**: Учет суточного числа запросов и расчетной стоимости по каждому ключу с экспортом в CSV/JSON (`GET /dsh-key-rotation/usage-report`).
132
142
  * **Гистограмма задержек SLO (`lib/histogram.js`)**: Измерение времени до первого токена (TTFT) и расчет индекса здоровья пула (`0..100`).
133
- * **Автоматические инциденты (`lib/incident.js`)**: Создание issue в GitHub при масштабных системных сбоях провайдеров.
134
- * **Shadow-трафик (`lib/shadow.js`)**: Теневое дублирование процента запросов для тестирования альтернативных провайдеров.
135
143
 
136
144
  ---
137
145
 
@@ -195,7 +203,10 @@ dsh-key-rotation:
195
203
  - UNKNOWN_MODEL
196
204
  - AUTH
197
205
  cooldownMs: 60000
198
- canaryProbing: true
206
+ circuitBreakerEnabled: true
207
+ circuitBreakerThreshold: 5
208
+ circuitBreakerOpenMs: 30000
209
+ circuitBreakerHalfOpenProbes: 1
199
210
  concurrencyLimit: 5
200
211
  quotaResetWindow:
201
212
  type: midnight_utc
@@ -224,7 +235,6 @@ dsh-key-rotation:
224
235
  |---|---|---|---|
225
236
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | Список кодов ошибок, инициирующих немедленный переход на следующий ключ. |
226
237
  | `cooldownMs` | `number` | `60000` (1 мин) | Базовая длительность нахождения ключа в карантине (в мс). |
227
- | `canaryProbing` | `boolean` | `true` | Фоновая проверка ключа canary-зондом перед возвратом из карантина. |
228
238
  | `concurrencyLimit` | `number` | `0` (отключено) | Лимит одновременных активных запросов на ключ (0 = без ограничений). |
229
239
  | `quotaResetWindow` | `object` | `null` | Календарное расписание сброса квот (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
230
240
  | `cascade` | `array` | `[]` | Цепочка резервных провайдеров при исчерпании всех ключей основного пула. |
@@ -287,3 +297,16 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
287
297
  - **Архитектура настроек**: Добавлена нативная интеграция со снимками `settingsScope` в карточке настроек с безопасным фоллбеком на HTTP-мост (#235).
288
298
  - **Локализация**: Фоллбек секции настроек `settings.section` переведён на локализованную метку `t('title')` со слотом `locale: NS`, а китайский словарь `zh` зарегистрирован в `ctx.locale` наряду с `en` и `ru` (#236).
289
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
  - **🛡️ 防御级联无限递归**:在跨提供商故障转移中增加递归深度防护,彻底杜绝循环级联导致的堆栈溢出。
@@ -117,7 +128,7 @@ graph LR
117
128
 
118
129
  ### 🛡️ 3. 自动愈合与跨提供商级联
119
130
  * **跨提供商故障转移级联 (`lib/cascade.js`)**:主提供商密钥全部冷却时,自动级联路由到备用提供商池。
120
- * **金丝雀探针探活 (`lib/canary.js`)**:密钥出冷却期前,自动发起轻量探测验证上游可用性,避免影响用户真实请求。
131
+ * **沙箱密钥探测 (`lib/sandbox.js`)**:按需对密钥执行 `/models` 探测后再回到轮换;空闲冷却由 self-heal sweep 解除。
121
132
  * **配额日历重置对齐 (`lib/quota-window.js`)**:支持 `midnight_utc`、`midnight_pst` 与 `rolling_24h` 配额刷新窗口。
122
133
  * **自适应指数退避 (`lib/pool.js`)**:连续失败使冷却时间呈指数递增(基准 → ×2 → ×4 → 上限 ×8)。
123
134
 
@@ -125,7 +136,6 @@ graph LR
125
136
  * **交互式 Webhook (`lib/webhook.js`)**:向 **Telegram**、**Discord**、**Slack** 推送带交互按钮的富文本警报,可在移动聊天中一键重置冷却或暂停提供商。
126
137
  * **使用量与成本报表 (`lib/usage-report.js`)**:按日统计各密钥请求数与预估成本,支持一键导出 CSV/JSON (`GET /dsh-key-rotation/usage-report`)。
127
138
  * **延迟 SLO 监控 (`lib/histogram.js`)**:记录首字延迟(TTFT)与健康度评分 (`0..100`)。
128
- * **影子流量测试 (`lib/shadow.js`)**:支持配置百分比的流量镜像复制以评估次要提供商。
129
139
 
130
140
  ---
131
141
 
@@ -185,7 +195,10 @@ dsh-key-rotation:
185
195
  - UNKNOWN_MODEL
186
196
  - AUTH
187
197
  cooldownMs: 60000
188
- canaryProbing: true
198
+ circuitBreakerEnabled: true
199
+ circuitBreakerThreshold: 5
200
+ circuitBreakerOpenMs: 30000
201
+ circuitBreakerHalfOpenProbes: 1
189
202
  concurrencyLimit: 5
190
203
  quotaResetWindow:
191
204
  type: midnight_utc
@@ -248,3 +261,13 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
248
261
  - **设置架构与状态**: 在设置卡片中增加原生 `settingsScope` 绑定支持,保留 HTTP 桥接安全回退机制 (#235)。
249
262
  - **本地化与文案**: 侧边栏备用项 `settings.section` 标签支持本地化 `t('title')` 并配置 `locale: NS`,并在 `ctx.locale` 中注册 `zh` 中文字典 (#236)。
250
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/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
+ }