@goodandready/dsh-key-rotation 0.7.39 → 0.8.0
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/README.md +27 -11
- package/README.ru.md +33 -10
- package/README.zh.md +26 -3
- package/lib/atomic-io.js +60 -0
- package/lib/bounded-map.js +65 -0
- package/lib/circuit-breaker.js +93 -0
- package/lib/clock.js +24 -0
- package/lib/error-taxonomy.js +66 -0
- package/lib/http-bridge.js +169 -0
- package/lib/index.js +97 -944
- package/lib/notify-queue.js +75 -0
- package/lib/pool.js +2 -1
- package/lib/rotate.js +261 -0
- package/lib/routes-ops.js +604 -0
- package/package.json +3 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// lib/notify-queue.js — non-blocking webhook dispatch with backoff (#263).
|
|
2
|
+
// rotate() must never await webhook I/O.
|
|
3
|
+
|
|
4
|
+
export class NotifyQueue {
|
|
5
|
+
/**
|
|
6
|
+
* @param {{ send: (url:string, payload:object)=>Promise<any>, maxDepth?: number, baseBackoffMs?: number, maxBackoffMs?: number }} opts
|
|
7
|
+
*/
|
|
8
|
+
constructor({ send, maxDepth = 50, baseBackoffMs = 2000, maxBackoffMs = 60000 } = {}) {
|
|
9
|
+
if (typeof send !== 'function') throw new Error('NotifyQueue: send required');
|
|
10
|
+
this._send = send;
|
|
11
|
+
this.maxDepth = maxDepth;
|
|
12
|
+
this.baseBackoffMs = baseBackoffMs;
|
|
13
|
+
this.maxBackoffMs = maxBackoffMs;
|
|
14
|
+
this._q = [];
|
|
15
|
+
this._busy = false;
|
|
16
|
+
this._backoffByUrl = new Map();
|
|
17
|
+
this._dropped = 0;
|
|
18
|
+
this._sent = 0;
|
|
19
|
+
this._failed = 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
enqueue(url, payload) {
|
|
23
|
+
if (!url) return { queued: false, reason: 'no-url' };
|
|
24
|
+
if (this._q.length >= this.maxDepth) {
|
|
25
|
+
this._dropped += 1;
|
|
26
|
+
return { queued: false, reason: 'full', dropped: this._dropped };
|
|
27
|
+
}
|
|
28
|
+
this._q.push({ url, payload });
|
|
29
|
+
this._pump();
|
|
30
|
+
return { queued: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_pump() {
|
|
34
|
+
if (this._busy) return;
|
|
35
|
+
const job = this._q.shift();
|
|
36
|
+
if (!job) return;
|
|
37
|
+
this._busy = true;
|
|
38
|
+
const delay = this._backoffByUrl.get(job.url) ?? 0;
|
|
39
|
+
const run = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await this._send(job.url, job.payload);
|
|
42
|
+
if (res && res.sent === false) {
|
|
43
|
+
this._failed += 1;
|
|
44
|
+
this._bumpBackoff(job.url);
|
|
45
|
+
} else {
|
|
46
|
+
this._sent += 1;
|
|
47
|
+
this._backoffByUrl.delete(job.url);
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
this._failed += 1;
|
|
51
|
+
this._bumpBackoff(job.url);
|
|
52
|
+
} finally {
|
|
53
|
+
this._busy = false;
|
|
54
|
+
if (this._q.length > 0) this._pump();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (delay > 0) {
|
|
58
|
+
const t = setTimeout(run, delay);
|
|
59
|
+
if (typeof t.unref === 'function') t.unref();
|
|
60
|
+
} else {
|
|
61
|
+
// fire and forget — do not return a promise to callers
|
|
62
|
+
run();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_bumpBackoff(url) {
|
|
67
|
+
const cur = this._backoffByUrl.get(url) ?? 0;
|
|
68
|
+
const next = cur === 0 ? this.baseBackoffMs : Math.min(cur * 2, this.maxBackoffMs);
|
|
69
|
+
this._backoffByUrl.set(url, next);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stats() {
|
|
73
|
+
return { depth: this._q.length, dropped: this._dropped, sent: this._sent, failed: this._failed, busy: this._busy };
|
|
74
|
+
}
|
|
75
|
+
}
|
package/lib/pool.js
CHANGED
|
@@ -288,7 +288,8 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
288
288
|
const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
|
|
289
289
|
const message = String(failure.message ?? failureOrPayload.message ?? '');
|
|
290
290
|
|
|
291
|
-
// 1. Direct HTTP status codes
|
|
291
|
+
// 1. Direct HTTP status codes (#267: also 408/425 transient)
|
|
292
|
+
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;
|
|
292
293
|
if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
|
|
293
294
|
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
|
|
294
295
|
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
package/lib/rotate.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// lib/rotate.js — stream rotation / failover generator (#253).
|
|
2
|
+
// Pure wiring helper: dependencies are injected so unit tests can supply mocks.
|
|
3
|
+
import { nowMono } from './clock.js';
|
|
4
|
+
import { classifyFailure } from './error-taxonomy.js';
|
|
5
|
+
import {
|
|
6
|
+
isSwitchableError,
|
|
7
|
+
recordFailure,
|
|
8
|
+
parseRetryAfter,
|
|
9
|
+
extractRateLimit,
|
|
10
|
+
isRateLimited,
|
|
11
|
+
formatExhaustionMessage,
|
|
12
|
+
} from './pool.js';
|
|
13
|
+
import { pickCascadeFallback } from './cascade.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create the rotate(options, pool) async generator used by llm/stream.
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
*/
|
|
19
|
+
export function createRotate(deps) {
|
|
20
|
+
const {
|
|
21
|
+
ctx,
|
|
22
|
+
dispatchStorage,
|
|
23
|
+
buildRuntime,
|
|
24
|
+
pushEvent,
|
|
25
|
+
notifySwitch,
|
|
26
|
+
notifyExhaustion,
|
|
27
|
+
recordLatency,
|
|
28
|
+
concurrencyTracker,
|
|
29
|
+
MARKER,
|
|
30
|
+
finishError,
|
|
31
|
+
setRotateStartMs,
|
|
32
|
+
quotaStore,
|
|
33
|
+
circuitBreaker,
|
|
34
|
+
now = nowMono,
|
|
35
|
+
} = deps;
|
|
36
|
+
|
|
37
|
+
function rotate(options, pool) {
|
|
38
|
+
return (async function* () {
|
|
39
|
+
const runtime0 = buildRuntime();
|
|
40
|
+
const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
|
|
41
|
+
let lastFailure = null;
|
|
42
|
+
const reqStore = { pool, pickedRef: undefined, startMs: now() };
|
|
43
|
+
setRotateStartMs(reqStore.startMs);
|
|
44
|
+
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
45
|
+
if (runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
|
|
46
|
+
// #193: prefer least-loaded key within limit
|
|
47
|
+
const available = attemptList.filter((r) => {
|
|
48
|
+
const fu = pool.state.failedUntil.get(r) ?? 0;
|
|
49
|
+
if (fu > now()) return false;
|
|
50
|
+
const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
|
|
51
|
+
if (exp !== undefined && now() >= exp) return false;
|
|
52
|
+
return true;
|
|
53
|
+
});
|
|
54
|
+
const preferred = concurrencyTracker.pickLeastLoaded(available);
|
|
55
|
+
if (preferred && attemptList[0] !== preferred) {
|
|
56
|
+
const list = attemptList.slice();
|
|
57
|
+
const i = list.indexOf(preferred);
|
|
58
|
+
if (i > 0) { list.splice(i, 1); list.unshift(preferred); }
|
|
59
|
+
attemptList = list;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const penalizeRef = (targetRef, errCode, errMsg) => {
|
|
64
|
+
if (!targetRef) return;
|
|
65
|
+
const _retry = parseRetryAfter(errMsg);
|
|
66
|
+
const _base = pool.cooldownMs ?? cooldownMs;
|
|
67
|
+
const _max = pool.maxCooldownMs ?? maxCooldownMs;
|
|
68
|
+
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
69
|
+
const cls = classifyFailure({ code: errCode, message: errMsg });
|
|
70
|
+
const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft);
|
|
71
|
+
if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
|
|
72
|
+
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
73
|
+
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
74
|
+
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
75
|
+
const _cStr = String(errCode ?? '');
|
|
76
|
+
if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
|
|
77
|
+
const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
|
|
78
|
+
pool.state.authFailCounts.set(targetRef, _c2);
|
|
79
|
+
if (_c2 >= 3) {
|
|
80
|
+
pool.state.brokenUntil.set(targetRef, now() + 86400000 * 30);
|
|
81
|
+
pool.state.failedUntil.set(targetRef, now() + 86400000 * 30);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
pool.state.authFailCounts.delete(targetRef);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// #260: fail fast when provider circuit is open
|
|
89
|
+
if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
|
|
90
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
|
|
91
|
+
yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (let attempt = 0; attempt < attemptList.length; attempt++) {
|
|
96
|
+
let yielded = false;
|
|
97
|
+
let switching = false;
|
|
98
|
+
let inner;
|
|
99
|
+
try {
|
|
100
|
+
// mark the internal dispatch so the interceptor does not re-rotate
|
|
101
|
+
inner = dispatchStorage.run(reqStore, () => ctx.llm.stream({ ...options, [MARKER]: true }));
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
104
|
+
penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
|
|
105
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
106
|
+
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
107
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
112
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.acquire(_pickedRef);
|
|
113
|
+
try {
|
|
114
|
+
for await (const chunk of inner) {
|
|
115
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
116
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
117
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
118
|
+
yielded = true;
|
|
119
|
+
yield chunk;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (chunk && chunk.type === 'finish') {
|
|
123
|
+
const kind = chunk.reason?.kind;
|
|
124
|
+
const failure = chunk.reason?.failure;
|
|
125
|
+
const code = failure?.code;
|
|
126
|
+
const message = failure?.message ?? '';
|
|
127
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
128
|
+
const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
|
|
129
|
+
const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
|
|
130
|
+
if (switchable) {
|
|
131
|
+
penalizeRef(activeRef, code ?? 'UNKNOWN', message);
|
|
132
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
133
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
134
|
+
pool.state.lastSwitchAt = now();
|
|
135
|
+
lastFailure = chunk;
|
|
136
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
|
|
137
|
+
// #216: per-switch webhook (opt-in switchNotify), deduped per provider
|
|
138
|
+
if (switchNotify && activeRef) {
|
|
139
|
+
notifySwitch(runtime0, pool, {
|
|
140
|
+
provider: options.provider,
|
|
141
|
+
from: activeRef,
|
|
142
|
+
code: String(code ?? 'UNKNOWN'),
|
|
143
|
+
at: pool.state.lastSwitchAt,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
switching = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
// cost tracking if provider returns usage.cost
|
|
150
|
+
const todayIso = activeRef ? new Date().toISOString().slice(0, 10) : undefined;
|
|
151
|
+
if (chunk.usage?.cost != null && activeRef) {
|
|
152
|
+
const c = Number(chunk.usage.cost);
|
|
153
|
+
if (!isNaN(c)) {
|
|
154
|
+
if (!pool.state.costPerKey) pool.state.costPerKey = new Map();
|
|
155
|
+
pool.state.costPerKey.set(activeRef, (pool.state.costPerKey.get(activeRef) ?? 0) + c);
|
|
156
|
+
// #208: cost per day per key (mirrors usageDays) for budget checks
|
|
157
|
+
if (!pool.state.costDays) pool.state.costDays = new Map();
|
|
158
|
+
const cMap = pool.state.costDays.get(activeRef) || new Map();
|
|
159
|
+
cMap.set(todayIso, (cMap.get(todayIso) ?? 0) + c);
|
|
160
|
+
pool.state.costDays.set(activeRef, cMap);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Usage by day (#119)
|
|
164
|
+
if (activeRef) {
|
|
165
|
+
if (!pool.state.usageDays) pool.state.usageDays = new Map();
|
|
166
|
+
const dayMap = pool.state.usageDays.get(activeRef) || new Map();
|
|
167
|
+
dayMap.set(todayIso, (dayMap.get(todayIso) ?? 0) + 1);
|
|
168
|
+
pool.state.usageDays.set(activeRef, dayMap);
|
|
169
|
+
}
|
|
170
|
+
// Per-model request detail (#121)
|
|
171
|
+
if (activeRef && options.model) {
|
|
172
|
+
if (!pool.state.byModel) pool.state.byModel = new Map();
|
|
173
|
+
let byRef = pool.state.byModel.get(activeRef);
|
|
174
|
+
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
175
|
+
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
// Proactive rate-limit (#115): if response headers say this key is near
|
|
178
|
+
// its quota, cool it down so the NEXT request starts on a different key.
|
|
179
|
+
// We do NOT re-run this (already successful) request — that would double-send.
|
|
180
|
+
const rate = extractRateLimit(chunk?.metadata?.headers ?? chunk?.headers);
|
|
181
|
+
if (rate && activeRef) {
|
|
182
|
+
if (isRateLimited(rate, rateLimitThreshold ?? 0.1)) {
|
|
183
|
+
const cool = rate.reset && rate.reset > now() ? (rate.reset - now()) : pool.cooldownMs;
|
|
184
|
+
recordFailure(pool, activeRef, now(), cool, pool.maxCooldownMs);
|
|
185
|
+
pushEvent(pool, activeRef, 'RATE_LIMIT', cool);
|
|
186
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} near quota (remaining ${String(rate.remaining)}/${String(rate.limit)}) — next request will rotate`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
|
|
190
|
+
if (rate && activeRef && Number.isFinite(rate.remaining) && quotaStore) {
|
|
191
|
+
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: now() });
|
|
192
|
+
}
|
|
193
|
+
yield chunk;
|
|
194
|
+
recordLatency(pool, reqStore);
|
|
195
|
+
if (circuitBreaker) circuitBreaker.onSuccess(options.provider);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
yield chunk;
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
202
|
+
const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
|
|
203
|
+
const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
|
|
204
|
+
if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
|
|
205
|
+
penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
206
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
207
|
+
pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
|
|
208
|
+
pool.state.lastSwitchAt = now();
|
|
209
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
210
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
|
|
211
|
+
if (switchNotify && activeRef) {
|
|
212
|
+
notifySwitch(runtime0, pool, {
|
|
213
|
+
provider: options.provider,
|
|
214
|
+
from: activeRef,
|
|
215
|
+
code: String(e?.code ?? 'TRANSPORT'),
|
|
216
|
+
at: pool.state.lastSwitchAt,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
continue; // Failover to next key!
|
|
220
|
+
}
|
|
221
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (_pickedRef && runtime0.concurrencyLimit > 0) concurrencyTracker.release(_pickedRef);
|
|
226
|
+
if (switching) continue; // try the next key
|
|
227
|
+
return; // clean end — served
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// pool exhausted — all keys cooling or missing
|
|
231
|
+
pool.state.lastExhaustionAt = now();
|
|
232
|
+
pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
|
|
233
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
|
|
234
|
+
const runtime = buildRuntime();
|
|
235
|
+
// notify via extracted helper (see notifyExhaustion above)
|
|
236
|
+
notifyExhaustion(runtime, pool, { provider: options.provider });
|
|
237
|
+
|
|
238
|
+
// #194: cross-provider cascade failover (guarded against infinite recursion)
|
|
239
|
+
if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
|
|
240
|
+
const pools = runtime.providerToPool;
|
|
241
|
+
const fb = pickCascadeFallback(options.provider, runtime, pools);
|
|
242
|
+
if (fb && fb.pool && fb.pool !== pool) {
|
|
243
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
|
|
244
|
+
pool.state.lastReason = 'CASCADE';
|
|
245
|
+
pool.state.lastSwitchAt = now();
|
|
246
|
+
// Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
|
|
247
|
+
const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
|
|
248
|
+
for await (const chunk of innerCascade) {
|
|
249
|
+
yield chunk;
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const exhaustionMsg = formatExhaustionMessage(options.provider, pool);
|
|
256
|
+
yield lastFailure ?? finishError('QUOTA', exhaustionMsg);
|
|
257
|
+
})();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return rotate;
|
|
261
|
+
}
|