@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 +14 -0
- package/lib/error-taxonomy.js +1 -1
- package/lib/index.js +6 -0
- package/lib/ops-analytics.js +1 -1
- package/lib/pool-builder.js +12 -0
- package/lib/pool.js +1 -0
- package/lib/rotate.js +39 -10
- package/lib/stream-watchdog.js +99 -0
- package/package.json +1 -1
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)
|
package/lib/error-taxonomy.js
CHANGED
|
@@ -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
|
|
package/lib/ops-analytics.js
CHANGED
|
@@ -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')) {
|
package/lib/pool-builder.js
CHANGED
|
@@ -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
|
|
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,
|
|
440
|
-
recordFailureCategory(pool,
|
|
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 =
|
|
471
|
+
pool.state.lastReason = failCode;
|
|
443
472
|
pool.state.lastSwitchAt = now();
|
|
444
|
-
lastFailure = finishError(
|
|
445
|
-
logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${
|
|
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:
|
|
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.
|
|
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": [
|