@goodandready/dsh-key-rotation 0.8.46 → 0.8.48

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.48 - 2026-10-02
4
+
5
+ ### Fixed
6
+ - **Debouncer Throttle Interval Preservation & Delivery Guarantee (#428)**: Ensure `AlertDebouncer.prototype.flush` respects `WebhookSender` inter-batch throttling window (`_minIntervalMs`, default 1000ms) between consecutive batch deliveries across high-burst traffic (e.g. 20 rapid exhaustion events splitting into two 10-incident batches), preventing secondary batches from receiving `throttled: true` drops and guaranteeing all accepted digest alerts are delivered without loss.
7
+
8
+ ## 0.8.47 - 2026-10-02
9
+
10
+ ### Fixed
11
+ - **Pool Exhaustion Alerts Coalescing into Digest (#428)**: Pass hooks (`webhookSender`, `notifyQueue`, `now`) to `notifyExhaustion` in `createRotate` options in `lib/index.js`, ensuring rapid multi-pool exhaustions route through `NotifyQueue` and `AlertDebouncer` into a single consolidated digest rather than falling back to default direct sender where secondary alerts get dropped by the 1-req/s throttle.
12
+ - **Queue Admission Bound & Debouncer Send Serialization (#435)**: Track unsettled events (`_unsettled`) in `NotifyQueue` across debounced send lifecycles to strictly enforce admission limit (`maxDepth`) during burst traffic without bypassing queue capacity, and serialize batched HTTP deliveries in `AlertDebouncer.prototype.flush` via `_sendLock` to ensure single-flight send concurrency (`peak <= 1`).
13
+
3
14
  ## 0.8.46 - 2026-10-02
4
15
 
5
16
  ### Fixed
package/lib/index.js CHANGED
@@ -409,7 +409,7 @@ export function apply(ctx, config = {}) {
409
409
  schedulePersist,
410
410
  pushEvent,
411
411
  notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
412
- notifyExhaustion,
412
+ notifyExhaustion: (runtime, pool, options) => notifyExhaustion(runtime, pool, options, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
413
413
  recordLatency,
414
414
  latencyHistogram,
415
415
  concurrencyTracker,
@@ -14,6 +14,7 @@ export class NotifyQueue {
14
14
  this._q = [];
15
15
  this._busy = false;
16
16
  this._activeSends = 0;
17
+ this._unsettled = 0;
17
18
  this._backoffByUrl = new Map();
18
19
  this._dropped = 0;
19
20
  this._sent = 0;
@@ -22,10 +23,11 @@ export class NotifyQueue {
22
23
 
23
24
  enqueue(url, payload) {
24
25
  if (!url) return { queued: false, reason: "no-url" };
25
- if (this._q.length >= this.maxDepth) {
26
+ if (this._unsettled >= this.maxDepth + 1 || this._q.length >= this.maxDepth) {
26
27
  this._dropped += 1;
27
28
  return { queued: false, reason: "full", dropped: this._dropped };
28
29
  }
30
+ this._unsettled += 1;
29
31
  this._q.push({ url, payload });
30
32
  this._pump();
31
33
  return { queued: true };
@@ -67,6 +69,7 @@ export class NotifyQueue {
67
69
  this._failed += 1;
68
70
  this._bumpBackoff(job.url);
69
71
  } finally {
72
+ this._unsettled = Math.max(0, this._unsettled - 1);
70
73
  if (!sendPromise || !sendPromise.debounced) {
71
74
  this._busy = false;
72
75
  this._activeSends = 0;
@@ -91,6 +94,12 @@ export class NotifyQueue {
91
94
  }
92
95
 
93
96
  stats() {
94
- return { depth: this._q.length, dropped: this._dropped, sent: this._sent, failed: this._failed, busy: this._busy };
97
+ return {
98
+ depth: this._q.length,
99
+ dropped: this._dropped,
100
+ sent: this._sent,
101
+ failed: this._failed,
102
+ busy: this._busy || this._activeSends > 0 || (this._unsettled >= this.maxDepth + 1)
103
+ };
95
104
  }
96
105
  }
package/lib/webhook.js CHANGED
@@ -264,10 +264,34 @@ export class AlertDebouncer {
264
264
  };
265
265
  }
266
266
 
267
- const res = await this._sender.send(url, payload);
268
- for (const cb of entry.callbacks) {
269
- bestEffort('webhook.callback', () => { cb(res); }, this._logger);
270
- }
271
- return res;
267
+ const sendBatch = async () => {
268
+ if (this._sender && typeof this._sender._lastSentAt?.get === 'function') {
269
+ const last = this._sender._lastSentAt.get(url);
270
+ const minInterval = Number.isFinite(this._sender._minIntervalMs) ? this._sender._minIntervalMs : 1000;
271
+ if (Number.isFinite(last)) {
272
+ const elapsed = Date.now() - last;
273
+ if (elapsed < minInterval) {
274
+ await new Promise((r) => setTimeout(r, minInterval - elapsed + 20));
275
+ }
276
+ }
277
+ }
278
+ let res = await this._sender.send(url, payload);
279
+ while (res && res.throttled) {
280
+ const last = (this._sender && typeof this._sender._lastSentAt?.get === 'function')
281
+ ? this._sender._lastSentAt.get(url) : null;
282
+ const minInterval = (this._sender && this._sender._minIntervalMs) || 1000;
283
+ const now = Date.now();
284
+ const wait = Number.isFinite(last) ? Math.max(50, minInterval - (now - last) + 20) : minInterval;
285
+ await new Promise((r) => setTimeout(r, wait));
286
+ res = await this._sender.send(url, payload);
287
+ }
288
+ for (const cb of entry.callbacks) {
289
+ bestEffort('webhook.callback', () => { cb(res); }, this._logger);
290
+ }
291
+ return res;
292
+ };
293
+
294
+ this._sendLock = (this._sendLock || Promise.resolve()).then(sendBatch, sendBatch);
295
+ return this._sendLock;
272
296
  }
273
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.46",
3
+ "version": "0.8.48",
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": [