@goodandready/dsh-key-rotation 0.8.8 → 0.8.11

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/lib/pool.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
2
3
  // Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
3
4
 
@@ -71,11 +72,6 @@ export const SOFT_FAILURE_CODES = new Set([
71
72
  ]);
72
73
 
73
74
  /** True if failure code or message represents a soft/transient drop. */
74
- export function isSoftFailure(code, message = '') {
75
- if (code && SOFT_FAILURE_CODES.has(String(code).toUpperCase())) return true;
76
- if (/502|503|504|timeout|econnreset|econnrefused|socket hang up/i.test(message)) return true;
77
- return false;
78
- }
79
75
 
80
76
  /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
81
77
  const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -184,9 +180,10 @@ export function envValue(ref) {
184
180
  return typeof v === 'string' && v.length > 0 ? v : undefined;
185
181
  }
186
182
 
187
- /** Sweep expired cooldown entries from poolState. Returns count of cleared refs. */
188
- export function sweepExpired(poolState, now = Date.now()) {
183
+ /** Sweep expired cooldown entries and prune stale deleted refs from poolState. Returns count of cleared refs. */
184
+ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
189
185
  let cleared = 0;
186
+ const activeSet = activeRefs ? new Set(activeRefs) : null;
190
187
  for (const st of poolState.values()) {
191
188
  for (const [ref, until] of [...st.failedUntil.entries()]) {
192
189
  if (until <= now) {
@@ -195,6 +192,16 @@ export function sweepExpired(poolState, now = Date.now()) {
195
192
  cleared++;
196
193
  }
197
194
  }
195
+ // Prune stale entries for keys no longer in active configuration
196
+ if (activeSet) {
197
+ for (const map of [st.failedUntil, st.failCounts, st.authFailCounts, st.brokenUntil, st.costPerKey, st.lastUsedAt, st.usageCounts, st.byModel, st.usageDays, st.quotaWindows, st.rpmWindows, st.probedAt]) {
198
+ if (map && typeof map.keys === 'function') {
199
+ for (const k of [...map.keys()]) {
200
+ if (!activeSet.has(k)) map.delete(k);
201
+ }
202
+ }
203
+ }
204
+ }
198
205
  }
199
206
  return cleared;
200
207
  }
@@ -248,33 +255,105 @@ export function computeHealthScore(state) {
248
255
  /** Extract rate-limit info from an object that may carry response headers. */
249
256
  export function extractRateLimit(headers) {
250
257
  if (!headers || typeof headers !== 'object') return null;
251
- let remaining, limit, reset;
258
+ let remaining, limit, reset, retryAfter;
252
259
  for (const k in headers) {
253
- const lk = k.length;
254
- if (lk === 21 || lk === 16 || lk === 17) {
255
- const lower = k.toLowerCase();
256
- if (lower === 'x-ratelimit-remaining') {
257
- const v = headers[k];
258
- if (v != null) remaining = Number(String(v));
259
- } else if (lower === 'x-ratelimit-limit') {
260
- const v = headers[k];
261
- if (v != null) limit = Number(String(v));
262
- } else if (lower === 'x-ratelimit-reset') {
263
- const v = headers[k];
264
- if (v != null) reset = Number(String(v));
260
+ const lower = k.toLowerCase();
261
+ const v = headers[k];
262
+ if (v == null) continue;
263
+ if (lower === 'retry-after') {
264
+ const num = Number(v);
265
+ if (Number.isFinite(num) && num >= 0) {
266
+ retryAfter = Math.ceil(num);
267
+ } else {
268
+ const parsed = Date.parse(v);
269
+ if (!Number.isNaN(parsed)) {
270
+ retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
271
+ }
272
+ }
273
+ } else if (
274
+ lower === 'x-ratelimit-remaining' ||
275
+ lower === 'x-ratelimit-remaining-requests' ||
276
+ lower === 'anthropic-ratelimit-requests-remaining' ||
277
+ lower === 'openai-ratelimit-remaining-requests' ||
278
+ lower === 'ratelimit-remaining'
279
+ ) {
280
+ const n = Number(String(v));
281
+ if (Number.isFinite(n)) remaining = n;
282
+ } else if (
283
+ (lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
284
+ remaining === undefined
285
+ ) {
286
+ const n = Number(String(v));
287
+ if (Number.isFinite(n)) remaining = n;
288
+ } else if (
289
+ lower === 'x-ratelimit-limit' ||
290
+ lower === 'x-ratelimit-limit-requests' ||
291
+ lower === 'anthropic-ratelimit-requests-limit' ||
292
+ lower === 'openai-ratelimit-limit-requests' ||
293
+ lower === 'ratelimit-limit'
294
+ ) {
295
+ const n = Number(String(v));
296
+ if (Number.isFinite(n)) limit = n;
297
+ } else if (
298
+ lower === 'x-ratelimit-reset' ||
299
+ lower === 'x-ratelimit-reset-requests' ||
300
+ lower === 'anthropic-ratelimit-requests-reset' ||
301
+ lower === 'ratelimit-reset'
302
+ ) {
303
+ const n = Number(String(v));
304
+ if (Number.isFinite(n)) {
305
+ reset = n;
306
+ } else {
307
+ const parsed = Date.parse(v);
308
+ if (!Number.isNaN(parsed)) reset = parsed;
265
309
  }
266
310
  }
267
311
  }
268
- if (remaining === undefined && limit === undefined) return null;
269
- return { remaining, limit, reset };
312
+ if (remaining === undefined && limit === undefined && retryAfter === undefined && reset === undefined) return null;
313
+ const out = { remaining, limit, reset };
314
+ if (retryAfter !== undefined) out.retryAfter = retryAfter;
315
+ return out;
270
316
  }
271
317
 
272
- /** True if remaining is below the given threshold fraction of limit (e.g. 0.1). */
318
+ /** True if remaining is below the given threshold fraction of limit (e.g. 0.1) or retry-after is active. */
273
319
  export function isRateLimited(rate, threshold = 0.1) {
274
320
  if (!rate) return false;
275
- if (rate.remaining === undefined) return false;
276
- if (rate.limit && rate.limit > 0) return rate.remaining < rate.limit * threshold;
277
- return rate.remaining <= 0;
321
+ if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) return true;
322
+ if (rate.remaining !== undefined && rate.remaining <= 1) return true;
323
+ if (rate.limit && rate.limit > 0 && rate.remaining !== undefined) {
324
+ return rate.remaining < rate.limit * threshold;
325
+ }
326
+ if (rate.remaining !== undefined) return rate.remaining <= 0;
327
+ return false;
328
+ }
329
+
330
+ /**
331
+ * Sort or prioritize an attempt list of refs according to routing strategy.
332
+ * Strategies:
333
+ * - 'round-robin': preserves order
334
+ * - 'least-loaded': keys with lowest active concurrency count first
335
+ * - 'lowest-latency': keys with lowest p95 (or avg) latency first; unsampled keys neutral
336
+ */
337
+ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
338
+ if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
339
+ const list = refs.slice();
340
+ if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
341
+ return list.sort((a, b) => {
342
+ const ca = deps.concurrencyTracker.getActive(a) ?? 0;
343
+ const cb = deps.concurrencyTracker.getActive(b) ?? 0;
344
+ return ca - cb;
345
+ });
346
+ }
347
+ if (strategy === 'lowest-latency' && deps.latencyHistogram && typeof deps.latencyHistogram.snapshot === 'function') {
348
+ return list.sort((a, b) => {
349
+ const sa = deps.latencyHistogram.snapshot(a);
350
+ const sb = deps.latencyHistogram.snapshot(b);
351
+ const la = (sa && Number.isFinite(sa.p95)) ? sa.p95 : ((sa && Number.isFinite(sa.avg)) ? sa.avg : 500);
352
+ const lb = (sb && Number.isFinite(sb.p95)) ? sb.p95 : ((sb && Number.isFinite(sb.avg)) ? sb.avg : 500);
353
+ return la - lb;
354
+ });
355
+ }
356
+ return list;
278
357
  }
279
358
 
280
359
  /**
@@ -386,7 +465,7 @@ export function budgetVerdict(spend, budget) {
386
465
  /** Safely reset a provider's circuit breaker to closed state. */
387
466
  export function resetCircuitForProvider(circuitBreaker, provider) {
388
467
  if (!circuitBreaker || !provider) return false;
389
- try {
468
+ const ok = bestEffort('resetCircuitForProvider', () => {
390
469
  if (typeof circuitBreaker.reset === 'function') {
391
470
  circuitBreaker.reset(provider);
392
471
  return true;
@@ -395,6 +474,7 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
395
474
  circuitBreaker.onSuccess(provider);
396
475
  return true;
397
476
  }
398
- } catch (_) {}
399
- return false;
477
+ return false;
478
+ });
479
+ return ok === true;
400
480
  }
package/lib/rotate.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  extractRateLimit,
10
10
  isRateLimited,
11
11
  formatExhaustionMessage,
12
+ sortAttemptList,
12
13
  } from './pool.js';
13
14
  import { pickCascadeFallback } from './cascade.js';
14
15
 
@@ -25,6 +26,7 @@ export function createRotate(deps) {
25
26
  notifySwitch,
26
27
  notifyExhaustion,
27
28
  recordLatency,
29
+ latencyHistogram,
28
30
  concurrencyTracker,
29
31
  MARKER,
30
32
  finishError,
@@ -42,7 +44,11 @@ export function createRotate(deps) {
42
44
  const reqStore = { pool, pickedRef: undefined, startMs: now() };
43
45
  setRotateStartMs(reqStore.startMs);
44
46
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
45
- if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
47
+ const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
48
+ if (strategy !== 'round-robin') {
49
+ attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
50
+ }
51
+ if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
46
52
  // #193: prefer least-loaded key within limit
47
53
  const available = attemptList.filter((r) => {
48
54
  const fu = pool.state.failedUntil.get(r) ?? 0;
@@ -115,122 +121,136 @@ export function createRotate(deps) {
115
121
  }
116
122
 
117
123
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
118
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
124
+ const acquired = (_pickedRef && runtime0.concurrencyLimit > 0) ? concurrencyTracker.acquire(_pickedRef) : false;
119
125
  try {
120
- for await (const chunk of inner) {
121
- // Only actual content deltas lock the stream (no more rotation).
122
- // Structural/metadata chunks (block-start/end, usage) do not.
123
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
124
- yielded = true;
125
- yield chunk;
126
- continue;
127
- }
128
- if (chunk && chunk.type === 'finish') {
129
- const kind = chunk.reason?.kind;
130
- const failure = chunk.reason?.failure;
131
- const code = failure?.code;
132
- const message = failure?.message ?? '';
133
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
134
- const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
135
- const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
136
- if (switchable) {
137
- penalizeRef(activeRef, code ?? 'UNKNOWN', message);
138
- pool.state.switches = (pool.state.switches ?? 0) + 1;
139
- pool.state.lastReason = String(code ?? 'UNKNOWN');
140
- pool.state.lastSwitchAt = now();
141
- lastFailure = chunk;
142
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
143
- // #216: per-switch webhook (opt-in switchNotify), deduped per provider
144
- if (switchNotify && activeRef) {
145
- notifySwitch(runtime0, pool, {
146
- provider: options.provider,
147
- from: activeRef,
148
- code: String(code ?? 'UNKNOWN'),
149
- at: pool.state.lastSwitchAt,
150
- });
151
- }
152
- switching = true;
153
- break;
126
+ try {
127
+ for await (const chunk of inner) {
128
+ // Only actual content deltas lock the stream (no more rotation).
129
+ // Structural/metadata chunks (block-start/end, usage) do not.
130
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
131
+ yielded = true;
132
+ yield chunk;
133
+ continue;
154
134
  }
155
- // cost tracking if provider returns usage.cost
156
- const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
157
- if (chunk.usage?.cost != null && activeRef) {
158
- const c = Number(chunk.usage.cost);
159
- if (!isNaN(c)) {
160
- if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
161
- pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
162
- // #208: cost per day per key (mirrors usageDays) for budget checks
163
- if (!pool.state.costDays) pool.state.costDays = new Map();
164
- const cMap = pool.state.costDays.get(activeRef) || new Map();
165
- cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
166
- pool.state.costDays.set(activeRef, cMap);
135
+ if (chunk && chunk.type === 'finish') {
136
+ const kind = chunk.reason?.kind;
137
+ const failure = chunk.reason?.failure;
138
+ const code = failure?.code;
139
+ const message = failure?.message ?? '';
140
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
141
+ const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
142
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
143
+ if (switchable) {
144
+ penalizeRef(activeRef, code ?? 'UNKNOWN', message);
145
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
146
+ pool.state.lastReason = String(code ?? 'UNKNOWN');
147
+ pool.state.lastSwitchAt = now();
148
+ lastFailure = chunk;
149
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
150
+ // #216: per-switch webhook (opt-in switchNotify), deduped per provider
151
+ if (switchNotify && activeRef) {
152
+ notifySwitch(runtime0, pool, {
153
+ provider: options.provider,
154
+ from: activeRef,
155
+ code: String(code ?? 'UNKNOWN'),
156
+ at: pool.state.lastSwitchAt,
157
+ });
158
+ }
159
+ switching = true;
160
+ break;
167
161
  }
168
- }
169
- // Usage by day (#119)
170
- if (activeRef) {
171
- if (!pool.state.usageDays) pool.state.usageDays = new Map();
172
- const dayMap = pool.state.usageDays.get(activeRef) || new Map();
173
- dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
174
- pool.state.usageDays.set(activeRef, dayMap);
175
- }
176
- // Per-model request detail (#121)
177
- if (activeRef && options.model) {
178
- if (!pool.state.byModel) pool.state.byModel = new Map();
179
- let byRef = pool.state.byModel.get(activeRef);
180
- if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
181
- byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
182
- }
183
- // Proactive rate-limit (#115): if response headers say this key is near
184
- // its quota, cool it down so the NEXT request starts on a different key.
185
- // We do NOT re-run this (already successful) request — that would double-send.
186
- const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
187
- if (rate && activeRef) {
188
- if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
189
- const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
190
- recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
191
- pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
192
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
162
+ // cost tracking if provider returns usage.cost
163
+ const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
164
+ if (chunk.usage?.cost != null && activeRef) {
165
+ const c = Number(chunk.usage.cost);
166
+ if (!isNaN(c)) {
167
+ if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
168
+ pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
169
+ // #208: cost per day per key (mirrors usageDays) for budget checks
170
+ if (!pool.state.costDays) pool.state.costDays = new Map();
171
+ const cMap = pool.state.costDays.get(activeRef) || new Map();
172
+ cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
173
+ pool.state.costDays.set(activeRef, cMap);
174
+ }
193
175
  }
194
- }
195
- // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
196
- if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
197
- quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
176
+ // Usage by day (#119)
177
+ if (activeRef) {
178
+ if (!pool.state.usageDays) pool.state.usageDays = new Map();
179
+ const dayMap = pool.state.usageDays.get(activeRef) || new Map();
180
+ dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
181
+ pool.state.usageDays.set(activeRef, dayMap);
182
+ }
183
+ // Per-model request detail (#121)
184
+ if (activeRef && options.model) {
185
+ if (!pool.state.byModel) pool.state.byModel = new Map();
186
+ let byRef = pool.state.byModel.get(activeRef);
187
+ if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
188
+ byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
189
+ }
190
+ // Proactive rate-limit (#115, #303): if response headers say this key is near
191
+ // its quota or has retry-after, cool it down so the NEXT request starts on a different key.
192
+ // We do NOT re-run this (already successful) request — that would double-send.
193
+ const guardEnabled = pool.proactiveRateLimitGuard ?? runtime0.proactiveRateLimitGuard ?? true;
194
+ const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
195
+ if (guardEnabled && rate && activeRef) {
196
+ if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
197
+ let cool;
198
+ if (typeof rate.retryAfter === 'number' && rate.retryAfter > 0) {
199
+ cool = rate.retryAfter * 1000;
200
+ } else if (rate.reset && rate.reset > now()) {
201
+ cool = rate.reset - now();
202
+ } else {
203
+ cool = pool.cooldownMs ?? cooldownMs;
204
+ }
205
+ const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
206
+ const effCool = Math.min(cool, maxCool ?? cool);
207
+ recordFailure(pool, activeRef, now(), effCool, maxCool);
208
+ pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
209
+ console.warn(`[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`);
210
+ }
211
+ }
212
+ // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
213
+ if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
214
+ quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
215
+ }
216
+ yield chunk;
217
+ recordLatency(pool, reqStore);
218
+ if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
219
+ return;
198
220
  }
199
221
  yield chunk;
200
- recordLatency(pool, reqStore);
201
- if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
202
- return;
203
222
  }
204
- yield chunk;
205
- }
206
- } catch (e) {
207
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
208
- const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
209
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
210
- if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
211
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
212
- pool.state.switches = (pool.state.switches ?? 0) + 1;
213
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
214
- pool.state.lastSwitchAt = now();
215
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
216
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
217
- if (switchNotify && activeRef) {
218
- notifySwitch(runtime0, pool, {
219
- provider: options.provider,
220
- from: activeRef,
221
- code: String(e?.code ?? 'TRANSPORT'),
222
- at: pool.state.lastSwitchAt,
223
- });
223
+ } catch (e) {
224
+ const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
225
+ const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
226
+ if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
227
+ penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
228
+ pool.state.switches = (pool.state.switches ?? 0) + 1;
229
+ pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
230
+ pool.state.lastSwitchAt = now();
231
+ lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
232
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
233
+ if (switchNotify && activeRef) {
234
+ notifySwitch(runtime0, pool, {
235
+ provider: options.provider,
236
+ from: activeRef,
237
+ code: String(e?.code ?? 'TRANSPORT'),
238
+ at: pool.state.lastSwitchAt,
239
+ });
240
+ }
241
+ continue; // Failover to next key!
224
242
  }
225
- continue; // Failover to next key!
243
+ yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
244
+ return;
226
245
  }
227
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
228
- return;
229
- }
230
246
 
231
- if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
232
- if (switching) continue; // try the next key
233
- return; // clean end — served
247
+ if (switching) continue; // try the next key
248
+ return; // clean end — served
249
+ } finally {
250
+ if (acquired && _pickedRef) {
251
+ concurrencyTracker.release(_pickedRef);
252
+ }
253
+ }
234
254
  }
235
255
 
236
256
  // pool exhausted — all keys cooling or missing
package/lib/routes-ops.js CHANGED
@@ -24,6 +24,7 @@ import { findSecrets, looksLikeApiSecret } from './keycheck.js';
24
24
  import { nextQuotaReset } from './quota-window.js';
25
25
  import { classifyFailure } from './error-taxonomy.js';
26
26
  import { sanitizeSnapshot } from './sanitize-snapshot.js';
27
+ import { bestEffort } from './best-effort.js';
27
28
 
28
29
  const STATUS_PATH = '/dsh-key-rotation/status';
29
30
  const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
@@ -163,6 +164,8 @@ export function registerOpsRoutes(ctx, deps) {
163
164
  budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
164
165
  budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
165
166
  pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
167
+ routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
168
+ proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
166
169
  });
167
170
  } catch (e) {
168
171
  console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
@@ -314,7 +317,7 @@ export function registerOpsRoutes(ctx, deps) {
314
317
  st.brokenUntil?.delete(ref);
315
318
  if (st.lastUsed === ref) st.lastUsed = undefined;
316
319
  }
317
- try { lastTestCache?.delete?.(ref); } catch (_) {}
320
+ bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
318
321
  json(res, 200, { ok: true, ref });
319
322
  return;
320
323
  }
@@ -480,7 +483,7 @@ export function registerOpsRoutes(ctx, deps) {
480
483
  const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
481
484
  const tail = valid ? keyTail(effectiveValue) : '';
482
485
  let source = null;
483
- try { const d = await base?.describe?.(ref); source = d?.source ?? null; } catch {}
486
+ await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
484
487
  if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
485
488
  if (!present && testValue) { source = 'pre-save'; }
486
489
  else if (!present) {
package/lib/sandbox.js CHANGED
@@ -83,7 +83,15 @@ export class SandboxRunner {
83
83
  res = await doFetch();
84
84
  } catch (e) {
85
85
  if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
86
- return { ok: false, code: 'network', latencyMs: Date.now() - started };
86
+ // ponytail: 1 retry on transient socket network errors before marking broken
87
+ await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
88
+ if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
89
+ try {
90
+ res = await doFetch();
91
+ } catch (e2) {
92
+ if (e2 && e2.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
93
+ return { ok: false, code: 'network', latencyMs: Date.now() - started };
94
+ }
87
95
  }
88
96
  // ponytail: 1 retry on 5xx — naive; classifier refines if needed
89
97
  if (res.status >= 500 && res.status < 600) {
package/lib/webhook.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
2
3
  export const WEBHOOK_TIMEOUT_MS = 5000;
3
4
  export const WEBHOOK_MIN_INTERVAL_MS = 1000;
@@ -187,7 +188,7 @@ export class AlertDebouncer {
187
188
 
188
189
  const res = await this._sender.send(url, payload);
189
190
  for (const cb of entry.callbacks) {
190
- try { cb(res); } catch (_) {}
191
+ bestEffort('webhook.callback', () => { cb(res); }, this._logger);
191
192
  }
192
193
  return res;
193
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.8",
3
+ "version": "0.8.11",
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": [
@@ -41,7 +41,10 @@
41
41
  },
42
42
  "client": {
43
43
  "platform": "web",
44
- "inject": []
44
+ "inject": [
45
+ "@deepseek-ai/dsh-client-locale",
46
+ "@deepseek-ai/dsh-client-ui-settings"
47
+ ]
45
48
  }
46
49
  },
47
50
  "license": "MIT",