@goodandready/dsh-key-rotation 0.8.17 → 0.8.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.18 - 2026-09-21
7
+
8
+ ### Fixed
9
+ - **Stream success failCount reset (#336)**: `rotate()` now invokes `recordSuccess(pool, activeRef, now())` upon successful stream completion and clean EOF, clearing accumulated `failCounts`, removing stale `failedUntil`, and updating `lastSuccessAt`.
10
+ - **Calendar-based quota reset connection (#337)**: connected `quota-window.js` (`nextQuotaReset`) to `penalizeRef` in `lib/rotate.js` when `QUOTA` / `RESOURCE_EXHAUSTED` / quota errors occur and `quotaResetWindow` is configured. Ensures exhausted keys remain penalized until calendar reset window instead of rapidly failing over back into penalty. Enhanced `nextQuotaReset` to safely handle both object and string window configurations.
11
+ - **Penalty decay during maintenance sweeps (#338)**: `sweepExpired` in `lib/pool.js` now invokes `decayPenalties({ state: st }, now)`, allowing keys with historic transient failures that have run stably to gradually recover their base backoff levels. Also cleans up expired `quotaWindows`. Removed redundant duplicate check in `isSwitchableError`.
12
+ - **State persistence during failover (#339)**: `schedulePersist()` is now passed into `createRotate()` and invoked whenever a key failover occurs, ensuring key penalty and switch state is promptly written to disk across restarts.
13
+ - **Dead imports cleanup (#340)**: removed 16 unused named imports from `lib/index.js` (`pickCascadeFallback`, `nextQuotaReset`, `usageRows`, `usageCsv`, `findSecrets`, `readJson`, `writeSection`, etc.) ensuring lean bundle evaluation.
14
+ - **Remote configuration import timeout (#341)**: added 10-second timeout via `AbortSignal.timeout(10000)` to remote `fetch(url)` in `/dsh-key-rotation/import` route (`lib/ops-keys.js`), returning HTTP 504 Gateway Timeout with structured error code `'timeout'` on network stalls.
15
+
6
16
  ## 0.8.17 - 2026-09-21
7
17
 
8
18
  ### Fixed
package/lib/index.js CHANGED
@@ -15,22 +15,17 @@ import {
15
15
  budgetVerdict, sortAttemptList,
16
16
  } from './pool.js';
17
17
  import { LatencyHistogram } from './histogram.js';
18
- import { pickCascadeFallback } from './cascade.js';
19
18
  import { ConcurrencyTracker } from './concurrency.js';
20
- import { nextQuotaReset } from './quota-window.js';
21
19
  import { QuotaStore } from './quota.js';
22
20
  import { WebhookSender } from './webhook.js';
23
- import { bucketAllow, bucketRetryMs, bucketSweep, bucketInfo } from './bucket.js';
24
- import { usageRows, usageCsv, compactUsage } from './usage-report.js';
25
- import { findSecrets, looksLikeApiSecret } from './keycheck.js';
26
- import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
21
+ import { bucketAllow, bucketRetryMs } from './bucket.js';
22
+ import { compactUsage } from './usage-report.js';
23
+ import { json, handleConfigBridge } from './http-bridge.js';
27
24
  import { createRotate } from './rotate.js';
28
25
  import { nowMono } from './clock.js';
29
26
  import { CircuitBreaker } from './circuit-breaker.js';
30
27
  import { NotifyQueue } from './notify-queue.js';
31
- import { BoundedMap } from './bounded-map.js';
32
28
  import { classifyFailure } from './error-taxonomy.js';
33
- import { safeParseJson } from './atomic-io.js';
34
29
  import { registerOpsRoutes } from './routes-ops.js';
35
30
  import { registerPluginUpdater } from './plugin-updater.js';
36
31
  import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
@@ -472,6 +467,7 @@ export function apply(ctx, config = {}) {
472
467
  ctx,
473
468
  dispatchStorage,
474
469
  buildRuntime,
470
+ schedulePersist,
475
471
  pushEvent,
476
472
  notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
477
473
  notifyExhaustion,
package/lib/ops-keys.js CHANGED
@@ -167,7 +167,7 @@ ctx.effect(() => ctx.webServer.register({
167
167
  const url = typeof body?.url === 'string' ? body.url.trim() : '';
168
168
  if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
169
169
  try {
170
- const resp = await fetch(url);
170
+ const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
171
171
  if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
172
172
  const data = await resp.json();
173
173
  if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
@@ -181,7 +181,10 @@ ctx.effect(() => ctx.webServer.register({
181
181
  const mergedArr = [...merged.values()];
182
182
  await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
183
183
  json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
184
- } catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
184
+ } catch (e) {
185
+ const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
186
+ json(res, isTimeout ? 504 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
187
+ }
185
188
  },
186
189
  }), 'dsh-key-rotation: import route');
187
190
 
package/lib/pool.js CHANGED
@@ -185,10 +185,13 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
185
185
  let cleared = 0;
186
186
  const activeSet = activeRefs ? new Set(activeRefs) : null;
187
187
  for (const st of poolState.values()) {
188
+ // #338: decay penalty failCounts for stable keys in maintenance sweep
189
+ decayPenalties({ state: st }, now);
188
190
  for (const [ref, until] of [...st.failedUntil.entries()]) {
189
191
  if (until <= now) {
190
192
  st.failedUntil.delete(ref);
191
193
  st.failCounts?.delete(ref);
194
+ st.quotaWindows?.delete(ref);
192
195
  cleared++;
193
196
  }
194
197
  }
@@ -372,7 +375,6 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
372
375
 
373
376
  // 1. Direct HTTP status codes (#267: also 408/425 transient)
374
377
  if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
375
- if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
376
378
  if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
377
379
  if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
378
380
 
@@ -4,9 +4,10 @@ export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'
4
4
 
5
5
  export function nextQuotaReset(quotaResetWindow, now) {
6
6
  now = now || Date.now();
7
- if (!quotaResetWindow || typeof quotaResetWindow !== 'object') return null;
8
- const type = quotaResetWindow.type;
9
- const hour = Number.isFinite(quotaResetWindow.hour) ? quotaResetWindow.hour : 0;
7
+ const win = typeof quotaResetWindow === 'string' ? { type: quotaResetWindow === 'daily' ? 'midnight_utc' : quotaResetWindow } : quotaResetWindow;
8
+ if (!win || typeof win !== 'object') return null;
9
+ const type = win.type === 'daily' ? 'midnight_utc' : win.type;
10
+ const hour = Number.isFinite(win.hour) ? win.hour : 0;
10
11
 
11
12
  if (type === 'rolling_24h') {
12
13
  const d = new Date(now);
package/lib/rotate.js CHANGED
@@ -5,6 +5,7 @@ import { classifyFailure } from './error-taxonomy.js';
5
5
  import {
6
6
  isSwitchableError,
7
7
  recordFailure,
8
+ recordSuccess,
8
9
  parseRetryAfter,
9
10
  extractRateLimit,
10
11
  isRateLimited,
@@ -12,6 +13,7 @@ import {
12
13
  sortAttemptList,
13
14
  } from './pool.js';
14
15
  import { pickCascadeFallback } from './cascade.js';
16
+ import { nextQuotaReset } from './quota-window.js';
15
17
 
16
18
  /**
17
19
  * Create the rotate(options, pool) async generator used by llm/stream.
@@ -34,6 +36,7 @@ export function createRotate(deps) {
34
36
  quotaStore,
35
37
  circuitBreaker,
36
38
  logger,
39
+ schedulePersist,
37
40
  now = nowMono,
38
41
  } = deps;
39
42
 
@@ -77,8 +80,24 @@ export function createRotate(deps) {
77
80
  const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
78
81
  const cls = classifyFailure({ code: errCode, message: errMsg });
79
82
  const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
83
+
84
+ // #337: connect quota-window calendar reset for quota exhaustion
85
+ const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' || /quota|exceeded/i.test(errMsg);
86
+ const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
87
+ if (isQuota && qWindow) {
88
+ const quotaResetAt = nextQuotaReset(qWindow, now());
89
+ if (quotaResetAt && quotaResetAt > now()) {
90
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
91
+ pool.state.quotaWindows.set(targetRef, quotaResetAt);
92
+ pool.state.failedUntil.set(targetRef, quotaResetAt);
93
+ }
94
+ }
95
+
80
96
  if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
81
97
  pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
98
+ if (typeof schedulePersist === 'function') {
99
+ try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
100
+ }
82
101
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
83
102
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
84
103
  const _cStr = String(errCode ?? '');
@@ -190,6 +209,11 @@ export function createRotate(deps) {
190
209
  if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
191
210
  byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
192
211
  }
212
+ // #336: record success for the active key upon successful stream finish
213
+ if (activeRef) {
214
+ recordSuccess(pool, activeRef, now());
215
+ }
216
+
193
217
  // Proactive rate-limit (#115, #303): if response headers say this key is near
194
218
  // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
195
219
  // We do NOT re-run this (already successful) request — that would double-send.
@@ -248,6 +272,9 @@ export function createRotate(deps) {
248
272
  }
249
273
 
250
274
  if (switching) continue; // try the next key
275
+ if (_pickedRef && yielded) {
276
+ recordSuccess(pool, _pickedRef, now());
277
+ }
251
278
  return; // clean end — served
252
279
  } finally {
253
280
  if (acquired && _pickedRef) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.17",
3
+ "version": "0.8.18",
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": [