@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 +10 -0
- package/lib/index.js +4 -8
- package/lib/ops-keys.js +5 -2
- package/lib/pool.js +3 -1
- package/lib/quota-window.js +4 -3
- package/lib/rotate.js +27 -0
- package/package.json +1 -1
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
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
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) {
|
|
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
|
|
package/lib/quota-window.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
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.
|
|
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": [
|