maxpool 1.0.6 → 1.1.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 +22 -3
- package/package.json +1 -1
- package/src/account-manager.js +52 -2
- package/src/config.js +12 -5
- package/src/server.js +74 -13
package/README.md
CHANGED
|
@@ -6,6 +6,19 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
+
## ⚠️ Read this first: account risk and Anthropic's terms
|
|
10
|
+
|
|
11
|
+
This tool sits in a **contested gray area** of Anthropic's terms. Using it could put your Claude accounts at risk. Please read before installing.
|
|
12
|
+
|
|
13
|
+
- **Not blessed by Anthropic.** This is an independent project. Anthropic has not approved multi-account proxying, and nothing here is covered by an official "this is allowed" statement.
|
|
14
|
+
- **The terms can be read to prohibit it.** Anthropic's Consumer Terms (Section 3, automated access) and its February 2026 rule that subscription OAuth tokens are "only authorized for use with Claude Code" can reasonably be read to cover a proxy that selects and injects tokens across accounts. It is genuinely ambiguous, and the ambiguity is not in your favor.
|
|
15
|
+
- **Multiplexing is a detectable pattern.** Many accounts fronted by one machine, with parallel sessions and fast token rotation, looks like the anomaly Anthropic's automated systems flag, regardless of intent. People have lost accounts to pattern detection, and a clawback can hit all your pooled accounts at once.
|
|
16
|
+
- **Owned accounts only. No spoofing.** Only use accounts you personally own and pay for. Never share accounts across people (that is the clear violation), and never add IP/residential proxies, fingerprint spoofing, or MITM. Those are the documented ban triggers, and this fork deliberately leaves them out. PRs adding them will be rejected.
|
|
17
|
+
- **You are accepting the risk.** If you use this, you may lose accounts you pay for. This notice protects honesty, not your account.
|
|
18
|
+
- **Do your own research. This is not legal advice.** The terms have moved several times in 2026. Check the current terms yourself before relying on it.
|
|
19
|
+
|
|
20
|
+
If you want certainty, ask Anthropic support directly, or use **API-key accounts** (the sanctioned path for tooling) instead of subscription OAuth.
|
|
21
|
+
|
|
9
22
|
## Features
|
|
10
23
|
|
|
11
24
|
- **Rate-aware load balancing** — ranks accounts by remaining quota ÷ time-to-reset, not raw usage %, so a near-reset account with quota left is drained (use-it-or-lose-it) and one burning fast early in its window is spared; sequential traffic rotates across accounts instead of funnelling onto one
|
|
@@ -286,8 +299,11 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
|
|
|
286
299
|
"maxWaitMs": 86400000,
|
|
287
300
|
"autoMaxWaitMs": null,
|
|
288
301
|
"capacityMaxWaitMs": 900000,
|
|
302
|
+
"weeklyMaxWaitMs": 86400000,
|
|
303
|
+
"nonStreamMaxWaitMs": 300000,
|
|
304
|
+
"maxConcurrentQueued": 64,
|
|
305
|
+
"maxQueuedBytes": 1073741824,
|
|
289
306
|
"maxQueuedBodyBytes": 268435456,
|
|
290
|
-
"weeklyMaxWaitMs": 0,
|
|
291
307
|
"pollMs": 1000
|
|
292
308
|
},
|
|
293
309
|
"shutdown": {
|
|
@@ -325,7 +341,10 @@ TEAMCLAUDE_CONFIG=./my-config.json maxpool server
|
|
|
325
341
|
| `queue.autoMaxWaitMs` | Optional shorter auto-queue cap. Set to `null` or omit it to use `queue.maxWaitMs`; set a number for interactive sessions where you prefer fast errors |
|
|
326
342
|
| `queue.capacityMaxWaitMs` | Separate cap for repeated upstream 5xx/overload failures; defaults to 15m so broken providers do not park requests for 24h |
|
|
327
343
|
| `queue.maxQueuedBodyBytes` | Maximum request body Maxpool will hold in memory while waiting for capacity before the request has been sent upstream; defaults to 256 MiB |
|
|
328
|
-
| `queue.weeklyMaxWaitMs` |
|
|
344
|
+
| `queue.weeklyMaxWaitMs` | How long to hold a request when every account is at its weekly (7d) cap. Defaults to 24h — but the early-exit gates on each account's REAL reset time, so it only waits when a reset genuinely lands inside the window and errors honestly otherwise. (Was `0` = fail-fast, which killed sessions the instant all accounts hit their weekly cap.) |
|
|
345
|
+
| `queue.nonStreamMaxWaitMs` | Max hold for non-streaming requests; defaults to 5m. They have no SSE keepalive, so a longer hold would die on the client timeout anyway |
|
|
346
|
+
| `queue.maxConcurrentQueued` | Backpressure: max requests held waiting at once; defaults to 64. Beyond it, new waiters get a clear "queue full" error instead of growing the heap |
|
|
347
|
+
| `queue.maxQueuedBytes` | Backpressure: max aggregate buffered request-body bytes across all held requests; defaults to 1 GiB |
|
|
329
348
|
| `queue.pollMs` | How often queued requests check for a recovered account/provider |
|
|
330
349
|
| `queue.heartbeatMs` | SSE heartbeat interval for queued streaming requests; defaults to 10s so Claude Code keeps the queued connection alive |
|
|
331
350
|
| `shutdown.drainTimeoutMs` | Maximum time quit/Ctrl-C waits for active requests before exiting |
|
|
@@ -355,7 +374,7 @@ The weekly usage bar shows raw upstream utilization and reset timing. Reset-awar
|
|
|
355
374
|
11. In the `all` profile only, if all Claude accounts are unavailable, provider fallbacks are tried by priority: GLM before Kimi
|
|
356
375
|
12. If all eligible accounts/providers are temporarily unavailable for a temporary reason (5h/session limit, provider cooldown, short 429), the proxy queues the request and retries when one recovers
|
|
357
376
|
13. Repeated upstream 5xx/overload failures use the shorter `capacityMaxWaitMs` cap, not the long quota wait
|
|
358
|
-
14. Weekly exhaustion and
|
|
377
|
+
14. Weekly (7d) exhaustion is held and retried like a 5h cap, up to `weeklyMaxWaitMs`, but only when an account's real reset lands inside the window; if the soonest reset is beyond it (or unknown), it returns 429 promptly with an honest message naming the soonest reset rather than spinning. Non-retryable 4xx errors still fail fast
|
|
359
378
|
15. Temporary OAuth refresh failures cool the account down and queue/fail over; invalid refresh credentials disable only that account and require login
|
|
360
379
|
16. In an interactive terminal, the server runs under a foreground supervisor so confirmed Restart (`r`) can drain and restart without detaching the replacement TUI
|
|
361
380
|
17. When Restart is confirmed, new upstream admission pauses immediately. Existing upstream requests finish, queued requests cannot deadlock restart, and their sockets close during relaunch so Claude Code reconnects automatically
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -173,6 +173,7 @@ export class AccountManager {
|
|
|
173
173
|
waiting: [],
|
|
174
174
|
lastAdmissionAt: 0,
|
|
175
175
|
rampUntil: 0,
|
|
176
|
+
bytes: 0, // aggregate buffered body bytes across all held requests
|
|
176
177
|
};
|
|
177
178
|
this.admissionPaused = false;
|
|
178
179
|
}
|
|
@@ -197,6 +198,7 @@ export class AccountManager {
|
|
|
197
198
|
let soonestTemporary = Infinity;
|
|
198
199
|
let temporaryCause = null;
|
|
199
200
|
let soonestWeekly = Infinity;
|
|
201
|
+
let weeklyUnknownReset = 0; // weekly-exhausted accounts whose reset time we don't know yet
|
|
200
202
|
let matchingRoutes = 0;
|
|
201
203
|
const reasons = {};
|
|
202
204
|
|
|
@@ -244,6 +246,11 @@ export class AccountManager {
|
|
|
244
246
|
} else if (retry.cause === 'weekly_exhausted' && retry.retryAt) {
|
|
245
247
|
const ms = retry.retryAt - Date.now();
|
|
246
248
|
if (ms < soonestWeekly) soonestWeekly = ms;
|
|
249
|
+
} else if (retry.cause === 'weekly_exhausted' && !retry.retryAt) {
|
|
250
|
+
// Weekly-capped but we haven't learned the reset time (cold start /
|
|
251
|
+
// probe failure). We cannot estimate a wait — flag it so the caller
|
|
252
|
+
// emits an honest "reset time unknown" error instead of waiting forever.
|
|
253
|
+
weeklyUnknownReset++;
|
|
247
254
|
}
|
|
248
255
|
}
|
|
249
256
|
|
|
@@ -267,6 +274,16 @@ export class AccountManager {
|
|
|
267
274
|
};
|
|
268
275
|
}
|
|
269
276
|
|
|
277
|
+
if (weeklyUnknownReset > 0) {
|
|
278
|
+
return {
|
|
279
|
+
available: false,
|
|
280
|
+
retryAfterMs: Infinity,
|
|
281
|
+
cause: 'weekly_reset_unknown',
|
|
282
|
+
reasons,
|
|
283
|
+
matchingRoutes,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
270
287
|
return {
|
|
271
288
|
available: false,
|
|
272
289
|
retryAfterMs: Infinity,
|
|
@@ -574,13 +591,41 @@ export class AccountManager {
|
|
|
574
591
|
});
|
|
575
592
|
}
|
|
576
593
|
|
|
577
|
-
|
|
594
|
+
// Drop wedged head tickets (deadline passed or explicitly marked dead) so a
|
|
595
|
+
// single orphaned waiter cannot block every other request behind it. Cheap;
|
|
596
|
+
// safe to call before every head check.
|
|
597
|
+
_reapStaleQueueHead() {
|
|
598
|
+
const q = this.queueState;
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
let guard = 0;
|
|
601
|
+
while (q.waiting.length && guard++ < 10_000) {
|
|
602
|
+
const head = q.waiting[0];
|
|
603
|
+
const stale = head.dead === true || (head.deadlineAt && now > head.deadlineAt);
|
|
604
|
+
if (!stale) break;
|
|
605
|
+
q.waiting.shift();
|
|
606
|
+
q.bytes = Math.max(0, q.bytes - (head.bytes || 0));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Register a waiter. Returns the ticket, or null if a backpressure limit
|
|
611
|
+
// (maxConcurrentQueued / maxQueuedBytes) would be exceeded — the caller then
|
|
612
|
+
// rejects the request with a "queue full" error instead of holding it.
|
|
613
|
+
registerQueuedRequest(requestInfo = {}, opts = {}) {
|
|
578
614
|
if (requestInfo.queueTicket) return requestInfo.queueTicket;
|
|
615
|
+
this._reapStaleQueueHead();
|
|
616
|
+
const bytes = Math.max(0, Number(opts.bytes) || 0);
|
|
617
|
+
const { maxConcurrentQueued, maxQueuedBytes } = opts;
|
|
618
|
+
if (maxConcurrentQueued != null && this.queueState.waiting.length >= maxConcurrentQueued) return null;
|
|
619
|
+
if (maxQueuedBytes != null && this.queueState.waiting.length > 0
|
|
620
|
+
&& this.queueState.bytes + bytes > maxQueuedBytes) return null;
|
|
579
621
|
const ticket = {
|
|
580
622
|
id: this.queueState.nextId++,
|
|
581
623
|
queuedAt: Date.now(),
|
|
624
|
+
bytes,
|
|
625
|
+
deadlineAt: opts.deadlineAt || null,
|
|
582
626
|
};
|
|
583
627
|
this.queueState.waiting.push(ticket);
|
|
628
|
+
this.queueState.bytes += bytes;
|
|
584
629
|
requestInfo.queueTicket = ticket;
|
|
585
630
|
return ticket;
|
|
586
631
|
}
|
|
@@ -588,10 +633,12 @@ export class AccountManager {
|
|
|
588
633
|
canAdmitQueuedRequest(requestInfo = {}) {
|
|
589
634
|
const ticket = requestInfo.queueTicket;
|
|
590
635
|
if (!ticket) return true;
|
|
636
|
+
this._reapStaleQueueHead();
|
|
591
637
|
if (this.queueState.waiting[0]?.id !== ticket.id) return false;
|
|
592
638
|
const now = Date.now();
|
|
593
639
|
if (now < this.queueState.rampUntil && now - this.queueState.lastAdmissionAt < 250) return false;
|
|
594
640
|
this.queueState.waiting.shift();
|
|
641
|
+
this.queueState.bytes = Math.max(0, this.queueState.bytes - (ticket.bytes || 0));
|
|
595
642
|
this.queueState.lastAdmissionAt = now;
|
|
596
643
|
requestInfo.queueTicket = null;
|
|
597
644
|
requestInfo.queueAdmitted = true;
|
|
@@ -602,7 +649,10 @@ export class AccountManager {
|
|
|
602
649
|
const ticket = requestInfo.queueTicket;
|
|
603
650
|
if (!ticket) return;
|
|
604
651
|
const index = this.queueState.waiting.findIndex(entry => entry.id === ticket.id);
|
|
605
|
-
if (index >= 0)
|
|
652
|
+
if (index >= 0) {
|
|
653
|
+
this.queueState.waiting.splice(index, 1);
|
|
654
|
+
this.queueState.bytes = Math.max(0, this.queueState.bytes - (ticket.bytes || 0));
|
|
655
|
+
}
|
|
606
656
|
requestInfo.queueTicket = null;
|
|
607
657
|
}
|
|
608
658
|
|
package/src/config.js
CHANGED
|
@@ -81,13 +81,20 @@ export function createDefaultConfig() {
|
|
|
81
81
|
shutdown: {
|
|
82
82
|
drainTimeoutMs: 15_000,
|
|
83
83
|
},
|
|
84
|
+
// When every account is rate-limited, hold the request and retry until one
|
|
85
|
+
// frees up, instead of erroring and killing the session. Only error if
|
|
86
|
+
// nothing recovers within the window below (the early-exit gates on each
|
|
87
|
+
// account's REAL reset time, so a generous bound never spins pointlessly).
|
|
84
88
|
queue: {
|
|
85
89
|
enabled: true,
|
|
86
|
-
maxWaitMs: 24 * 60 * 60 * 1000,
|
|
87
|
-
autoMaxWaitMs: null,
|
|
88
|
-
capacityMaxWaitMs: 15 * 60 * 1000,
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
maxWaitMs: 24 * 60 * 60 * 1000, // hard ceiling for any hold
|
|
91
|
+
autoMaxWaitMs: null, // 5h/session-cap hold (null = maxWaitMs)
|
|
92
|
+
capacityMaxWaitMs: 15 * 60 * 1000, // upstream 529/overload — stays short, never governed by the others
|
|
93
|
+
weeklyMaxWaitMs: 24 * 60 * 60 * 1000, // weekly (7d) cap hold; was 0 (fail-fast) — that killed sessions on weekly cap
|
|
94
|
+
nonStreamMaxWaitMs: 5 * 60 * 1000, // non-streaming requests have no keepalive; cap their wait
|
|
95
|
+
maxConcurrentQueued: 64, // backpressure: max requests held at once
|
|
96
|
+
maxQueuedBytes: 1024 * 1024 * 1024, // backpressure: max aggregate buffered body bytes (1 GiB)
|
|
97
|
+
maxQueuedBodyBytes: 256 * 1024 * 1024, // per-request cap on a queueable body
|
|
91
98
|
pollMs: 1000,
|
|
92
99
|
heartbeatMs: 10_000,
|
|
93
100
|
},
|
package/src/server.js
CHANGED
|
@@ -19,7 +19,19 @@ const DEFAULT_QUEUE = {
|
|
|
19
19
|
maxWaitMs: 24 * 60 * 60 * 1000,
|
|
20
20
|
autoMaxWaitMs: null,
|
|
21
21
|
capacityMaxWaitMs: 15 * 60 * 1000,
|
|
22
|
-
|
|
22
|
+
// Weekly (7d) cap hold. A generous bound is SAFE because the early-exit
|
|
23
|
+
// gates on the REAL reset time (unified7dReset - now): it only waits when a
|
|
24
|
+
// reset genuinely lands inside the window, and errors honestly otherwise.
|
|
25
|
+
// 0 here was the bug — it fail-fast-killed sessions the instant every
|
|
26
|
+
// account hit its weekly cap, instead of waiting for the soonest reset.
|
|
27
|
+
weeklyMaxWaitMs: 24 * 60 * 60 * 1000,
|
|
28
|
+
// Non-streaming requests have no SSE heartbeat to keep them alive, so a long
|
|
29
|
+
// hold would die on the client timeout anyway. Cap their wait conservatively.
|
|
30
|
+
nonStreamMaxWaitMs: 5 * 60 * 1000,
|
|
31
|
+
// Backpressure: holds used to be 0ms, now they can be hours. Bound the queue
|
|
32
|
+
// so 22 retrying agents can't grow the heap without limit.
|
|
33
|
+
maxConcurrentQueued: 64,
|
|
34
|
+
maxQueuedBytes: 1024 * 1024 * 1024, // 1 GiB aggregate across all held bodies
|
|
23
35
|
pollMs: 1000,
|
|
24
36
|
heartbeatMs: 10_000,
|
|
25
37
|
};
|
|
@@ -825,6 +837,14 @@ function hasEligibleRoute(accountManager, requestInfo = {}, excludedIndexes = ne
|
|
|
825
837
|
return accountManager.hasAvailableRoute?.(requestInfo, excludedIndexes) || false;
|
|
826
838
|
}
|
|
827
839
|
|
|
840
|
+
function formatRetryDuration(seconds) {
|
|
841
|
+
const s = Math.max(0, Math.round(Number(seconds) || 0));
|
|
842
|
+
if (s >= 86400) return `${Math.round(s / 86400)}d`;
|
|
843
|
+
if (s >= 3600) return `${Math.round(s / 3600)}h`;
|
|
844
|
+
if (s >= 60) return `${Math.round(s / 60)}m`;
|
|
845
|
+
return `${s}s`;
|
|
846
|
+
}
|
|
847
|
+
|
|
828
848
|
function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
|
|
829
849
|
const thinking = requestInfo.requiresAnthropicThinkingIntegrity
|
|
830
850
|
|| accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
|
|
@@ -834,7 +854,10 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
834
854
|
// account is at its own 5h/weekly limit. A short "retry in Ns" would be a lie;
|
|
835
855
|
// tell the user the real fix.
|
|
836
856
|
if (!willRecoverSoon) {
|
|
837
|
-
const
|
|
857
|
+
const eta = Number.isFinite(retryAfter) && retryAfter > 0
|
|
858
|
+
? ` Soonest reset in ~${formatRetryDuration(retryAfter)}, beyond the hold window.`
|
|
859
|
+
: '';
|
|
860
|
+
const base = `No Claude account can take this request — all ${n} are at their 5h or weekly limit.${eta} Add another Claude account or wait for a quota reset.`;
|
|
838
861
|
return thinking
|
|
839
862
|
? `${base} GLM/Kimi fallback is unavailable because this session contains Anthropic signed thinking blocks; start a fresh non-thinking session to use them.`
|
|
840
863
|
: base;
|
|
@@ -1062,29 +1085,64 @@ async function queueAndRetry(
|
|
|
1062
1085
|
? autoMaxWaitMs
|
|
1063
1086
|
: Math.max(0, Number(queueConfig.capacityMaxWaitMs) || 0);
|
|
1064
1087
|
const weeklyMaxWaitMs = Math.max(0, Number(queueConfig.weeklyMaxWaitMs) || 0);
|
|
1088
|
+
const nonStreamMaxWaitMs = queueConfig.nonStreamMaxWaitMs == null
|
|
1089
|
+
? 5 * 60_000
|
|
1090
|
+
: Math.max(0, Number(queueConfig.nonStreamMaxWaitMs) || 0);
|
|
1065
1091
|
const retryPlan = accountManager.nextRetryForRequest?.(requestInfo, new Set()) || {
|
|
1066
1092
|
retryAfterMs: Infinity,
|
|
1067
1093
|
cause: 'unavailable',
|
|
1068
1094
|
};
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1095
|
+
|
|
1096
|
+
// Honest, cause-/thinking-aware message used for every give-up path below.
|
|
1097
|
+
const honestMessage = unavailableMessage(
|
|
1098
|
+
accountManager, requestInfo,
|
|
1099
|
+
Math.ceil((Number.isFinite(retryPlan.retryAfterMs) ? retryPlan.retryAfterMs : 0) / 1000),
|
|
1100
|
+
false,
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Weekly-capped but the reset time is unknown (cold start / probe failure):
|
|
1104
|
+
// we can't estimate a wait, so don't pretend to — error honestly now.
|
|
1105
|
+
if (retryPlan.cause === 'weekly_reset_unknown') {
|
|
1106
|
+
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Pick the wait window. Capacity (upstream 529/overload) MUST stay on its own
|
|
1110
|
+
// short cap even when it coincides with weekly exhaustion — never let a
|
|
1111
|
+
// transient overload inherit the long weekly bound.
|
|
1112
|
+
let queueWindowMs = cause === 'capacity'
|
|
1113
|
+
? Math.min(maxWaitMs, capacityMaxWaitMs)
|
|
1114
|
+
: retryPlan.cause === 'weekly_exhausted'
|
|
1115
|
+
? Math.min(maxWaitMs, weeklyMaxWaitMs)
|
|
1116
|
+
: Math.min(maxWaitMs, autoMaxWaitMs);
|
|
1117
|
+
// Non-streaming requests have no SSE heartbeat, so a long hold would die on
|
|
1118
|
+
// the client timeout. Cap them so we never promise a wait we can't deliver.
|
|
1119
|
+
if (!requestInfo.stream) queueWindowMs = Math.min(queueWindowMs, nonStreamMaxWaitMs);
|
|
1120
|
+
|
|
1121
|
+
if (queueWindowMs <= 0) return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1075
1122
|
|
|
1076
1123
|
const retryAfterMs = retryPlan.retryAfterMs;
|
|
1077
1124
|
if (!Number.isFinite(retryAfterMs) || retryAfterMs > queueWindowMs) {
|
|
1078
|
-
return finishQueuedStreamIfNeeded(res, requestInfo,
|
|
1125
|
+
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1079
1126
|
}
|
|
1080
1127
|
|
|
1081
1128
|
requestInfo.queueStartedAt ||= Date.now();
|
|
1082
|
-
accountManager.registerQueuedRequest?.(requestInfo
|
|
1129
|
+
const ticket = accountManager.registerQueuedRequest?.(requestInfo, {
|
|
1130
|
+
bytes: body?.length || 0,
|
|
1131
|
+
deadlineAt: requestInfo.queueStartedAt + queueWindowMs,
|
|
1132
|
+
maxConcurrentQueued: queueConfig.maxConcurrentQueued,
|
|
1133
|
+
maxQueuedBytes: queueConfig.maxQueuedBytes,
|
|
1134
|
+
});
|
|
1135
|
+
if (ticket === null) {
|
|
1136
|
+
// Backpressure: too many requests already waiting / too many bytes buffered.
|
|
1137
|
+
// Reject honestly instead of growing the heap unbounded.
|
|
1138
|
+
return finishQueuedStreamIfNeeded(res, requestInfo,
|
|
1139
|
+
'Maxpool queue is full — too many requests are already waiting for capacity. Try again shortly.');
|
|
1140
|
+
}
|
|
1083
1141
|
const elapsed = Date.now() - requestInfo.queueStartedAt;
|
|
1084
1142
|
const remaining = queueWindowMs - elapsed;
|
|
1085
1143
|
if (remaining <= 0) {
|
|
1086
1144
|
accountManager.removeQueuedRequest?.(requestInfo);
|
|
1087
|
-
return finishQueuedStreamIfNeeded(res, requestInfo,
|
|
1145
|
+
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1088
1146
|
}
|
|
1089
1147
|
|
|
1090
1148
|
ctx.account = '(queued)';
|
|
@@ -1096,7 +1154,7 @@ async function queueAndRetry(
|
|
|
1096
1154
|
if (!available) {
|
|
1097
1155
|
if (res.destroyed || req.destroyed) return true;
|
|
1098
1156
|
accountManager.removeQueuedRequest?.(requestInfo);
|
|
1099
|
-
return finishQueuedStreamIfNeeded(res, requestInfo,
|
|
1157
|
+
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
1100
1158
|
}
|
|
1101
1159
|
|
|
1102
1160
|
return forwardRequest(
|
|
@@ -1163,7 +1221,10 @@ async function waitForAvailableRoute(req, res, accountManager, requestInfo, queu
|
|
|
1163
1221
|
) return true;
|
|
1164
1222
|
|
|
1165
1223
|
const remaining = maxWaitMs - (Date.now() - startedAt);
|
|
1166
|
-
|
|
1224
|
+
// Jitter the poll so a synchronized weekly-reset event doesn't re-align
|
|
1225
|
+
// every waiter's poll into the same instant (thundering scan).
|
|
1226
|
+
const jittered = pollMs * (0.8 + Math.random() * 0.4);
|
|
1227
|
+
await sleep(Math.min(jittered, remaining));
|
|
1167
1228
|
}
|
|
1168
1229
|
|
|
1169
1230
|
return accountManager.hasAvailableRoute(requestInfo, new Set())
|