@askalf/dario 6.0.38 → 6.0.40

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/dist/pool.js CHANGED
@@ -88,6 +88,27 @@ export function describeRateLimitSnapshot(rl, now = Date.now()) {
88
88
  : `resets in ${formatDurationMs(resetInMs)}`;
89
89
  return `5h ${pct(rl.util5h)}, 7d ${pct(rl.util7d)}, claim ${rl.claim}, ${reset}`;
90
90
  }
91
+ /**
92
+ * The log line for a 429 as `markRejected` classified it: an exhausted window
93
+ * parks the seat until its reset; anything else cools briefly and says which
94
+ * stated reset was NOT honoured, so the operator can see the reading dario
95
+ * declined to act on.
96
+ */
97
+ /** `th***@example.com` — enough to recognise an account by eye, never the whole address on a listing or a log. */
98
+ export function maskEmail(email) {
99
+ if (typeof email !== 'string' || !email.includes('@'))
100
+ return null;
101
+ const [user, domain] = email.split('@', 2);
102
+ return `${(user ?? '').slice(0, 2)}***@${domain ?? ''}`;
103
+ }
104
+ export function describeRejection(rl, now = Date.now()) {
105
+ if (rl.exhausted !== false)
106
+ return `${describeRateLimitSnapshot(rl, now)} — parked until the window rolls`;
107
+ const { resetInMs } = rateLimitWindow(rl, now);
108
+ const stated = resetInMs === null ? 'no reset stated' : `stated reset in ${formatDurationMs(resetInMs)} not honoured`;
109
+ const cool = Math.max(0, (rl.cooldownUntil ?? now) - now);
110
+ return `429 without an exhausted window (5h ${Math.round(rl.util5h * 100)}%, 7d ${Math.round(rl.util7d * 100)}%, claim ${rl.claim}; ${stated}) — cooling ${formatDurationMs(cool)}, seat stays probeable`;
111
+ }
91
112
  function formatDurationMs(ms) {
92
113
  const totalMins = Math.max(1, Math.round(ms / 60_000));
93
114
  const h = Math.floor(totalMins / 60);
@@ -95,59 +116,51 @@ function formatDurationMs(ms) {
95
116
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
96
117
  }
97
118
  /**
98
- * The identity of the rate-limit window a reading was measured against:
99
- * its representative claim plus its reset second, or null when the reading
100
- * states no live window (no reset, a reset that has passed, or no claim).
119
+ * For every seat, the other aliases that are the SAME Anthropic account —
120
+ * one subscription under several aliases (dario#1244).
101
121
  *
102
- * Two seats that report the same key are one subscription under two aliases
103
- * (dario#1244, "a few have the same issue"): two independent windows all but
104
- * never share a reset second, and two readings of one window always do. The
105
- * organization id is deliberately NOT part of the key — several seats can
106
- * share an organization and still have their own windows — the window itself
107
- * is the fact that matters for headroom.
122
+ * Identity, not inference. The first version of this keyed seats on
123
+ * `claim@reset` — "two independent windows all but never share a reset
124
+ * second". They do: Anthropic aligns the five-hour reset to a 20-minute grid
125
+ * (dario#1263 — two demonstrably different accounts, both resetting at
126
+ * exactly :40:00), so a five-hour window has 15 possible reset seconds and a
127
+ * pool of 18 seats is GUARANTEED collisions. That heuristic told an operator
128
+ * seven independent colleagues were one subscription. `accountId` is the
129
+ * account uuid the OAuth grant belongs to (accounts.ts, `fetchOAuthProfile`);
130
+ * two seats with the same one are the same account, full stop, and two
131
+ * seats without one are simply not yet identified — never guessed.
108
132
  */
109
- export function windowKey(rl, now) {
110
- if (!(rl.reset > 0) || rl.reset * 1000 <= now)
111
- return null;
112
- if (!rl.claim || rl.claim === 'unknown')
113
- return null;
114
- return `${rl.claim}@${rl.reset}`;
115
- }
116
- /** For every seat, the other aliases whose last reading names the same live window. */
117
- export function windowPeers(accounts, now) {
118
- const byKey = new Map();
133
+ export function accountPeers(accounts) {
134
+ const byId = new Map();
119
135
  for (const a of accounts) {
120
- const k = windowKey(a.rateLimit, now);
121
- if (!k)
136
+ if (!a.accountId)
122
137
  continue;
123
- const list = byKey.get(k);
138
+ const list = byId.get(a.accountId);
124
139
  if (list)
125
140
  list.push(a.alias);
126
141
  else
127
- byKey.set(k, [a.alias]);
142
+ byId.set(a.accountId, [a.alias]);
128
143
  }
129
144
  const out = new Map();
130
145
  for (const a of accounts) {
131
- const k = windowKey(a.rateLimit, now);
132
- out.set(a.alias, k ? (byKey.get(k) ?? []).filter((alias) => alias !== a.alias) : []);
146
+ out.set(a.alias, a.accountId ? (byId.get(a.accountId) ?? []).filter((alias) => alias !== a.alias) : []);
133
147
  }
134
148
  return out;
135
149
  }
136
150
  /**
137
- * How many windows the pool really has: each measured live window once, and
138
- * each seat without a live reading as its own (nothing says otherwise yet).
151
+ * How many accounts the pool really has: each identified account once, and
152
+ * each seat not yet identified as its own (nothing says otherwise).
139
153
  */
140
- export function distinctWindows(accounts, now) {
141
- const keys = new Set();
142
- let unmeasured = 0;
154
+ export function distinctAccounts(accounts) {
155
+ const ids = new Set();
156
+ let unidentified = 0;
143
157
  for (const a of accounts) {
144
- const k = windowKey(a.rateLimit, now);
145
- if (k)
146
- keys.add(k);
158
+ if (a.accountId)
159
+ ids.add(a.accountId);
147
160
  else
148
- unmeasured++;
161
+ unidentified++;
149
162
  }
150
- return keys.size + unmeasured;
163
+ return ids.size + unidentified;
151
164
  }
152
165
  /**
153
166
  * Cool-down schedule after auth failures. First failure: 60s. Each
@@ -212,6 +225,10 @@ export const TOKEN_EXPIRY_MARGIN_MS = 30_000;
212
225
  * would push a genuinely throttled account back into rotation.
213
226
  */
214
227
  export function rateLimitWindowPassed(rl, now = Date.now()) {
228
+ // A rejection that named no exhausted window is over when its cool-down is:
229
+ // the stated reset was never this seat's window (see RateLimitSnapshot.exhausted).
230
+ if (rl.exhausted === false)
231
+ return !(rl.cooldownUntil !== undefined && now < rl.cooldownUntil);
215
232
  return rl.reset > 0 && rl.reset * 1000 <= now;
216
233
  }
217
234
  /**
@@ -259,7 +276,44 @@ export function isAccountEligible(account, now = Date.now()) {
259
276
  */
260
277
  export function isParkedInLiveWindow(account, now = Date.now()) {
261
278
  const rl = account.rateLimit;
262
- return rl.status === 'rejected' && rl.reset > 0 && rl.reset * 1000 > now;
279
+ // A non-exhausted rejection is never "parked in a live window": its reset
280
+ // was not this seat's window, and once its cool-down passes asking is the
281
+ // way back (the all-exhausted branch may probe it even sooner).
282
+ return rl.status === 'rejected' && rl.exhausted !== false && rl.reset > 0 && rl.reset * 1000 > now;
283
+ }
284
+ /**
285
+ * A seat rejected by a 429 that named no exhausted window, still inside the
286
+ * cool-down that rejection earned (see `RateLimitSnapshot.exhausted`). It is
287
+ * deliberately NOT "parked in a live window" — the reset it stated was never
288
+ * this seat's window — but it is equally not askable yet: the upstream said
289
+ * retry after N, and asking sooner is the retry storm the cool-down exists to
290
+ * prevent (dario#1264 review).
291
+ */
292
+ export function isCoolingAfterRejection(account, now = Date.now()) {
293
+ const rl = account.rateLimit;
294
+ return rl.status === 'rejected' && rl.exhausted === false
295
+ && rl.cooldownUntil !== undefined && now < rl.cooldownUntil;
296
+ }
297
+ /**
298
+ * Whether the router may send this seat a request RIGHT NOW, for reasons that
299
+ * expire on their own: an auth cool-down, a live rate-limit window, or the
300
+ * cool-down a non-window 429 earned.
301
+ *
302
+ * ONE predicate, filtered on by both fallback paths (the all-exhausted branch
303
+ * in `select()` and the mid-flight `selectExcluding()`). They carried the
304
+ * condition inline and independently, so `isCoolingAfterRejection` was added to
305
+ * neither — a seat that had just answered `retry-after: 17` could be retried
306
+ * inside the same client request. Caught in review on #1264; the shape of the
307
+ * bug is why this is a named predicate rather than two copies of a filter.
308
+ *
309
+ * Note this is NOT `isAccountEligible`: eligibility also refuses an expired
310
+ * token, which no amount of waiting fixes and which these paths handle
311
+ * separately.
312
+ */
313
+ export function isProbeable(account, now = Date.now()) {
314
+ return !isInAuthCooldown(account, now)
315
+ && !isParkedInLiveWindow(account, now)
316
+ && !isCoolingAfterRejection(account, now);
263
317
  }
264
318
  /**
265
319
  * The operator's next step for one seat, next to `status` on both listings
@@ -299,10 +353,70 @@ export function resolvePoolStrategy(explicit, env = process.env) {
299
353
  * normalized to lowercase to match `modelFamily()` output.
300
354
  */
301
355
  const PER_MODEL_7D_HEADER = /^anthropic-ratelimit-unified-7d_([a-z0-9-]+)-utilization$/i;
356
+ /** `…-7d_<bucket>-status`: `rejected` names the bucket that refused this request. */
357
+ const PER_MODEL_7D_STATUS_HEADER = /^anthropic-ratelimit-unified-7d_([a-z0-9-]+)-status$/i;
358
+ /**
359
+ * Which wire buckets bind which model family, when the wire does not say it
360
+ * by name. `7d_sonnet` names its family; `7d_oi` does not — it is the plan's
361
+ * INCLUDED-OVERAGE credit, and it is what Fable's weekly allowance is metered
362
+ * on (dario#1262):
363
+ *
364
+ * - 2026-07-05, live Max account: Fable drew `representative-claim:
365
+ * seven_day_overage_included` at 7d 82% with `7d_oi` at 99%, served at $0;
366
+ * at `7d_oi` ≥ 1.0 Fable answered a hard 429 (`7d_oi-status: rejected`,
367
+ * `7d_oi-surpassed-threshold: 1.0`) while Opus kept serving.
368
+ * - 2026-09-08 (#1262): a Fable response at `7d 0.63, 7d_oi 0.98` — headroom
369
+ * read 0.37 against a seat two points from refusal.
370
+ *
371
+ * So for Fable, `oi` IS the binding weekly bucket, under a name that is not
372
+ * "fable". This seed makes that true from the first response. Bindings for
373
+ * other families are LEARNED per account from the wire (see
374
+ * `parseRateLimits`): a response whose claim is `*_overage_included`, or a 429
375
+ * whose `7d_<bucket>-status` is `rejected`, proves that bucket binds the family
376
+ * that request was for. Nothing here maps a family to `oi` by assumption:
377
+ * a seat drawing on included overage for Opus is bound by `oi` for Opus
378
+ * exactly when its responses say so.
379
+ */
380
+ export const WIRE_BUCKET_BINDINGS = {
381
+ oi: ['fable'],
382
+ };
383
+ /** Parse `retry-after`: delta-seconds or an HTTP date; null when absent or unreadable. */
384
+ export function parseRetryAfterMs(value, now = Date.now()) {
385
+ if (!value)
386
+ return null;
387
+ const secs = Number(value);
388
+ if (Number.isFinite(secs) && secs >= 0)
389
+ return Math.round(secs * 1000);
390
+ const at = Date.parse(value);
391
+ return Number.isFinite(at) ? Math.max(0, at - now) : null;
392
+ }
393
+ /** Union of two readings' learned bindings, per family, order-preserving. */
394
+ export function mergeBoundBuckets(prev, next) {
395
+ if (!prev)
396
+ return next;
397
+ if (!next)
398
+ return prev;
399
+ const out = {};
400
+ for (const src of [prev, next]) {
401
+ for (const [family, buckets] of Object.entries(src)) {
402
+ const list = out[family] ?? (out[family] = []);
403
+ for (const b of buckets)
404
+ if (!list.includes(b))
405
+ list.push(b);
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+ /** `next` with everything the account had already learned carried forward. */
411
+ export function withBoundBuckets(prev, next) {
412
+ const merged = mergeBoundBuckets(prev.boundBuckets, next.boundBuckets);
413
+ return merged ? { ...next, boundBuckets: merged } : next;
414
+ }
302
415
  /** Parse an Anthropic response's rate-limit headers into a snapshot. */
303
- export function parseRateLimits(headers) {
416
+ export function parseRateLimits(headers, family) {
304
417
  const get = (key) => headers.get(`anthropic-ratelimit-unified-${key}`) ?? '';
305
418
  const perModel7d = {};
419
+ const rejectedBuckets = [];
306
420
  // Iterate the full header set — `headers.get` only retrieves known
307
421
  // keys, but Anthropic can add new `7d_<family>-utilization` shapes
308
422
  // unannounced. Scanning the iterator means the parser is automatically
@@ -316,7 +430,28 @@ export function parseRateLimits(headers) {
316
430
  const m = k.match(PER_MODEL_7D_HEADER);
317
431
  if (m && m[1]) {
318
432
  perModel7d[m[1].toLowerCase()] = parseFloat(v) || 0;
433
+ continue;
319
434
  }
435
+ const st = k.match(PER_MODEL_7D_STATUS_HEADER);
436
+ if (st && st[1] && v.trim().toLowerCase() === 'rejected')
437
+ rejectedBuckets.push(st[1].toLowerCase());
438
+ }
439
+ const claim = get('representative-claim') || 'unknown';
440
+ // What THIS response proved about which bucket binds the request's family
441
+ // (see WIRE_BUCKET_BINDINGS). Only with a family to attribute to, and only
442
+ // on the wire's own say-so: an overage-included claim means the request was
443
+ // metered on `oi`; a bucket that reports itself rejected is the one that
444
+ // refused this family.
445
+ let boundBuckets;
446
+ if (family) {
447
+ const bound = [];
448
+ if (/_overage_included$/.test(claim) && perModel7d['oi'] !== undefined)
449
+ bound.push('oi');
450
+ for (const b of rejectedBuckets)
451
+ if (perModel7d[b] !== undefined && !bound.includes(b))
452
+ bound.push(b);
453
+ if (bound.length > 0)
454
+ boundBuckets = { [family]: bound };
320
455
  }
321
456
  return {
322
457
  status: get('status') || 'unknown',
@@ -328,8 +463,30 @@ export function parseRateLimits(headers) {
328
463
  reset: parseInt(get('reset')) || 0,
329
464
  fallbackPct: parseFloat(get('fallback-percentage')) || 0,
330
465
  updatedAt: Date.now(),
466
+ retryAfterMs: parseRetryAfterMs(headers.get('retry-after')),
467
+ ...(boundBuckets ? { boundBuckets } : {}),
331
468
  };
332
469
  }
470
+ /**
471
+ * Does this 429 say a rate-limit WINDOW is over? True only when the reading
472
+ * itself shows one: a utilization at or past the 1.0 threshold on any window
473
+ * or bucket (the unified headers are a ratio against
474
+ * `surpassed-threshold: 1.0`, so `1.02` is 102%; every live rejection observed
475
+ * has read 1.00–1.06). A 429 with a claim but 30% used, or with no claim and
476
+ * 0% used, is a refusal of some other kind — concurrency, an account-level
477
+ * lock, a monthly credit — and its stated `reset` is not the moment this
478
+ * seat's window rolls. 0.99 rather than 1 absorbs float formatting.
479
+ */
480
+ export function isWindowRejection(rl) {
481
+ // Utilization alone decides. The representative claim names WHICH window,
482
+ // and a real rejection carries one, but its absence must not turn a 104%
483
+ // reading into a mere cool-down — under-parking a genuinely exhausted seat
484
+ // re-probes it every minute, which is the loop #1254 removed.
485
+ const utils = [rl.util5h, rl.util7d, ...Object.values(rl.perModel7d)];
486
+ return utils.some((u) => u >= 0.99);
487
+ }
488
+ /** Cool-down for a 429 that named no exhausted window, when it stated no `retry-after`. */
489
+ export const NON_WINDOW_REJECTION_COOLDOWN_MS = 60_000;
333
490
  /**
334
491
  * Extract the model family (`opus` / `sonnet` / `haiku` / `fable`) from a
335
492
  * request's model id. Used to look up the per-model 7d bucket in
@@ -368,16 +525,34 @@ export function modelFamily(modelId) {
368
525
  * isn't represented in the snapshot (e.g. account hasn't seen a Sonnet
369
526
  * request yet so `7d_sonnet` is unknown), headroom is computed from the
370
527
  * unified buckets only — best-effort, populated on the next response.
528
+ *
529
+ * A bucket counts for a family when it names the family (`7d_sonnet`), when
530
+ * `WIRE_BUCKET_BINDINGS` says it binds the family (`7d_oi` → fable, dario#1262),
531
+ * or when this account's responses have shown it does (`boundBuckets`).
371
532
  */
372
533
  export function computeHeadroom(snapshot, family) {
373
534
  const utils = [snapshot.util5h, snapshot.util7d];
374
535
  if (family) {
375
- const perModel = snapshot.perModel7d[family];
376
- if (perModel !== undefined)
377
- utils.push(perModel);
536
+ for (const bucket of bucketsBindingFamily(snapshot, family)) {
537
+ const util = snapshot.perModel7d[bucket];
538
+ if (util !== undefined)
539
+ utils.push(util);
540
+ }
378
541
  }
379
542
  return 1 - Math.max(...utils);
380
543
  }
544
+ /** Every bucket name that binds `family` for this reading — by name, by seed, or as learned. */
545
+ export function bucketsBindingFamily(snapshot, family) {
546
+ const out = [family];
547
+ for (const [bucket, families] of Object.entries(WIRE_BUCKET_BINDINGS)) {
548
+ if (families.includes(family) && !out.includes(bucket))
549
+ out.push(bucket);
550
+ }
551
+ for (const bucket of snapshot.boundBuckets?.[family] ?? [])
552
+ if (!out.includes(bucket))
553
+ out.push(bucket);
554
+ return out;
555
+ }
381
556
  const STICKY_IDLE_TTL_MS = 6 * 60 * 60 * 1000; // reap a binding 6h after its LAST use, not its creation
382
557
  const STICKY_MAX_ENTRIES = 2_000; // lazy cleanup cap
383
558
  const STICKY_CLEANUP_INTERVAL_MS = 30_000; // amortize the O(n) TTL/orphan sweep
@@ -454,12 +629,22 @@ export class AccountPool {
454
629
  expiresAt: opts.expiresAt,
455
630
  grantedAt: opts.grantedAt ?? keep?.grantedAt,
456
631
  organizationId: opts.organizationId ?? keep?.organizationId,
632
+ accountId: opts.accountId ?? keep?.accountId,
633
+ accountEmail: opts.accountEmail ?? keep?.accountEmail,
634
+ rateLimitTier: opts.rateLimitTier ?? keep?.rateLimitTier,
635
+ seatTier: opts.seatTier ?? keep?.seatTier,
457
636
  adoptedFrom: keep?.adoptedFrom,
458
- identity: keep?.identity ?? {
459
- deviceId: opts.deviceId,
460
- accountUuid: opts.accountUuid,
461
- sessionId: randomUUID(),
462
- },
637
+ // The record's client identity is authoritative: `dario accounts identity
638
+ // --fresh` rewrites it on disk, and the running seat must present the new
639
+ // one on its next request rather than the old one until a restart. The
640
+ // session id is the seat's own and is kept.
641
+ identity: keep?.identity && keep.identity.deviceId === opts.deviceId && keep.identity.accountUuid === opts.accountUuid
642
+ ? keep.identity
643
+ : {
644
+ deviceId: opts.deviceId,
645
+ accountUuid: opts.accountUuid,
646
+ sessionId: keep?.identity?.sessionId ?? randomUUID(),
647
+ },
463
648
  rateLimit: keep?.rateLimit ?? { ...EMPTY_SNAPSHOT },
464
649
  requestCount: keep?.requestCount ?? 0,
465
650
  rejectedCount: keep?.rejectedCount ?? 0,
@@ -554,7 +739,7 @@ export class AccountPool {
554
739
  // What is left — a rejection with no stated reset (nothing to expire, so
555
740
  // asking is the only way back) or an expiring token — is tried least-used
556
741
  // first, as before.
557
- const probeable = all.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
742
+ const probeable = all.filter(a => isProbeable(a, now));
558
743
  if (probeable.length === 0)
559
744
  return null;
560
745
  return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
@@ -572,13 +757,22 @@ export class AccountPool {
572
757
  if (this.accounts.size === 0)
573
758
  return null;
574
759
  const all = [...this.accounts.values()];
575
- if (!all.every(a => isParkedInLiveWindow(a, now)))
760
+ // A seat cooling after a non-window 429 counts here: it is over a rate
761
+ // limit of some kind and it comes back on its own, which is exactly what
762
+ // this answer means. Without it a pool of cooling seats reported "not
763
+ // parked", fell past the local-429, and spent a probe that could only
764
+ // 429 again (dario#1264 review). An auth cool-down or an expired token
765
+ // still does NOT count — those are not rate limits and must not be
766
+ // reported, or cooled, as if they were.
767
+ if (!all.every(a => isParkedInLiveWindow(a, now) || isCoolingAfterRejection(a, now)))
576
768
  return null;
577
- return Math.min(...all.map(a => a.rateLimit.reset * 1000));
769
+ return Math.min(...all.map(a => isParkedInLiveWindow(a, now)
770
+ ? a.rateLimit.reset * 1000
771
+ : a.rateLimit.cooldownUntil ?? now));
578
772
  }
579
- /** Seats currently parked inside a live window (dario#1244). */
773
+ /** Seats a rate limit is currently keeping out of rotation (dario#1244, #1264). */
580
774
  parkedCount(now = Date.now()) {
581
- return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now)).length;
775
+ return [...this.accounts.values()].filter(a => isParkedInLiveWindow(a, now) || isCoolingAfterRejection(a, now)).length;
582
776
  }
583
777
  /**
584
778
  * Select with session stickiness. If `stickyKey` is already bound to a
@@ -699,7 +893,7 @@ export class AccountPool {
699
893
  // parked inside a live window is not one of them — on the dario#1244
700
894
  // gateway every request walked all six parked seats, six guaranteed 429s
701
895
  // a request. Cool-downs are skipped for the same reason.
702
- const probeable = candidates.filter(a => !isInAuthCooldown(a, now) && !isParkedInLiveWindow(a, now));
896
+ const probeable = candidates.filter(a => isProbeable(a, now));
703
897
  if (probeable.length > 0) {
704
898
  return probeable.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
705
899
  }
@@ -709,7 +903,7 @@ export class AccountPool {
709
903
  const account = this.accounts.get(alias);
710
904
  if (!account)
711
905
  return;
712
- account.rateLimit = snapshot;
906
+ account.rateLimit = withBoundBuckets(account.rateLimit, snapshot);
713
907
  account.adoptedFrom = undefined;
714
908
  account.requestCount++;
715
909
  }
@@ -726,7 +920,22 @@ export class AccountPool {
726
920
  return false;
727
921
  const now = snapshot.updatedAt || Date.now();
728
922
  const wasParked = account.rateLimit.status === 'rejected' && !rateLimitWindowPassed(account.rateLimit, now);
729
- account.rateLimit = { ...snapshot, status: 'rejected' };
923
+ // The status code says "no"; the headers say WHY. Only a 429 that names an
924
+ // exhausted window is parked until that window's reset. Any other 429 —
925
+ // no claim, or a claim with utilization nowhere near the threshold — is
926
+ // not this seat's window rolling, whatever `reset` it states (the fleet box
927
+ // parked a seat for 546 hours on `5h 0%, 7d 0%, claim unknown`). It cools for
928
+ // the upstream's own `retry-after`, or a minute, and stays probeable.
929
+ const exhausted = isWindowRejection(snapshot);
930
+ const merged = withBoundBuckets(account.rateLimit, snapshot);
931
+ account.rateLimit = exhausted
932
+ ? { ...merged, status: 'rejected', exhausted: true }
933
+ : {
934
+ ...merged,
935
+ status: 'rejected',
936
+ exhausted: false,
937
+ cooldownUntil: now + (snapshot.retryAfterMs ?? NON_WINDOW_REJECTION_COOLDOWN_MS),
938
+ };
730
939
  account.adoptedFrom = undefined;
731
940
  account.rejectedCount++;
732
941
  account.lastRejectedAt = now;
@@ -754,7 +963,8 @@ export class AccountPool {
754
963
  const account = this.accounts.get(alias);
755
964
  if (!account)
756
965
  return false;
757
- account.rateLimit = rejected ? { ...snapshot, status: 'rejected' } : { ...snapshot };
966
+ const merged = withBoundBuckets(account.rateLimit, snapshot);
967
+ account.rateLimit = rejected ? { ...merged, status: 'rejected' } : { ...merged };
758
968
  account.adoptedFrom = from;
759
969
  return true;
760
970
  }