@goodandready/dsh-key-rotation 0.8.84 → 0.8.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.85 (2026-11-03)
4
+
5
+ ### Inter-Token Stall & Zombie Stream Detector (Block 12 Release)
6
+ - **Watchdog Stream Protection (Issue #569)**: Added `withStreamWatchdog` and `StreamStallError` (`src/stream-watchdog.js`) to continuously monitor active token streams and protect against zombie HTTP connections and provider gateway hangs.
7
+ - **Differentiated Reasoning & Inter-Token Timeouts**: Configurable `stallInitialTimeoutMs` (default: 30s) prevents false positives during initial reasoning generation for DeepSeek R1 and OpenAI o1/o3, while `stallTimeoutMs` (default: 15s) immediately aborts stalled streams when tokens stop arriving mid-response.
8
+ - **Failover & Cooldown Integration (`src/rotate.js`)**: If a stall occurs before any token has yielded, the key is penalized and failover immediately rotates to the next available provider candidate. If a mid-stream stall occurs after partial output, the key is penalized and a structured `STREAM_STALLED` terminal chunk is delivered, keeping the conversation from hanging.
9
+ - **Config & Taxonomy Support**: Added `stallDetectionEnabled`, `stallTimeoutMs`, and `stallInitialTimeoutMs` to Schemastery `SettingsSchema` and `PoolConfig` types, and integrated `STREAM_STALLED` into error taxonomy and 24-hour operations analytics as a recoverable `TIMEOUT`.
10
+
11
+ ## 0.8.84 (2026-10-31)
12
+
13
+ ### DSH 0.2.2 Cordis Service Isolation Compatibility (Hotfix Release)
14
+ - **DSH 0.2.2 Strict Isolation (Issue #567, #568, GitHub #27)**: Resolved Cordis service access without explicit inject by introducing scoped inject guards for `webServer`, `settings`, and optional services across all HTTP routes, lifecycle hooks, and plugin updater.
15
+ - **Zero-Breakage Backward Compatibility**: Retained full compatibility with DSH 0.2.1 and headless environments while ensuring strict adherence to Cordis 0.2.2+ isolation rules.
16
+
3
17
  ## 0.8.83 (2026-10-31)
4
18
 
5
19
  ### TypeScript Migration & Type-Safe Core Architecture (Block 11 Release)
@@ -95,7 +95,7 @@ export function classifyFailure(failureOrPayload) {
95
95
  if (SURFACE_STATUS.has(status)) {
96
96
  return { action: 'surface', code: String(status), soft: false, reason: 'client' };
97
97
  }
98
- if (code === 'TIMEOUT' || code === 'DEADLINE_EXCEEDED' || /timeout/i.test(message)) {
98
+ if (code === 'TIMEOUT' || code === 'DEADLINE_EXCEEDED' || code === 'STREAM_STALLED' || /timeout|stream stalled/i.test(message)) {
99
99
  return { action: 'switch', code: 'TIMEOUT', soft: true, reason: 'timeout' };
100
100
  }
101
101
  if (SOCKET_SWITCH.has(code) || /ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|premature close/i.test(message)) {
package/lib/index.js CHANGED
@@ -137,6 +137,9 @@ const SettingsSchema = Schema.object({
137
137
  warnBelowHealthy: Schema.number().default(0),
138
138
  latencySloMs: Schema.number().default(0),
139
139
  showChatHeaderChip: Schema.boolean().default(true),
140
+ stallDetectionEnabled: Schema.boolean().default(true),
141
+ stallTimeoutMs: Schema.number().default(15000),
142
+ stallInitialTimeoutMs: Schema.number().default(30000),
140
143
  providers: Schema.array(Schema.object({
141
144
  provider: Schema.string().required(),
142
145
  keys: Schema.array(Schema.string()).default([]),
@@ -166,6 +169,9 @@ const SettingsSchema = Schema.object({
166
169
  proactiveRateLimitGuard: Schema.boolean(),
167
170
  rpmLimit: Schema.number(),
168
171
  tpmLimit: Schema.number(),
172
+ stallDetectionEnabled: Schema.boolean(),
173
+ stallTimeoutMs: Schema.number(),
174
+ stallInitialTimeoutMs: Schema.number(),
169
175
  })).default([...DEFAULT_PROVIDERS]),
170
176
  });
171
177
 
@@ -18,7 +18,7 @@ export function classifyErrorCategory(code, message = '') {
18
18
  if (/^5\d\d$/.test(c) || c === 'SERVER' || c === 'BAD_GATEWAY' || c === 'SERVICE_UNAVAILABLE' || m.includes('internal server error')) {
19
19
  return 'server';
20
20
  }
21
- if (c.includes('TIMEOUT') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out')) {
21
+ if (c.includes('TIMEOUT') || c.includes('STALL') || c === 'ETIMEDOUT' || c === 'ESOCKETTIMEDOUT' || m.includes('timeout') || m.includes('timed out') || m.includes('stalled')) {
22
22
  return 'timeout';
23
23
  }
24
24
  if (c === 'QUOTA' || c === '402' || c.includes('QUOTA') || m.includes('quota') || m.includes('credit') || m.includes('insufficient_quota')) {
@@ -41,6 +41,9 @@ export function buildPoolItem({
41
41
  maxQueueWaitMs = null,
42
42
  maxAttempts = null,
43
43
  maxDurationMs = null,
44
+ stallDetectionEnabled = true,
45
+ stallTimeoutMs = 15000,
46
+ stallInitialTimeoutMs = 30000,
44
47
  }) {
45
48
  const seen = new Set();
46
49
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -151,6 +154,9 @@ const weightsMap = {};
151
154
  maxDurationMs: typeof maxDurationMs === 'number' && Number.isFinite(maxDurationMs) && maxDurationMs > 0
152
155
  ? Math.floor(maxDurationMs)
153
156
  : 0,
157
+ stallDetectionEnabled: stallDetectionEnabled ?? true,
158
+ stallTimeoutMs: typeof stallTimeoutMs === 'number' && Number.isFinite(stallTimeoutMs) && stallTimeoutMs > 0 ? Math.floor(stallTimeoutMs) : 15000,
159
+ stallInitialTimeoutMs: typeof stallInitialTimeoutMs === 'number' && Number.isFinite(stallInitialTimeoutMs) && stallInitialTimeoutMs > 0 ? Math.floor(stallInitialTimeoutMs) : 30000,
154
160
  };
155
161
  }
156
162
 
@@ -198,6 +204,9 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
198
204
  maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
199
205
  maxAttempts: poolAttempts,
200
206
  maxDurationMs: poolDuration,
207
+ stallDetectionEnabled: p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
208
+ stallTimeoutMs: p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
209
+ stallInitialTimeoutMs: p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
201
210
  }, provRpm, provTpm, provConcurrency);
202
211
  if (pool) {
203
212
  for (const ref of pool.refs) {
@@ -230,6 +239,9 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
230
239
  maxQueueWaitMs: mp.maxQueueWaitMs ?? p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
231
240
  maxAttempts: typeof mp.maxAttempts === 'number' ? mp.maxAttempts : poolAttempts,
232
241
  maxDurationMs: typeof mp.maxDurationMs === 'number' ? mp.maxDurationMs : poolDuration,
242
+ stallDetectionEnabled: mp.stallDetectionEnabled ?? p.stallDetectionEnabled ?? cfg.stallDetectionEnabled ?? true,
243
+ stallTimeoutMs: mp.stallTimeoutMs ?? p.stallTimeoutMs ?? cfg.stallTimeoutMs ?? 15000,
244
+ stallInitialTimeoutMs: mp.stallInitialTimeoutMs ?? p.stallInitialTimeoutMs ?? cfg.stallInitialTimeoutMs ?? 30000,
233
245
  }, modelRpm, modelTpm, modelConcurrency);
234
246
  if (!mpool) continue;
235
247
  mpool.basePool = pool;
package/lib/pool.js CHANGED
@@ -491,6 +491,7 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
491
491
  let rawCode = String(failure.code ?? failure.reason ?? '').trim();
492
492
  let code = rawCode.toUpperCase();
493
493
  if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
494
+ if (code === 'STREAM_STALLED' || code === 'STALL_TIMEOUT' || code === 'INTER_TOKEN_STALL') code = 'TIMEOUT';
494
495
  const message = String(failure.message ?? failureOrPayload.message ?? '');
495
496
 
496
497
  // Treat numeric failure.code (e.g. code: "429") as HTTP status if status was not set (#545)
package/lib/rotate.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
 
27
27
  import { LOCAL_POOL_EXHAUSTED_CODE, resolveKeyDirectly } from './resolver.js';
28
28
  import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-metrics.js';
29
+ import { withStreamWatchdog, StreamStallError } from './stream-watchdog.js';
29
30
  import { penalizeKey } from './rotate-penalty.js';
30
31
  import { recordPromptCache } from './prompt-cache.js';
31
32
  import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
@@ -310,8 +311,33 @@ export function createRotate(deps) {
310
311
  }
311
312
 
312
313
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
314
+ let stallAbort = null;
315
+ if (typeof AbortController !== 'undefined') {
316
+ stallAbort = new AbortController();
317
+ if (budget?.signal) {
318
+ if (budget.signal.aborted) {
319
+ try { stallAbort.abort(budget.signal.reason); } catch {}
320
+ } else {
321
+ budget.signal.addEventListener('abort', () => {
322
+ try { stallAbort?.abort(budget.signal.reason); } catch {}
323
+ }, { once: true });
324
+ }
325
+ }
326
+ }
327
+ const guardedStream = withStreamWatchdog(
328
+ preserveDispatchContext(inner, dispatchStorage, reqStore),
329
+ {
330
+ stallDetectionEnabled: pool.stallDetectionEnabled ?? runtime0.stallDetectionEnabled ?? true,
331
+ stallTimeoutMs: pool.stallTimeoutMs ?? runtime0.stallTimeoutMs ?? 15000,
332
+ stallInitialTimeoutMs: pool.stallInitialTimeoutMs ?? runtime0.stallInitialTimeoutMs ?? 30000,
333
+ abortController: stallAbort,
334
+ onStall: (err) => {
335
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(_pickedRef ?? '?')} stalled (${err.message})`);
336
+ },
337
+ }
338
+ );
313
339
  try {
314
- for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
340
+ for await (const chunk of guardedStream) {
315
341
  if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
316
342
  const fallbackRef = reqStore.pickedRef ?? _pickedRef;
317
343
  if (fallbackRef) {
@@ -434,33 +460,36 @@ export function createRotate(deps) {
434
460
  } catch (e) {
435
461
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
436
462
  const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
463
+ const isStall = e instanceof StreamStallError || e?.code === 'STREAM_STALLED' || e?.isStall;
437
464
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
465
+ const failCode = isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT');
466
+ const catCode = isStall ? 'TIMEOUT' : failCode;
438
467
  preTokenBuffer.length = 0; // Discard metadata from failed attempt (#527)
439
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
440
- recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
468
+ penalizeRef(activeRef, catCode, String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
469
+ recordFailureCategory(pool, catCode, String(e?.message ?? e), activeRef, now());
441
470
  pool.state.switches = (pool.state.switches ?? 0) + 1;
442
- pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
471
+ pool.state.lastReason = failCode;
443
472
  pool.state.lastSwitchAt = now();
444
- lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
445
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
473
+ lastFailure = finishError(failCode, String(e?.message ?? e));
474
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${failCode} - failover to next key`);
446
475
  if (switchNotify && activeRef) {
447
476
  notifySwitch(runtime0, pool, {
448
477
  provider: options.provider,
449
478
  from: activeRef,
450
- code: String(e?.code ?? 'TRANSPORT'),
479
+ code: failCode,
451
480
  at: pool.state.lastSwitchAt,
452
481
  });
453
482
  }
454
483
  continue; // Failover to next key!
455
484
  }
456
- if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
457
- penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
485
+ if (yielded && (isStall || isSwitchableError(e, effectiveSwitchCodes))) {
486
+ penalizeRef(activeRef, isStall ? 'TIMEOUT' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
458
487
  }
459
488
  if (!yielded) {
460
489
  for (const prev of preTokenBuffer) yield prev;
461
490
  preTokenBuffer.length = 0;
462
491
  }
463
- yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
492
+ yield finishError(isStall ? 'STREAM_STALLED' : (e?.code ?? 'TRANSPORT'), String(e?.message ?? e));
464
493
  return;
465
494
  }
466
495
 
@@ -0,0 +1,99 @@
1
+ // lib/stream-watchdog.js — Inter-token stall & zombie stream detector (#569).
2
+ // Monitors streaming chunk intervals and TTFT to prevent infinite agent hangs.
3
+
4
+ export class StreamStallError extends Error {
5
+ constructor(message, elapsedMs, chunkCount) {
6
+ super(message || `Stream stalled: no tokens received for ${elapsedMs}ms`);
7
+ this.name = 'StreamStallError';
8
+ this.code = 'STREAM_STALLED';
9
+ this.elapsedMs = elapsedMs;
10
+ this.chunkCount = chunkCount;
11
+ this.isStall = true;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Wraps an async iterable stream with an inter-chunk stall watchdog.
17
+ *
18
+ * @param {AsyncIterable<any>} innerStream
19
+ * @param {object} [options]
20
+ * @param {number} [options.stallTimeoutMs=15000]
21
+ * @param {number} [options.stallInitialTimeoutMs=30000]
22
+ * @param {boolean} [options.stallDetectionEnabled=true]
23
+ * @param {AbortController} [options.abortController]
24
+ * @param {Function} [options.onStall]
25
+ * @returns {AsyncGenerator<any>}
26
+ */
27
+ export async function* withStreamWatchdog(innerStream, options = {}) {
28
+ const {
29
+ stallTimeoutMs = 15000,
30
+ stallInitialTimeoutMs = 30000,
31
+ stallDetectionEnabled = true,
32
+ abortController = null,
33
+ onStall = null,
34
+ } = options;
35
+
36
+ if (!stallDetectionEnabled || (!stallTimeoutMs && !stallInitialTimeoutMs)) {
37
+ yield* innerStream;
38
+ return;
39
+ }
40
+
41
+ const iterator = innerStream[Symbol.asyncIterator]();
42
+ let chunksCount = 0;
43
+
44
+ try {
45
+ while (true) {
46
+ const currentTimeout = chunksCount === 0
47
+ ? (stallInitialTimeoutMs || stallTimeoutMs || 30000)
48
+ : (stallTimeoutMs || 15000);
49
+
50
+ let timerId = null;
51
+
52
+ const timerPromise = new Promise((_, reject) => {
53
+ timerId = setTimeout(() => {
54
+ const err = new StreamStallError(
55
+ `Stream stalled: no tokens received for ${currentTimeout}ms (chunks: ${chunksCount})`,
56
+ currentTimeout,
57
+ chunksCount
58
+ );
59
+ if (abortController && typeof abortController.abort === 'function') {
60
+ try { abortController.abort(err); } catch {}
61
+ }
62
+ if (typeof onStall === 'function') {
63
+ try { onStall(err); } catch {}
64
+ }
65
+ reject(err);
66
+ }, currentTimeout);
67
+ });
68
+
69
+ // Suppress unhandled rejection if iterator.next() finishes first
70
+ timerPromise.catch(() => {});
71
+
72
+ let result;
73
+ try {
74
+ result = await Promise.race([
75
+ iterator.next(),
76
+ timerPromise,
77
+ ]);
78
+ } finally {
79
+ if (timerId !== null) {
80
+ clearTimeout(timerId);
81
+ timerId = null;
82
+ }
83
+ }
84
+
85
+ if (result.done) {
86
+ break;
87
+ }
88
+
89
+ chunksCount++;
90
+ yield result.value;
91
+ }
92
+ } finally {
93
+ if (typeof iterator.return === 'function') {
94
+ try {
95
+ await iterator.return();
96
+ } catch {}
97
+ }
98
+ }
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.84",
3
+ "version": "0.8.85",
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": [