@goodandready/dsh-key-rotation 0.8.17 → 0.8.19
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 +19 -0
- package/lib/circuit-breaker.js +53 -12
- package/lib/client.js +11 -4
- package/lib/heal.js +12 -1
- package/lib/index.js +13 -26
- package/lib/lifecycle.js +19 -6
- package/lib/notify-events.js +2 -0
- package/lib/ops-keys.js +5 -2
- package/lib/persistence.js +16 -12
- package/lib/pool-builder.js +2 -1
- package/lib/pool-state.js +24 -0
- package/lib/pool.js +6 -2
- package/lib/quota-window.js +4 -3
- package/lib/rotate.js +195 -144
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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.19 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Restored pool state hydration & telemetry preservation (GitHub PR #14)**: extracted `initializePoolState()` and `hasPoolActivity()` into `lib/pool-state.js`. `StatePersistence.restorePools()` now hydrates existing runtime pools in-place, ensuring transient fields like `events` array are never undefined. `pushEvent()` defensively guards `events` array so telemetry errors cannot throw `TypeError` and mask underlying provider errors as `TRANSPORT`.
|
|
10
|
+
- **Circuit breaker probe lifecycle & stream accounting (GitHub PR #15)**: added once-settled request permits with generation tokens in `CircuitBreaker`. Correctly counts the first half-open probe, checks admission before each key attempt, prevents late failures while open from extending cooldowns, and distinguishes error/aborted terminals from successes in `rotate()`.
|
|
11
|
+
- **Accurate quota failure classification & backoff overflow saturation (GitHub PR #16)**: narrowed `penalizeRef()` quota detection regex in `lib/rotate.js` to explicit quota-exhaustion phrases (`insufficient_quota`, `quota exhausted`, `You exceeded your current quota`), preventing ordinary rate limits (e.g. `rate limit exceeded`, `requests per minute exceeded`, `deadline exceeded`) from being held until midnight UTC. Replaced 32-bit signed integer shifts in `computeBackoff()` with `Math.pow(2, failCount - 1)`, preventing negative delay wrap-around on 32+ failures.
|
|
12
|
+
- **Quarantined key auto-unbreak isolation (GitHub PR #17)**: in `setupAutoUnbreakEffect()`, quarantined keys are now probed using the un-wrapped original credentials resolver handle, ensuring the exact broken key is tested rather than a healthy neighbor chosen by the rotation wrapper. Added concurrency lock and state-observation checks to prevent stale probes from clearing newer failures.
|
|
13
|
+
- **Client error message visibility & empty ref validation (GitHub Issue #13)**: `validateBeforeSave()` in `lib/client.js` now normalizes and extracts `vres.message ?? vres.error?.message` from pre-save validation failures, displaying the server-provided reason instead of the generic "validation failed" toast. `buildPoolItem()` in `lib/pool-builder.js` filters out empty, whitespace-only, and invalid ref names using `isValidRef()`.
|
|
14
|
+
|
|
15
|
+
## 0.8.18 - 2026-09-21
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **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`.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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`.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
6
25
|
## 0.8.17 - 2026-09-21
|
|
7
26
|
|
|
8
27
|
### Fixed
|
package/lib/circuit-breaker.js
CHANGED
|
@@ -11,18 +11,24 @@ export class CircuitBreaker {
|
|
|
11
11
|
* @param {{ threshold?: number, openMs?: number, halfOpenProbes?: number, now?: () => number }} opts
|
|
12
12
|
*/
|
|
13
13
|
constructor({ threshold = 5, openMs = 30_000, halfOpenProbes = 1, now = Date.now } = {}) {
|
|
14
|
-
this.threshold
|
|
15
|
-
this.openMs = Math.max(100, openMs | 0);
|
|
16
|
-
this.halfOpenProbes = Math.max(1, halfOpenProbes | 0);
|
|
14
|
+
this.configure({ threshold, openMs, halfOpenProbes });
|
|
17
15
|
this._now = now;
|
|
18
|
-
/** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number}>} */
|
|
16
|
+
/** @type {Map<string, {state:string, fails:number, openedAt:number, probes:number, generation:number}>} */
|
|
19
17
|
this._st = new Map();
|
|
20
18
|
}
|
|
21
19
|
|
|
20
|
+
configure({ threshold = 5, openMs = 30_000, halfOpenProbes = 1 } = {}) {
|
|
21
|
+
const integer = (value, fallback, min) => Number.isFinite(value)
|
|
22
|
+
? Math.max(min, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(value))) : fallback;
|
|
23
|
+
this.threshold = integer(threshold, 5, 1);
|
|
24
|
+
this.openMs = integer(openMs, 30_000, 100);
|
|
25
|
+
this.halfOpenProbes = integer(halfOpenProbes, 1, 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
_entry(provider) {
|
|
23
29
|
let e = this._st.get(provider);
|
|
24
30
|
if (!e) {
|
|
25
|
-
e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0 };
|
|
31
|
+
e = { state: BREAKER_CLOSED, fails: 0, openedAt: 0, probes: 0, generation: 0 };
|
|
26
32
|
this._st.set(provider, e);
|
|
27
33
|
}
|
|
28
34
|
return e;
|
|
@@ -36,9 +42,10 @@ export class CircuitBreaker {
|
|
|
36
42
|
if (now - e.openedAt >= this.openMs) {
|
|
37
43
|
e.state = BREAKER_HALF_OPEN;
|
|
38
44
|
e.probes = 0;
|
|
39
|
-
|
|
45
|
+
e.generation += 1;
|
|
46
|
+
} else {
|
|
47
|
+
return false;
|
|
40
48
|
}
|
|
41
|
-
return false;
|
|
42
49
|
}
|
|
43
50
|
if (e.state === BREAKER_HALF_OPEN) {
|
|
44
51
|
if (e.probes < this.halfOpenProbes) {
|
|
@@ -50,26 +57,58 @@ export class CircuitBreaker {
|
|
|
50
57
|
return true;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
// A permit is settled exactly once. Late outcomes from an older circuit
|
|
61
|
+
// generation (or a removed provider) cannot close/reopen a newer circuit.
|
|
62
|
+
acquire(provider) {
|
|
63
|
+
if (!this.canRequest(provider)) return null;
|
|
64
|
+
const entry = this._entry(provider);
|
|
65
|
+
const generation = entry.generation;
|
|
66
|
+
const probing = entry.state === BREAKER_HALF_OPEN;
|
|
67
|
+
let settled = false;
|
|
68
|
+
const settle = (fn) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
if (this._st.get(provider) === entry && entry.generation === generation) fn();
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
success: () => settle(() => this.onSuccess(provider)),
|
|
75
|
+
failure: () => settle(() => this.onFailure(provider)),
|
|
76
|
+
release: () => settle(() => {
|
|
77
|
+
if (probing && entry.state === BREAKER_HALF_OPEN) {
|
|
78
|
+
entry.probes = Math.max(0, entry.probes - 1);
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
onSuccess(provider) {
|
|
54
85
|
const e = this._entry(provider);
|
|
86
|
+
if (e.state !== BREAKER_CLOSED) e.generation += 1;
|
|
55
87
|
e.fails = 0;
|
|
56
88
|
e.probes = 0;
|
|
89
|
+
e.openedAt = 0;
|
|
57
90
|
e.state = BREAKER_CLOSED;
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
onFailure(provider) {
|
|
61
94
|
const e = this._entry(provider);
|
|
62
95
|
const now = this._now();
|
|
96
|
+
// In-flight failures arriving after opening must not extend the cooldown.
|
|
97
|
+
if (e.state === BREAKER_OPEN) return e.state;
|
|
63
98
|
if (e.state === BREAKER_HALF_OPEN) {
|
|
64
99
|
e.state = BREAKER_OPEN;
|
|
65
100
|
e.openedAt = now;
|
|
66
101
|
e.fails = this.threshold;
|
|
102
|
+
e.probes = 0;
|
|
103
|
+
e.generation += 1;
|
|
67
104
|
return e.state;
|
|
68
105
|
}
|
|
69
106
|
e.fails += 1;
|
|
70
107
|
if (e.fails >= this.threshold) {
|
|
71
108
|
e.state = BREAKER_OPEN;
|
|
72
109
|
e.openedAt = now;
|
|
110
|
+
e.probes = 0;
|
|
111
|
+
e.generation += 1;
|
|
73
112
|
}
|
|
74
113
|
return e.state;
|
|
75
114
|
}
|
|
@@ -81,7 +120,7 @@ export class CircuitBreaker {
|
|
|
81
120
|
snapshot() {
|
|
82
121
|
const out = {};
|
|
83
122
|
for (const [k, e] of this._st) {
|
|
84
|
-
out[k] = { state: e.state, fails: e.fails, openedAt: e.
|
|
123
|
+
out[k] = { state: e.state, fails: e.fails, openedAt: e.state === BREAKER_CLOSED ? null : e.openedAt };
|
|
85
124
|
}
|
|
86
125
|
return out;
|
|
87
126
|
}
|
|
@@ -92,20 +131,22 @@ export class CircuitBreaker {
|
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
/** Restore entries from a persistence snapshot (#287). */
|
|
95
|
-
restore(snapshot) {
|
|
96
|
-
if (!snapshot || typeof snapshot !== 'object') return 0;
|
|
134
|
+
restore(snapshot, { preserveExisting = false } = {}) {
|
|
135
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return 0;
|
|
97
136
|
let n = 0;
|
|
98
137
|
for (const [provider, e] of Object.entries(snapshot)) {
|
|
99
|
-
if (!e || typeof e !== 'object') continue;
|
|
138
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
|
|
139
|
+
if (preserveExisting && this._st.has(provider)) continue;
|
|
100
140
|
const state = e.state === BREAKER_OPEN || e.state === BREAKER_HALF_OPEN ? e.state : BREAKER_CLOSED;
|
|
101
141
|
this._st.set(provider, {
|
|
102
142
|
state,
|
|
103
143
|
fails: Number.isFinite(e.fails) ? e.fails : 0,
|
|
104
144
|
openedAt: Number.isFinite(e.openedAt) ? e.openedAt : 0,
|
|
105
145
|
probes: 0,
|
|
146
|
+
generation: 0,
|
|
106
147
|
});
|
|
107
148
|
n += 1;
|
|
108
149
|
}
|
|
109
150
|
return n;
|
|
110
151
|
}
|
|
111
|
-
}
|
|
152
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -947,9 +947,15 @@ window.__ModuleLoader__.load({
|
|
|
947
947
|
headers: { 'content-type': 'application/json' },
|
|
948
948
|
body: JSON.stringify({ ref, value }),
|
|
949
949
|
})
|
|
950
|
-
.then((r) =>
|
|
951
|
-
|
|
952
|
-
|
|
950
|
+
.then(async (r) => {
|
|
951
|
+
const data = (await r.json().catch(() => null)) ?? {};
|
|
952
|
+
const resData = !r.ok && data.error
|
|
953
|
+
? { ok: false, code: data.error.code, message: data.error.message }
|
|
954
|
+
: { ok: r.ok, ...data };
|
|
955
|
+
setValidationResult((m) => ({ ...m, [ref]: resData }));
|
|
956
|
+
return resData;
|
|
957
|
+
})
|
|
958
|
+
.catch((e) => ({ ok: false, message: String(e?.message ?? e) }))
|
|
953
959
|
.finally(() => setValidating(''));
|
|
954
960
|
};
|
|
955
961
|
const saveSecret = async (ref, rowKey) => {
|
|
@@ -963,7 +969,8 @@ window.__ModuleLoader__.load({
|
|
|
963
969
|
if (vres && vres.ok === false && vres.code === 'no-credential') {
|
|
964
970
|
// No credential yet is fine for a new key being saved
|
|
965
971
|
} else if (vres && !vres.ok) {
|
|
966
|
-
|
|
972
|
+
const msg = vres.message ?? vres.error?.message ?? 'validation failed';
|
|
973
|
+
setSecretError(t('keyWriteFailed').replace('{msg}', msg));
|
|
967
974
|
return;
|
|
968
975
|
}
|
|
969
976
|
fetch('/dsh-key-rotation/key', {
|
package/lib/heal.js
CHANGED
|
@@ -42,9 +42,10 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
|
42
42
|
* @param {Array} pools
|
|
43
43
|
* @param {Function} probeFn async (ref) => { ok: boolean, code?: string, status?: number }
|
|
44
44
|
* @param {number} [now=Date.now()]
|
|
45
|
+
* @param {{ isActive?: () => boolean }} [options] lifecycle guard for delayed results
|
|
45
46
|
* @returns {Promise<Array<{ ref: string, poolBase: string, ok: boolean }>>}
|
|
46
47
|
*/
|
|
47
|
-
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
48
|
+
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now(), { isActive = () => true } = {}) {
|
|
48
49
|
if (!Array.isArray(pools) || pools.length === 0 || typeof probeFn !== 'function') return [];
|
|
49
50
|
const results = [];
|
|
50
51
|
for (const pool of pools) {
|
|
@@ -58,8 +59,18 @@ export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
for (const ref of brokenRefs) {
|
|
62
|
+
if (!isActive()) return results;
|
|
63
|
+
const state = pool.state;
|
|
64
|
+
const fields = ['brokenUntil', 'failedUntil', 'failCounts', 'authFailCounts'];
|
|
65
|
+
const observed = fields.map(field => [field, state[field], state[field]?.get(ref)]);
|
|
66
|
+
// An earlier await may have removed this key from the captured worklist.
|
|
67
|
+
if (state.brokenUntil !== bu || !bu.has(ref)) continue;
|
|
61
68
|
try {
|
|
62
69
|
const res = await probeFn(ref);
|
|
70
|
+
if (!isActive()) return results;
|
|
71
|
+
// Do not let a late success erase a newer failure or replaced state.
|
|
72
|
+
if (pool.state !== state || observed.some(([field, map, value]) =>
|
|
73
|
+
state[field] !== map || map?.get(ref) !== value)) continue;
|
|
63
74
|
if (res && res.ok) {
|
|
64
75
|
bu.delete(ref);
|
|
65
76
|
pool.state.failedUntil?.delete(ref);
|
package/lib/index.js
CHANGED
|
@@ -15,22 +15,18 @@ 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';
|
|
25
|
+
import { initializePoolState } from './pool-state.js';
|
|
28
26
|
import { nowMono } from './clock.js';
|
|
29
27
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
30
28
|
import { NotifyQueue } from './notify-queue.js';
|
|
31
|
-
import { BoundedMap } from './bounded-map.js';
|
|
32
29
|
import { classifyFailure } from './error-taxonomy.js';
|
|
33
|
-
import { safeParseJson } from './atomic-io.js';
|
|
34
30
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
35
31
|
import { registerPluginUpdater } from './plugin-updater.js';
|
|
36
32
|
import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
@@ -224,6 +220,11 @@ export function apply(ctx, config = {}) {
|
|
|
224
220
|
lastConfigRef = cfg;
|
|
225
221
|
lastProfilesRef = profilesSnapshot;
|
|
226
222
|
|
|
223
|
+
moduleBreaker.configure({
|
|
224
|
+
threshold: cfg.circuitBreakerThreshold ?? 5,
|
|
225
|
+
openMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
226
|
+
halfOpenProbes: cfg.circuitBreakerHalfOpenProbes ?? 1,
|
|
227
|
+
});
|
|
227
228
|
const switchCodes = cfg.switchCodes ?? DEFAULT_SWITCH_CODES;
|
|
228
229
|
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
229
230
|
const maxCooldownMs = cfg.maxCooldownMs;
|
|
@@ -244,25 +245,10 @@ export function apply(ctx, config = {}) {
|
|
|
244
245
|
const makeState = (base) => {
|
|
245
246
|
let st = poolState.get(base);
|
|
246
247
|
if (!st) {
|
|
247
|
-
st =
|
|
248
|
-
failedUntil: new Map(),
|
|
249
|
-
failCounts: new Map(),
|
|
250
|
-
authFailCounts: new Map(),
|
|
251
|
-
brokenUntil: new Map(),
|
|
252
|
-
costPerKey: new Map(),
|
|
253
|
-
lastUsedAt: new Map(),
|
|
254
|
-
usageCounts: new Map(),
|
|
255
|
-
byModel: new Map(),
|
|
256
|
-
usageDays: new Map(),
|
|
257
|
-
quotaWindows: new Map(),
|
|
258
|
-
costDays: new Map(),
|
|
259
|
-
events: [],
|
|
260
|
-
pointer: 0,
|
|
261
|
-
lastUsed: null,
|
|
262
|
-
};
|
|
248
|
+
st = initializePoolState();
|
|
263
249
|
poolState.set(base, st);
|
|
264
250
|
}
|
|
265
|
-
return st;
|
|
251
|
+
return initializePoolState(st);
|
|
266
252
|
};
|
|
267
253
|
|
|
268
254
|
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
|
|
@@ -472,6 +458,7 @@ export function apply(ctx, config = {}) {
|
|
|
472
458
|
ctx,
|
|
473
459
|
dispatchStorage,
|
|
474
460
|
buildRuntime,
|
|
461
|
+
schedulePersist,
|
|
475
462
|
pushEvent,
|
|
476
463
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
477
464
|
notifyExhaustion,
|
|
@@ -533,7 +520,7 @@ export function apply(ctx, config = {}) {
|
|
|
533
520
|
const cls = classifyFailure(payload);
|
|
534
521
|
const switchable = isSwitchableError(payload, effectiveSwitchCodes) || cls.action === 'switch';
|
|
535
522
|
if (!switchable) return next();
|
|
536
|
-
if (moduleBreaker) moduleBreaker.onFailure(provider);
|
|
523
|
+
if (buildRuntime().circuitBreakerEnabled && moduleBreaker) moduleBreaker.onFailure(provider);
|
|
537
524
|
const ref = pool.state.lastUsed;
|
|
538
525
|
if (ref) {
|
|
539
526
|
const backoff = recordFailure(pool, ref, Date.now(), pool.cooldownMs ?? 60000, undefined, cls.soft, true);
|
package/lib/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
3
3
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { envValue } from './pool.js';
|
|
5
6
|
|
|
6
7
|
export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
|
|
7
8
|
return ctx.effect(() => {
|
|
@@ -30,21 +31,33 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
30
31
|
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
31
32
|
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
32
33
|
const intervalMs = intervalMin * 60 * 1000;
|
|
34
|
+
let running = false;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
const isActive = () => !disposed && (getConfig()?.selfHealingIntervalMinutes ?? 0) > 0;
|
|
33
37
|
const timer = setInterval(async () => {
|
|
38
|
+
if (running || disposed) return;
|
|
39
|
+
running = true;
|
|
34
40
|
try {
|
|
35
41
|
const c = getConfig();
|
|
36
42
|
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
37
43
|
const { pools } = buildRuntime();
|
|
38
44
|
const runner = ensureSandboxRunner(ctx);
|
|
39
45
|
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// A rotating resolver can substitute a healthy neighbor for this
|
|
47
|
+
// quarantined ref, falsely proving that the broken key recovered.
|
|
48
|
+
const credentials = ctx.get?.('credentials') ?? ctx.credentials;
|
|
49
|
+
const resolve = credentials?.__dshKeyRotationOriginalResolve ?? credentials?.resolve;
|
|
50
|
+
const hit = await resolve?.call(credentials, ref);
|
|
51
|
+
const value = typeof hit?.value === 'string' && hit.value.length > 0
|
|
52
|
+
? hit.value : envValue(ref);
|
|
53
|
+
if (!value || !isActive()) return { ok: false };
|
|
54
|
+
return runner.probeModels(ref, value);
|
|
55
|
+
}, Date.now(), { isActive });
|
|
44
56
|
} catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
57
|
+
finally { running = false; }
|
|
45
58
|
}, intervalMs);
|
|
46
59
|
if (typeof timer.unref === 'function') timer.unref();
|
|
47
|
-
return () => clearInterval(timer);
|
|
60
|
+
return () => { disposed = true; clearInterval(timer); };
|
|
48
61
|
}, 'dsh-key-rotation: auto-unbreak');
|
|
49
62
|
}
|
|
50
63
|
|
|
@@ -65,7 +78,7 @@ export function setupPersistence(ctx, { cfg0, poolState, moduleBreaker, verboseL
|
|
|
65
78
|
if (!snap) return;
|
|
66
79
|
try {
|
|
67
80
|
StatePersistence.restorePools(poolState, snap);
|
|
68
|
-
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit);
|
|
81
|
+
if (moduleBreaker && snap.circuit) moduleBreaker.restore(snap.circuit, { preserveExisting: true });
|
|
69
82
|
if (verboseLoggingOn?.()) {
|
|
70
83
|
logger?.warn?.(`[dsh-key-rotation] restored ${Object.keys(snap.pools ?? {}).length} pool state(s) from ${path.basename(resolvedPath)}`);
|
|
71
84
|
}
|
package/lib/notify-events.js
CHANGED
|
@@ -32,6 +32,8 @@ export function notifySwitch(runtime, pool, info, hooks = { webhookSender: defau
|
|
|
32
32
|
|
|
33
33
|
export function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
34
34
|
const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
|
|
35
|
+
// Legacy/partially restored state must not replace the provider's error.
|
|
36
|
+
if (!Array.isArray(pool.state.events)) pool.state.events = [];
|
|
35
37
|
pool.state.events.push(ev);
|
|
36
38
|
if (pool.state.events.length > MAX_EVENTS) pool.state.events.shift();
|
|
37
39
|
}
|
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/persistence.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// reload would otherwise forget cooldowns and stampede recovered keys.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { initializePoolState, hasPoolActivity } from './pool-state.js';
|
|
6
7
|
import { atomicWriteJson, safeReadJson } from './atomic-io.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_FILE = 'dsh-key-rotation-state.json';
|
|
@@ -100,21 +101,24 @@ export class StatePersistence {
|
|
|
100
101
|
|
|
101
102
|
/** Restore poolState Map from a loaded snapshot. */
|
|
102
103
|
static restorePools(poolState, snapshot) {
|
|
103
|
-
if (!poolState || !snapshot
|
|
104
|
+
if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
|
|
104
105
|
let n = 0;
|
|
105
|
-
for (const [base,
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
for (const [base, saved] of Object.entries(snapshot.pools)) {
|
|
107
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
|
|
108
|
+
const existing = poolState.get(base);
|
|
109
|
+
const state = initializePoolState(existing);
|
|
110
|
+
// Never replace objects retained by buildRuntime(), nor roll back a pool
|
|
111
|
+
// already used while the asynchronous load was in flight.
|
|
112
|
+
if (!existing || !hasPoolActivity(state)) {
|
|
113
|
+
if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
|
|
114
|
+
for (const [ref, until] of Object.entries(saved.failedUntil)) {
|
|
115
|
+
if (Number.isFinite(until)) state.failedUntil.set(ref, until);
|
|
116
|
+
}
|
|
111
117
|
}
|
|
118
|
+
state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
|
|
119
|
+
state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
|
|
112
120
|
}
|
|
113
|
-
poolState.set(base,
|
|
114
|
-
failedUntil,
|
|
115
|
-
pointer: Number.isFinite(st.pointer) ? st.pointer : 0,
|
|
116
|
-
lastUsed: typeof st.lastUsed === 'string' ? st.lastUsed : undefined,
|
|
117
|
-
});
|
|
121
|
+
poolState.set(base, state);
|
|
118
122
|
n += 1;
|
|
119
123
|
}
|
|
120
124
|
return n;
|
package/lib/pool-builder.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// lib/pool-builder.js — provider & per-model pool assembly and cleanup
|
|
2
2
|
import { bucketSweep } from './bucket.js';
|
|
3
|
+
import { isValidRef } from './pool.js';
|
|
3
4
|
|
|
4
5
|
export function parseExpiry(v) {
|
|
5
6
|
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
@@ -22,7 +23,7 @@ export function buildPoolItem({
|
|
|
22
23
|
rpmLimit,
|
|
23
24
|
makeState,
|
|
24
25
|
}) {
|
|
25
|
-
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.
|
|
26
|
+
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && isValidRef(ref.trim()));
|
|
26
27
|
if (refs.length === 0) return null;
|
|
27
28
|
const w = Array.isArray(weights) ? weights : [];
|
|
28
29
|
const weightedRefs = [];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared runtime shape for fresh and persisted pools. Hydrate in place so cached
|
|
2
|
+
// runtime pools, persistence and timers continue to observe the same object.
|
|
3
|
+
const MAP_FIELDS = [
|
|
4
|
+
'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
|
|
5
|
+
'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function initializePoolState(state = {}) {
|
|
9
|
+
for (const key of MAP_FIELDS) {
|
|
10
|
+
if (!(state[key] instanceof Map)) state[key] = new Map();
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(state.events)) state.events = [];
|
|
13
|
+
if (!Number.isSafeInteger(state.pointer) || state.pointer < 0) state.pointer = 0;
|
|
14
|
+
if (typeof state.lastUsed !== 'string') state.lastUsed = null;
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Startup disk I/O can finish after requests have already used a pool. In that
|
|
19
|
+
// case the live state wins: replaying an older cooldown/cursor can undo success.
|
|
20
|
+
export function hasPoolActivity(state) {
|
|
21
|
+
return state.pointer !== 0 || state.lastUsed !== null || state.events.length > 0 ||
|
|
22
|
+
MAP_FIELDS.some((key) => state[key].size > 0) ||
|
|
23
|
+
(state.lastSuccessAt?.size ?? 0) > 0;
|
|
24
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -122,7 +122,9 @@ export function computeBackoff(baseMs, failCount, maxMs, isSoft = false) {
|
|
|
122
122
|
}
|
|
123
123
|
const cap = maxMs ?? baseMs * 8;
|
|
124
124
|
if (failCount <= 1) return Math.min(baseMs, cap);
|
|
125
|
-
|
|
125
|
+
// Bit shifts wrap/sign-flip at 32 failures. Floating-point exponentiation
|
|
126
|
+
// saturates at Infinity, which the configured cap safely bounds.
|
|
127
|
+
const backoff = baseMs === 0 ? 0 : baseMs * (2 ** (failCount - 1));
|
|
126
128
|
return Math.min(backoff, cap);
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -185,10 +187,13 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
|
185
187
|
let cleared = 0;
|
|
186
188
|
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
187
189
|
for (const st of poolState.values()) {
|
|
190
|
+
// #338: decay penalty failCounts for stable keys in maintenance sweep
|
|
191
|
+
decayPenalties({ state: st }, now);
|
|
188
192
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
189
193
|
if (until <= now) {
|
|
190
194
|
st.failedUntil.delete(ref);
|
|
191
195
|
st.failCounts?.delete(ref);
|
|
196
|
+
st.quotaWindows?.delete(ref);
|
|
192
197
|
cleared++;
|
|
193
198
|
}
|
|
194
199
|
}
|
|
@@ -372,7 +377,6 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
372
377
|
|
|
373
378
|
// 1. Direct HTTP status codes (#267: also 408/425 transient)
|
|
374
379
|
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
380
|
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
|
|
377
381
|
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
|
378
382
|
|
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
|
|
|
@@ -44,6 +47,8 @@ export function createRotate(deps) {
|
|
|
44
47
|
const runtime0 = buildRuntime();
|
|
45
48
|
const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
|
|
46
49
|
let lastFailure = null;
|
|
50
|
+
const breaker = runtime0.circuitBreakerEnabled === false ? null : circuitBreaker;
|
|
51
|
+
let activePermit = null;
|
|
47
52
|
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
48
53
|
setRotateStartMs(reqStore.startMs);
|
|
49
54
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
@@ -77,8 +82,27 @@ export function createRotate(deps) {
|
|
|
77
82
|
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
78
83
|
const cls = classifyFailure({ code: errCode, message: errMsg });
|
|
79
84
|
const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
|
|
80
|
-
|
|
85
|
+
|
|
86
|
+
// #337: connect quota-window calendar reset for quota exhaustion
|
|
87
|
+
// "rate limit exceeded" and "deadline exceeded" are not evidence
|
|
88
|
+
// of an exhausted billing quota. Keep those on ordinary cooldowns.
|
|
89
|
+
const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' ||
|
|
90
|
+
/\b(?:insufficient[_\s-]+quota|quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota)\b/i.test(errMsg);
|
|
91
|
+
const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
|
|
92
|
+
if (isQuota && qWindow) {
|
|
93
|
+
const quotaResetAt = nextQuotaReset(qWindow, now());
|
|
94
|
+
if (quotaResetAt && quotaResetAt > now()) {
|
|
95
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
96
|
+
pool.state.quotaWindows.set(targetRef, quotaResetAt);
|
|
97
|
+
pool.state.failedUntil.set(targetRef, quotaResetAt);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
activePermit?.failure();
|
|
81
102
|
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
103
|
+
if (typeof schedulePersist === 'function') {
|
|
104
|
+
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
105
|
+
}
|
|
82
106
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
83
107
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
84
108
|
const _cStr = String(errCode ?? '');
|
|
@@ -94,165 +118,192 @@ export function createRotate(deps) {
|
|
|
94
118
|
}
|
|
95
119
|
};
|
|
96
120
|
|
|
97
|
-
// #260: fail fast when provider circuit is open
|
|
98
|
-
if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
|
|
99
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
100
|
-
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
121
|
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
inner = dispatchStorage.run(reqStore, () => {
|
|
111
|
-
const llm = ctx.get('llm');
|
|
112
|
-
if (!llm || typeof llm.stream !== 'function') {
|
|
113
|
-
throw new Error('dsh-key-rotation: llm service unavailable');
|
|
114
|
-
}
|
|
115
|
-
return llm.stream({ ...options, [MARKER]: true });
|
|
116
|
-
});
|
|
117
|
-
} catch (e) {
|
|
118
|
-
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
119
|
-
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
120
|
-
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
121
|
-
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
122
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
123
|
-
continue;
|
|
122
|
+
activePermit = breaker ? breaker.acquire(options.provider) : null;
|
|
123
|
+
if (breaker && !activePermit) {
|
|
124
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
125
|
+
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
126
|
+
return;
|
|
124
127
|
}
|
|
125
|
-
|
|
126
|
-
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
127
|
-
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
128
128
|
try {
|
|
129
|
+
let yielded = false;
|
|
130
|
+
let switching = false;
|
|
131
|
+
let inner;
|
|
129
132
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
yield chunk;
|
|
136
|
-
continue;
|
|
133
|
+
// mark the internal dispatch so the interceptor does not re-rotate
|
|
134
|
+
inner = dispatchStorage.run(reqStore, () => {
|
|
135
|
+
const llm = ctx.get('llm');
|
|
136
|
+
if (!llm || typeof llm.stream !== 'function') {
|
|
137
|
+
throw new Error('dsh-key-rotation: llm service unavailable');
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
switching = true;
|
|
163
|
-
break;
|
|
139
|
+
return llm.stream({ ...options, [MARKER]: true });
|
|
140
|
+
});
|
|
141
|
+
} catch (e) {
|
|
142
|
+
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
143
|
+
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
144
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
145
|
+
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
146
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
151
|
+
const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
|
|
152
|
+
try {
|
|
153
|
+
try {
|
|
154
|
+
for await (const chunk of inner) {
|
|
155
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
156
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
157
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
158
|
+
yielded = true;
|
|
159
|
+
yield chunk;
|
|
160
|
+
continue;
|
|
164
161
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
pool.state.
|
|
162
|
+
if (chunk && chunk.type === 'finish') {
|
|
163
|
+
const kind = chunk.reason?.kind;
|
|
164
|
+
const failure = chunk.reason?.failure;
|
|
165
|
+
const code = failure?.code;
|
|
166
|
+
const message = failure?.message ?? '';
|
|
167
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
168
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
169
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
170
|
+
if (switchable) {
|
|
171
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
172
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
173
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
174
|
+
pool.state.lastSwitchAt = now();
|
|
175
|
+
lastFailure = chunk;
|
|
176
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
177
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
178
|
+
if (switchNotify && activeRef) {
|
|
179
|
+
notifySwitch(runtime0, pool, {
|
|
180
|
+
provider: options.provider,
|
|
181
|
+
from: activeRef,
|
|
182
|
+
code: String(code ?? 'UNKNOWN'),
|
|
183
|
+
at: pool.state.lastSwitchAt,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
switching = true;
|
|
187
|
+
break;
|
|
177
188
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
184
|
-
pool.state.usageDays.set(activeRef, dayMap);
|
|
185
|
-
}
|
|
186
|
-
// Per-model request detail (#121)
|
|
187
|
-
if (activeRef && options.model) {
|
|
188
|
-
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
189
|
-
let byRef = pool.state.byModel.get(activeRef);
|
|
190
|
-
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
191
|
-
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
192
|
-
}
|
|
193
|
-
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
194
|
-
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
195
|
-
// We do NOT re-run this (already successful) request — that would double-send.
|
|
196
|
-
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
197
|
-
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
198
|
-
if (guardEnabled && rate && activeRef) {
|
|
199
|
-
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
200
|
-
let cool;
|
|
201
|
-
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
202
|
-
cool = rate.retryAfter * 1000;
|
|
203
|
-
} else if (rate.reset && rate.reset > now()) {
|
|
204
|
-
cool = rate.reset - now();
|
|
205
|
-
} else {
|
|
206
|
-
cool = pool.cooldownMs ?? cooldownMs;
|
|
189
|
+
// Error/abort terminals are not successful responses. After a
|
|
190
|
+
// content delta we may penalize, but must never replay the request.
|
|
191
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
192
|
+
if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
|
|
193
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
207
194
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
211
|
-
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
212
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
195
|
+
yield chunk;
|
|
196
|
+
return;
|
|
213
197
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
198
|
+
// cost tracking if provider returns usage.cost
|
|
199
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
200
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
201
|
+
const c = Number(chunk.usage.cost);
|
|
202
|
+
if (!isNaN(c)) {
|
|
203
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
204
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
205
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
206
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
207
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
208
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
209
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Usage by day (#119)
|
|
213
|
+
if (activeRef) {
|
|
214
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
215
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
216
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
217
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
218
|
+
}
|
|
219
|
+
// Per-model request detail (#121)
|
|
220
|
+
if (activeRef && options.model) {
|
|
221
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
222
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
223
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
224
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
// #336: record success for the active key upon successful stream finish
|
|
227
|
+
if (activeRef) {
|
|
228
|
+
recordSuccess(pool, activeRef, now());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
232
|
+
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
233
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
234
|
+
const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
|
|
235
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
236
|
+
if (guardEnabled && rate && activeRef) {
|
|
237
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
238
|
+
let cool;
|
|
239
|
+
if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
|
|
240
|
+
cool = rate.retryAfter * 1000;
|
|
241
|
+
} else if (rate.reset && rate.reset > now()) {
|
|
242
|
+
cool = rate.reset - now();
|
|
243
|
+
} else {
|
|
244
|
+
cool = pool.cooldownMs ?? cooldownMs;
|
|
245
|
+
}
|
|
246
|
+
const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
|
|
247
|
+
const effCool = Math.min(cool, maxCool ?? cool);
|
|
248
|
+
recordFailure(pool, activeRef, now(), effCool, maxCool);
|
|
249
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
|
|
250
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
254
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
255
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
256
|
+
}
|
|
257
|
+
recordLatency(pool, reqStore);
|
|
258
|
+
activePermit?.success();
|
|
259
|
+
yield chunk;
|
|
260
|
+
return;
|
|
218
261
|
}
|
|
219
262
|
yield chunk;
|
|
220
|
-
recordLatency(pool, reqStore);
|
|
221
|
-
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
222
|
-
return;
|
|
223
263
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
} catch (e) {
|
|
265
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
266
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
267
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
268
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
269
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
270
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
271
|
+
pool.state.lastSwitchAt = now();
|
|
272
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
273
|
+
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
274
|
+
if (switchNotify && activeRef) {
|
|
275
|
+
notifySwitch(runtime0, pool, {
|
|
276
|
+
provider: options.provider,
|
|
277
|
+
from: activeRef,
|
|
278
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
279
|
+
at: pool.state.lastSwitchAt,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
continue; // Failover to next key!
|
|
283
|
+
}
|
|
284
|
+
if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
285
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
243
286
|
}
|
|
244
|
-
|
|
287
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
288
|
+
return;
|
|
245
289
|
}
|
|
246
|
-
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
290
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
291
|
+
if (switching) continue; // try the next key
|
|
292
|
+
if (yielded) {
|
|
293
|
+
const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
|
|
294
|
+
if (activeRef) recordSuccess(pool, activeRef, now());
|
|
295
|
+
recordLatency(pool, reqStore);
|
|
296
|
+
activePermit?.success();
|
|
297
|
+
}
|
|
298
|
+
return; // clean end — served
|
|
299
|
+
} finally {
|
|
300
|
+
if (acquired && _pickedRef) {
|
|
301
|
+
concurrencyTracker.release(_pickedRef);
|
|
302
|
+
}
|
|
255
303
|
}
|
|
304
|
+
} finally {
|
|
305
|
+
// Also runs on consumer return(), abort, empty EOF and local errors.
|
|
306
|
+
activePermit?.release();
|
|
256
307
|
}
|
|
257
308
|
}
|
|
258
309
|
|
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.19",
|
|
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": [
|