@goodandready/dsh-key-rotation 0.8.79 → 0.8.81

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.
@@ -1,267 +1,2 @@
1
- // lib/key-state-machine.js — Encapsulated Key State Machine (#529).
2
- // Manages credential lifecycle across deterministic states and valid transitions.
3
-
4
- export const KEY_STATES = Object.freeze({
5
- ACTIVE: 'ACTIVE',
6
- COOLDOWN: 'COOLDOWN',
7
- PROBING: 'PROBING',
8
- EXHAUSTED: 'EXHAUSTED',
9
- PAUSED: 'PAUSED',
10
- REVOKED: 'REVOKED',
11
- });
12
-
13
- export const VALID_TRANSITIONS = Object.freeze({
14
- [KEY_STATES.ACTIVE]: Object.freeze([
15
- KEY_STATES.COOLDOWN,
16
- KEY_STATES.EXHAUSTED,
17
- KEY_STATES.PAUSED,
18
- KEY_STATES.REVOKED,
19
- ]),
20
- [KEY_STATES.COOLDOWN]: Object.freeze([
21
- KEY_STATES.PROBING,
22
- KEY_STATES.ACTIVE,
23
- KEY_STATES.PAUSED,
24
- KEY_STATES.REVOKED,
25
- ]),
26
- [KEY_STATES.PROBING]: Object.freeze([
27
- KEY_STATES.ACTIVE,
28
- KEY_STATES.COOLDOWN,
29
- KEY_STATES.PAUSED,
30
- KEY_STATES.REVOKED,
31
- ]),
32
- [KEY_STATES.EXHAUSTED]: Object.freeze([
33
- KEY_STATES.ACTIVE,
34
- KEY_STATES.COOLDOWN,
35
- KEY_STATES.PAUSED,
36
- KEY_STATES.REVOKED,
37
- ]),
38
- [KEY_STATES.PAUSED]: Object.freeze([
39
- KEY_STATES.ACTIVE,
40
- KEY_STATES.COOLDOWN,
41
- KEY_STATES.REVOKED,
42
- ]),
43
- [KEY_STATES.REVOKED]: Object.freeze([
44
- KEY_STATES.ACTIVE,
45
- ]),
46
- });
47
-
48
- export class KeyStateMachine {
49
- /**
50
- * @param {object} [options]
51
- * @param {number} [options.maxHistory] Maximum transition history entries per pool
52
- */
53
- constructor(options = {}) {
54
- /** @type {Map<string, { state: string, updatedAt: number, previousState?: string, reason?: string, failedUntil?: number }>} */
55
- this.keyRecords = new Map();
56
- /** @type {Array<{ ref: string, from: string, to: string, at: number, reason: string|null }>} */
57
- this.history = [];
58
- this.maxHistory = options.maxHistory ?? 100;
59
- }
60
-
61
- /**
62
- * Computes the current deterministic state of a key reference.
63
- *
64
- * @param {string} keyRef
65
- * @param {object} [context] Optional pool state context for backward compatibility
66
- * @param {Set<string>} [context.revokedRefs]
67
- * @param {Set<string>} [context.pausedRefs]
68
- * @param {Map<string, number>} [context.failedUntil]
69
- * @param {number} [now]
70
- * @returns {string} Current KEY_STATES value
71
- */
72
- getState(keyRef, context = {}, now = Date.now()) {
73
- if (!keyRef) return KEY_STATES.ACTIVE;
74
-
75
- // 1. Revocation is absolute
76
- if (context.revokedRefs && context.revokedRefs.has(keyRef)) {
77
- return KEY_STATES.REVOKED;
78
- }
79
-
80
- // 2. Manual pause overrides operational state
81
- if (context.pausedRefs && context.pausedRefs.has(keyRef)) {
82
- return KEY_STATES.PAUSED;
83
- }
84
-
85
- const record = this.keyRecords.get(keyRef);
86
- if (!record) {
87
- const failedUntil = context.failedUntil?.get?.(keyRef);
88
- if (typeof failedUntil === 'number' && failedUntil > now) {
89
- return KEY_STATES.COOLDOWN;
90
- }
91
- return KEY_STATES.ACTIVE;
92
- }
93
-
94
- // Cooldown transition to probing when timer elapses
95
- if (record.state === KEY_STATES.COOLDOWN) {
96
- const until = record.failedUntil ?? context.failedUntil?.get?.(keyRef);
97
- if (typeof until === 'number' && until <= now) {
98
- return KEY_STATES.PROBING;
99
- }
100
- }
101
-
102
- return record.state;
103
- }
104
-
105
- /**
106
- * Transitions a key reference to a new state if valid.
107
- *
108
- * @param {string} keyRef
109
- * @param {string} targetState Target KEY_STATES value
110
- * @param {object} [metadata]
111
- * @param {string} [metadata.reason]
112
- * @param {number} [metadata.failedUntil]
113
- * @param {object} [metadata.context]
114
- * @param {number} [now]
115
- * @returns {string|null} Resulting state or null on failure
116
- */
117
- transition(keyRef, targetState, metadata = {}, now = Date.now()) {
118
- if (!keyRef || !targetState) return null;
119
-
120
- const currentState = this.getState(keyRef, metadata.context, now);
121
- if (currentState === targetState) return currentState;
122
-
123
- const allowed = VALID_TRANSITIONS[currentState] || [];
124
- if (!allowed.includes(targetState)) {
125
- // In relaxed mode, force transition with logged anomaly
126
- targetState = targetState in KEY_STATES ? targetState : KEY_STATES.ACTIVE;
127
- }
128
-
129
- const record = this.keyRecords.get(keyRef) ?? {
130
- state: currentState,
131
- updatedAt: now,
132
- };
133
-
134
- record.previousState = currentState;
135
- record.state = targetState;
136
- record.updatedAt = now;
137
- if (metadata.reason !== undefined) record.reason = metadata.reason;
138
- if (metadata.failedUntil !== undefined) record.failedUntil = metadata.failedUntil;
139
-
140
- this.keyRecords.set(keyRef, record);
141
-
142
- const event = {
143
- ref: keyRef,
144
- from: currentState,
145
- to: targetState,
146
- at: now,
147
- reason: metadata.reason ?? null,
148
- };
149
-
150
- this.history.push(event);
151
- if (this.history.length > this.maxHistory) {
152
- this.history.shift();
153
- }
154
-
155
- // Synchronize external pool state if provided
156
- if (metadata.context) {
157
- if (targetState === KEY_STATES.REVOKED) {
158
- metadata.context.revokedRefs?.add?.(keyRef);
159
- metadata.context.pausedRefs?.delete?.(keyRef);
160
- } else if (targetState === KEY_STATES.PAUSED) {
161
- metadata.context.pausedRefs?.add?.(keyRef);
162
- } else if (currentState === KEY_STATES.PAUSED && targetState !== KEY_STATES.PAUSED) {
163
- metadata.context.pausedRefs?.delete?.(keyRef);
164
- }
165
- }
166
-
167
- return targetState;
168
- }
169
-
170
- /**
171
- * Puts key into COOLDOWN state until specified duration elapses.
172
- */
173
- transitionToCooldown(keyRef, options = {}, now = Date.now()) {
174
- const cooldownMs = Number(options.cooldownMs) > 0 ? Number(options.cooldownMs) : 60000;
175
- const failedUntil = now + cooldownMs;
176
- return this.transition(keyRef, KEY_STATES.COOLDOWN, {
177
- failedUntil,
178
- reason: options.reason || 'COOLDOWN',
179
- context: options.context,
180
- }, now);
181
- }
182
-
183
- /** Marks key as ready for canary probe dispatch. */
184
- markProbing(keyRef, metadata = {}, now = Date.now()) {
185
- return this.transition(keyRef, KEY_STATES.PROBING, {
186
- reason: metadata.reason || 'CANARY_PROBE',
187
- context: metadata.context,
188
- }, now);
189
- }
190
-
191
- /** Activates key following successful request or probe. */
192
- activate(keyRef, metadata = {}, now = Date.now()) {
193
- return this.transition(keyRef, KEY_STATES.ACTIVE, {
194
- reason: metadata.reason || 'SUCCESS',
195
- context: metadata.context,
196
- }, now);
197
- }
198
-
199
- /** Marks key as exhausted due to local quota or TPM/RPM window. */
200
- markExhausted(keyRef, metadata = {}, now = Date.now()) {
201
- return this.transition(keyRef, KEY_STATES.EXHAUSTED, {
202
- reason: metadata.reason || 'QUOTA_EXHAUSTED',
203
- context: metadata.context,
204
- }, now);
205
- }
206
-
207
- /** Manually pauses key from rotation. */
208
- pause(keyRef, reason = 'USER_PAUSE', metadata = {}, now = Date.now()) {
209
- return this.transition(keyRef, KEY_STATES.PAUSED, {
210
- reason,
211
- context: metadata.context,
212
- }, now);
213
- }
214
-
215
- /** Unpauses key, returning it to active rotation. */
216
- unpause(keyRef, metadata = {}, now = Date.now()) {
217
- return this.transition(keyRef, KEY_STATES.ACTIVE, {
218
- reason: 'USER_RESUME',
219
- context: metadata.context,
220
- }, now);
221
- }
222
-
223
- /** Revokes key permanently until credentials change. */
224
- revoke(keyRef, reason = 'INVALID_CREDENTIAL', metadata = {}, now = Date.now()) {
225
- return this.transition(keyRef, KEY_STATES.REVOKED, {
226
- reason,
227
- context: metadata.context,
228
- }, now);
229
- }
230
-
231
- /** Checks if key is usable for dispatch (ACTIVE or PROBING). */
232
- isUsable(keyRef, context = {}, now = Date.now()) {
233
- const state = this.getState(keyRef, context, now);
234
- return state === KEY_STATES.ACTIVE || state === KEY_STATES.PROBING;
235
- }
236
-
237
- /** Returns transition history for a specific key or all keys. */
238
- getHistory(keyRef) {
239
- if (!keyRef) return this.history.slice();
240
- return this.history.filter((e) => e.ref === keyRef);
241
- }
242
-
243
- /** Returns state distribution statistics across a set of keys. */
244
- getStats(refs = [], context = {}, now = Date.now()) {
245
- const stats = {
246
- total: refs.length,
247
- [KEY_STATES.ACTIVE]: 0,
248
- [KEY_STATES.COOLDOWN]: 0,
249
- [KEY_STATES.PROBING]: 0,
250
- [KEY_STATES.EXHAUSTED]: 0,
251
- [KEY_STATES.PAUSED]: 0,
252
- [KEY_STATES.REVOKED]: 0,
253
- };
254
- for (const ref of refs) {
255
- const state = this.getState(ref, context, now);
256
- if (state in stats) stats[state]++;
257
- }
258
- return stats;
259
- }
260
-
261
- clear() {
262
- this.keyRecords.clear();
263
- this.history = [];
264
- }
265
- }
266
-
267
- export const defaultKeyStateMachine = new KeyStateMachine();
1
+ // lib/key-state-machine.js — Backwards-compatible facade (#530).
2
+ export * from './core/state-machine.js';
@@ -1,8 +1,9 @@
1
- // lib/persistence.js — atomic snapshot of rotation state across restarts (#287).
2
- // Cooldowns, circuit-breaker entries and quota counters live in memory; a DSH
3
- // reload would otherwise forget cooldowns and stampede recovered keys.
1
+ // lib/persistence.js — atomic snapshot and append-only event journal (#287, #525).
2
+ // Cooldowns, circuit-breaker entries and quota counters live in memory; an append-only
3
+ // journal records incremental events without rewriting the entire state tree on disk.
4
4
 
5
5
  import path from 'node:path';
6
+ import fs from 'node:fs';
6
7
  import { initializePoolState, hasPoolActivity } from './pool-state.js';
7
8
  import { atomicWriteJson, safeReadJson } from './atomic-io.js';
8
9
 
@@ -18,6 +19,9 @@ export class StatePersistence {
18
19
  throw new Error('StatePersistence: filePath is required');
19
20
  }
20
21
  this.filePath = filePath;
22
+ this.journalPath = filePath.endsWith('.json')
23
+ ? filePath.slice(0, -5) + '-journal.jsonl'
24
+ : `${filePath}.jsonl`;
21
25
  this._now = now;
22
26
  this._timer = null;
23
27
  this._dirty = false;
@@ -112,8 +116,8 @@ export class StatePersistence {
112
116
  if (val instanceof Map || (typeof val === 'object' && !Array.isArray(val))) {
113
117
  const innerObj = {};
114
118
  const innerEntries = val instanceof Map ? val.entries() : Object.entries(val);
115
- for (const [day, c] of innerEntries) {
116
- const num = Number(c);
119
+ for (const [day, count] of innerEntries) {
120
+ const num = Number(count);
117
121
  if (Number.isFinite(num) && num >= 0) innerObj[day] = Math.floor(num);
118
122
  }
119
123
  if (Object.keys(innerObj).length > 0) usageDays[k] = innerObj;
@@ -168,7 +172,22 @@ export class StatePersistence {
168
172
  }
169
173
 
170
174
  /**
171
- * Schedule a debounced atomic write. Never throws to the caller.
175
+ * Append-only event journaling (#525). Records incremental event without full state rewrite.
176
+ * @param {object} event
177
+ */
178
+ async appendEvent(event) {
179
+ if (!event || typeof event !== 'object') return false;
180
+ const line = JSON.stringify({ ...event, at: event.at ?? this._nowSafe() }) + '\n';
181
+ try {
182
+ await fs.promises.appendFile(this.journalPath, line, 'utf-8');
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Schedule a debounced atomic snapshot write. Never throws to the caller.
172
191
  * @param {object} payload result of StatePersistence.serialize
173
192
  */
174
193
  save(payload) {
@@ -190,9 +209,11 @@ export class StatePersistence {
190
209
  try {
191
210
  await atomicWriteJson(this.filePath, payload);
192
211
  this._lastWrite = this._nowSafe();
212
+ try {
213
+ await fs.promises.writeFile(this.journalPath, '', 'utf-8');
214
+ } catch {}
193
215
  return true;
194
216
  } catch {
195
- // Keep previous good file; mark dirty so a later save retries.
196
217
  this._dirty = true;
197
218
  this._pending = this._pending || payload;
198
219
  return false;
@@ -200,16 +221,56 @@ export class StatePersistence {
200
221
  }
201
222
 
202
223
  /**
203
- * Load a snapshot. Corrupt/missing file → null (never wipes memory).
224
+ * Load snapshot and replay any journaled events (#525).
204
225
  * @returns {Promise<object|null>}
205
226
  */
206
227
  async load() {
207
228
  const raw = await safeReadJson(this.filePath, null);
208
229
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
209
230
  if (raw.version !== 1 && raw.version !== 2) return null;
231
+
232
+ if (fs.existsSync(this.journalPath)) {
233
+ try {
234
+ const content = await fs.promises.readFile(this.journalPath, 'utf-8');
235
+ const lines = content.split('\n');
236
+ for (const line of lines) {
237
+ const trimmed = line.trim();
238
+ if (trimmed) {
239
+ try { this.replayEvent(raw, JSON.parse(trimmed)); } catch {}
240
+ }
241
+ }
242
+ } catch {}
243
+ }
244
+
210
245
  return raw;
211
246
  }
212
247
 
248
+ /** Replay individual event into a snapshot object (#525). */
249
+ replayEvent(snapshot, event) {
250
+ if (!snapshot || !event?.pool || !event?.ref) return;
251
+ if (!snapshot.pools) snapshot.pools = {};
252
+ if (!snapshot.pools[event.pool]) {
253
+ snapshot.pools[event.pool] = { failedUntil: {}, pausedRefs: [], revokedRefs: [] };
254
+ }
255
+ const p = snapshot.pools[event.pool];
256
+ if (event.type === 'cooldown' || event.type === 'failure') {
257
+ if (!p.failedUntil) p.failedUntil = {};
258
+ p.failedUntil[event.ref] = event.until ?? (event.at + (event.cooldownMs || 60000));
259
+ } else if (event.type === 'success' || event.type === 'reset') {
260
+ if (p.failedUntil) delete p.failedUntil[event.ref];
261
+ } else if (event.type === 'pause') {
262
+ if (!Array.isArray(p.pausedRefs)) p.pausedRefs = [];
263
+ if (!p.pausedRefs.includes(event.ref)) p.pausedRefs.push(event.ref);
264
+ } else if (event.type === 'unpause') {
265
+ if (Array.isArray(p.pausedRefs)) {
266
+ p.pausedRefs = p.pausedRefs.filter((r) => r !== event.ref);
267
+ }
268
+ } else if (event.type === 'revoke') {
269
+ if (!Array.isArray(p.revokedRefs)) p.revokedRefs = [];
270
+ if (!p.revokedRefs.includes(event.ref)) p.revokedRefs.push(event.ref);
271
+ }
272
+ }
273
+
213
274
  /** Restore poolState Map from a loaded snapshot. */
214
275
  static restorePools(poolState, snapshot) {
215
276
  if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
@@ -218,8 +279,6 @@ export class StatePersistence {
218
279
  if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
219
280
  const existing = poolState.get(base);
220
281
  const state = initializePoolState(existing);
221
- // Never replace objects retained by buildRuntime(), nor roll back a pool
222
- // already used while the asynchronous load was in flight.
223
282
  if (!existing || !hasPoolActivity(state)) {
224
283
  if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
225
284
  for (const [ref, until] of Object.entries(saved.failedUntil)) {
package/lib/quota.js CHANGED
@@ -1,39 +1,2 @@
1
- // lib/quota.js — quota-remaining persistence per ref.
2
- // ponytail: pure helpers, snapshot() returns shallow copies.
3
-
4
- export class QuotaStore {
5
- constructor() {
6
- this._data = new Map(); // ref -> { remaining, limit, reset, at }
7
- }
8
-
9
- set(ref, info) {
10
- if (!ref) return;
11
- if (!info || typeof info !== 'object') return;
12
- const next = {
13
- remaining: Number.isFinite(info.remaining) ? info.remaining : null,
14
- limit: Number.isFinite(info.limit) ? info.limit : null,
15
- reset: Number.isFinite(info.reset) ? info.reset : null,
16
- at: Number.isFinite(info.at) ? info.at : Date.now(),
17
- };
18
- this._data.set(ref, next);
19
- }
20
-
21
- get(ref) {
22
- return this._data.get(ref);
23
- }
24
-
25
- snapshot() {
26
- const out = {};
27
- for (const [k, v] of this._data) out[k] = v;
28
- return out;
29
- }
30
-
31
- clear(ref) {
32
- if (ref) this._data.delete(ref);
33
- else this._data.clear();
34
- }
35
-
36
- get size() {
37
- return this._data.size;
38
- }
39
- }
1
+ // lib/quota.js — Backwards-compatible facade (#530).
2
+ export * from './core/quota.js';
package/lib/resolver.js CHANGED
@@ -309,3 +309,39 @@ function settle(pool, candidate, now, onPicked, concurrencyRef, sessionId) {
309
309
  pool.state.quotaWindows.set(candidate, win);
310
310
  }
311
311
  }
312
+
313
+ /**
314
+ * Resolves credential secret directly without requiring monkey-patching (#526).
315
+ * Reads from credentials service safely or falls back to environment variables.
316
+ * @param {string} candidateRef
317
+ * @param {object} [ctx]
318
+ * @returns {Promise<string|null>}
319
+ */
320
+ export async function resolveKeyDirectly(candidateRef, ctx) {
321
+ if (!candidateRef || typeof candidateRef !== 'string') return null;
322
+ try {
323
+ const creds = ctx?.get?.('credentials') ?? ctx?.credentials;
324
+ if (creds) {
325
+ if (typeof creds.get === 'function') {
326
+ const val = await creds.get(candidateRef);
327
+ if (val) return decryptSecret(typeof val === 'object' ? val.value : val);
328
+ }
329
+ if (typeof creds.resolve === 'function') {
330
+ const orig = creds.__dshKeyRotationOriginalResolve ?? creds.resolve;
331
+ const hit = await orig.call(creds, candidateRef);
332
+ if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
333
+ return decryptSecret(hit.value);
334
+ }
335
+ }
336
+ }
337
+ } catch {
338
+ /* credentials miss or access error falls through to env */
339
+ }
340
+
341
+ const envVal = envValue(candidateRef);
342
+ if (envVal !== undefined && typeof envVal === 'string' && envVal.length > 0) {
343
+ return decryptSecret(envVal);
344
+ }
345
+
346
+ return null;
347
+ }
@@ -0,0 +1,193 @@
1
+ // lib/rotate-penalty.js — Key penalty, backoff, dynamic headers, and failure tracking (#526).
2
+ import { classifyFailure } from './error-taxonomy.js';
3
+ import {
4
+ recordFailure,
5
+ parseRetryAfter,
6
+ isKeyPaused,
7
+ isKeyRevoked,
8
+ isKeyBroken,
9
+ clearSessionSticky,
10
+ unstickFailedKey,
11
+ } from './pool.js';
12
+ import { extractProviderLimits } from './provider-limits-detector.js';
13
+ import { poolResetAt } from './quota-window.js';
14
+
15
+ export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
16
+
17
+ /**
18
+ * Penalize a key upon error or upstream failure during rotation.
19
+ */
20
+ export function penalizeKey(targetRef, errCode, errMsg, rawHeaders, {
21
+ pool,
22
+ options,
23
+ sessionId,
24
+ runtime0,
25
+ cooldownMs,
26
+ maxCooldownMs,
27
+ now,
28
+ activePermit,
29
+ trace,
30
+ pushEvent,
31
+ schedulePersist,
32
+ effectiveNotifyEarlyWarning,
33
+ logWarn,
34
+ buildRuntime,
35
+ }) {
36
+ if (!targetRef) return;
37
+ if (sessionId) clearSessionSticky(pool, sessionId);
38
+ unstickFailedKey(pool, targetRef);
39
+ if (errCode === LOCAL_QUOTA_CODE) return;
40
+
41
+ let headerResetMs = null;
42
+ if (rawHeaders) {
43
+ const limits = (typeof rawHeaders === 'object' && rawHeaders.rpmResetMs !== undefined)
44
+ ? rawHeaders
45
+ : extractProviderLimits(rawHeaders);
46
+ headerResetMs = limits?.retryAfterMs ?? limits?.rpmResetMs ?? null;
47
+ }
48
+ if (!headerResetMs) {
49
+ const pRetry = parseRetryAfter(errMsg);
50
+ if (pRetry) headerResetMs = pRetry;
51
+ }
52
+
53
+ const _base = pool.cooldownMs ?? cooldownMs;
54
+ const _max = pool.maxCooldownMs ?? maxCooldownMs ?? 3600000;
55
+ let _b = 0;
56
+ const cls = classifyFailure({ status: Number(errCode) || 0, code: errCode, message: errMsg });
57
+
58
+ if (headerResetMs && headerResetMs > 0) {
59
+ const dynamicCooldownMs = Math.min(_max, Math.max(1000, Math.floor(headerResetMs) + 250));
60
+ pool.state.failedUntil.set(targetRef, now() + dynamicCooldownMs);
61
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
62
+ pool.state.resetReasons.set(targetRef, 'header');
63
+ if (!pool.state.headerResetTimes) pool.state.headerResetTimes = new Map();
64
+ pool.state.headerResetTimes.set(targetRef, dynamicCooldownMs);
65
+ _b = dynamicCooldownMs;
66
+ pushEvent(pool, targetRef, 'HEADER_RESET', dynamicCooldownMs);
67
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} dynamic cooldown set to ${dynamicCooldownMs}ms via upstream headers`);
68
+
69
+ const recTimer = setTimeout(() => {
70
+ if ((pool.state.failedUntil?.get?.(targetRef) ?? 0) <= now()) {
71
+ pool.state.failedUntil.delete(targetRef);
72
+ pushEvent(pool, targetRef, 'DYNAMIC_UNBREAK', 0);
73
+ }
74
+ }, dynamicCooldownMs + 50);
75
+ if (typeof recTimer.unref === 'function') recTimer.unref();
76
+ } else {
77
+ const _effBase = _base;
78
+ _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
79
+ if (!pool.state.resetReasons) pool.state.resetReasons = new Map();
80
+ pool.state.resetReasons.set(targetRef, 'backoff');
81
+ }
82
+
83
+ // #337 & #486 (GitHub #24): connect quota-window calendar reset for quota exhaustion
84
+ // Transient rate limiting (RPM/TPM, requests/tokens per minute, concurrency)
85
+ // must NOT be locked until midnight. Keep those on ordinary cooldowns.
86
+ const isRateThrottling = /\b(?:rpm|tpm|requests?\s+per\s+minute|tokens?\s+per\s+minute|per[-_\s]*minute|per[-_\s]*second|concurrency|requests?\s+limit|tokens?\s+limit|traffic)\b/i.test(errMsg) ||
87
+ (/\b(?:rate[-_\s]*limit)\b/i.test(errMsg) && !/\b(?:insufficient[_\s-]+quota|exceeded\s+(?:your\s+)?(?:current\s+)?quota|credit\s+balance|billing)\b/i.test(errMsg));
88
+
89
+ const isQuota = !isRateThrottling && (
90
+ (errCode === 'QUOTA' && !/\b(?:rpm|tpm|rate|traffic|throttle)\b/i.test(errMsg)) ||
91
+ /\b(?:quota(?:\s+is)?\s+(?:exhausted|exceeded)|exceeded\s+(?:your\s+)?(?:current\s+)?quota|insufficient[_\s-]+quota|credit\s+balance|billing\s+quota|out\s+of\s+credits?)\b/i.test(errMsg)
92
+ );
93
+ const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
94
+ if (isQuota && qWindow) {
95
+ const quotaResetAt = poolResetAt(pool, qWindow, now());
96
+ if (quotaResetAt && quotaResetAt > now()) {
97
+ if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
98
+ pool.state.quotaWindows.set(targetRef, quotaResetAt);
99
+ pool.state.failedUntil.set(targetRef, quotaResetAt);
100
+ }
101
+ }
102
+
103
+ activePermit?.failure();
104
+ trace.addStep(targetRef, 'FAILED', errCode ?? 'UNKNOWN', errMsg);
105
+ pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
106
+ if (typeof schedulePersist === 'function') {
107
+ try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
108
+ }
109
+ if (runtime0.earlyWarningNotify && effectiveNotifyEarlyWarning) {
110
+ const total = pool.refs?.length || 1;
111
+ let available = 0;
112
+ const at = now();
113
+ for (const r of (pool.refs || [])) {
114
+ if (!isKeyRevoked(pool, r) && !isKeyPaused(pool, r) && !isKeyBroken(pool, r, at) && ((pool.state?.failedUntil?.get(r) ?? 0) <= at)) {
115
+ available++;
116
+ }
117
+ }
118
+ const threshold = Number.isFinite(runtime0.earlyWarningKeyThreshold) ? runtime0.earlyWarningKeyThreshold : 0.25;
119
+ if (total > 0 && (available / total) <= threshold) {
120
+ effectiveNotifyEarlyWarning(runtime0, pool, {
121
+ provider: options.provider,
122
+ availableCount: available,
123
+ totalCount: total,
124
+ });
125
+ }
126
+ }
127
+ if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
128
+ if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
129
+ const _cStr = String(errCode ?? '').toUpperCase();
130
+ const _isAuth = cls.reason === 'auth' || cls.code === '401' || cls.code === '403' ||
131
+ _cStr === 'AUTH' || _cStr === '401' || _cStr === '403' ||
132
+ _cStr === 'PERMISSION_DENIED' || _cStr === 'UNAUTHENTICATED' ||
133
+ /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated|permission[_\s-]?denied/i.test(errMsg);
134
+
135
+ if (_isAuth) {
136
+ const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
137
+ pool.state.authFailCounts.set(targetRef, _c2);
138
+ if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
139
+ pool.state.revokedRefs.add(targetRef);
140
+ if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
141
+ if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(targetRef);
142
+ else if (Array.isArray(pool.state.pausedRefs) && !pool.state.pausedRefs.includes(targetRef)) pool.state.pausedRefs.push(targetRef);
143
+ if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
144
+ pool.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
145
+ pool.state.failedUntil.set(targetRef, Infinity);
146
+ pushEvent(pool, targetRef, 'KEY_AUTH_PAUSED', 0);
147
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently PAUSED on auth failure (${errCode})`);
148
+ // Fan-out permanent pause across all pools sharing this physical ref (#408):
149
+ if (typeof buildRuntime === 'function') {
150
+ const rAll = buildRuntime();
151
+ const candidatePools = [
152
+ ...(Array.isArray(rAll.pools) ? rAll.pools : []),
153
+ ...(rAll.index?.allPools ? rAll.index.allPools.values() : []),
154
+ ...(rAll.providerToPool ? rAll.providerToPool.values() : []),
155
+ ];
156
+ if (rAll.modelPoolByProvider) {
157
+ for (const map of rAll.modelPoolByProvider.values()) { if (map) candidatePools.push(...map.values()); }
158
+ }
159
+ const seenPools = new Set();
160
+ for (const other of candidatePools) {
161
+ if (!other || seenPools.has(other)) continue;
162
+ seenPools.add(other);
163
+ if (other === pool) continue;
164
+ const hasRef = (Array.isArray(other.refs) && other.refs.includes(targetRef)) ||
165
+ (other.weightsMap instanceof Map && other.weightsMap.has(targetRef));
166
+ if (hasRef && other.state) {
167
+ if (!other.state.revokedRefs) other.state.revokedRefs = new Set();
168
+ other.state.revokedRefs.add(targetRef);
169
+ if (!other.state.pausedRefs) other.state.pausedRefs = new Set();
170
+ if (other.state.pausedRefs instanceof Set) other.state.pausedRefs.add(targetRef);
171
+ else if (Array.isArray(other.state.pausedRefs) && !other.state.pausedRefs.includes(targetRef)) other.state.pausedRefs.push(targetRef);
172
+ if (!other.state.pauseReasons) other.state.pauseReasons = new Map();
173
+ other.state.pauseReasons.set(targetRef, 'AUTH_FAILURE_PAUSED');
174
+ if (other.state.failedUntil) other.state.failedUntil.set(targetRef, Infinity);
175
+ pushEvent(other, targetRef, 'KEY_AUTH_PAUSED', 0);
176
+ }
177
+ }
178
+ }
179
+ } else {
180
+ pool.state.authFailCounts.delete(targetRef);
181
+ // Soft/transient errors (TIMEOUT, 5xx, socket) do not count toward quarantine (#473).
182
+ // recordFailure already maintains pool.state.failCounts accurately for non-soft failures.
183
+ if (!isQuota && !cls.soft) {
184
+ const _fc = pool.state.failCounts.get(targetRef) ?? 0;
185
+ if (_fc >= 3 && !pool.state.brokenUntil.has(targetRef)) {
186
+ const quarantineMs = 1800000;
187
+ pool.state.brokenUntil.set(targetRef, Date.now() + quarantineMs);
188
+ pushEvent(pool, targetRef, 'KEY_QUARANTINED', quarantineMs);
189
+ if (logWarn) logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} put in recoverable quarantine after ${_fc} failures`);
190
+ }
191
+ }
192
+ }
193
+ }