@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.
@@ -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
+ }