maxpool 1.5.1 → 1.5.3
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/package.json +1 -1
- package/src/account-manager.js +83 -41
- package/src/config.js +4 -0
- package/src/oauth.js +7 -0
- package/src/server.js +26 -7
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -48,6 +48,10 @@ const DEFAULT_SCHEDULER = {
|
|
|
48
48
|
safetyMaxGlobalActive: 150,
|
|
49
49
|
cooldownMs: 30_000,
|
|
50
50
|
maxCooldownMs: 15 * 60_000,
|
|
51
|
+
// Fixed cooldown for NETWORK-class failures (lost connectivity / token-refresh
|
|
52
|
+
// fetch-failed). Short + non-escalating so the fleet auto-recovers seconds after
|
|
53
|
+
// connectivity returns, instead of the exponential maxCooldownMs bench.
|
|
54
|
+
networkCooldownMs: 5_000,
|
|
51
55
|
weeklySoftThreshold: 0.65,
|
|
52
56
|
weeklyReserveThreshold: 0.85,
|
|
53
57
|
weeklyCriticalThreshold: 0.95,
|
|
@@ -250,48 +254,53 @@ export class AccountManager {
|
|
|
250
254
|
|
|
251
255
|
for (const account of this.accounts) {
|
|
252
256
|
if (excludedIndexes.has(account.index)) continue;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
&& this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
|
|
285
|
-
soonestBoundedHold = Math.min(soonestBoundedHold, BOUNDED_REPOLL_HOLD_MS);
|
|
286
|
-
if (!boundedHoldCause) boundedHoldCause = 'queued_behind_fairness';
|
|
287
|
-
} else if (account.type === 'provider' && this._requiresAnthropicThinkingIntegrity(requestInfo)) {
|
|
257
|
+
const matches = this._matchesRequest(account, profile, requestInfo);
|
|
258
|
+
|
|
259
|
+
// A signed-thinking request behind the TRANSIENT FIFO queue-fairness gate
|
|
260
|
+
// (a queue already exists and this newcomer hasn't registered a ticket yet
|
|
261
|
+
// — the `!requestInfo.queueTicket` clause in _matchesRequest) must still be
|
|
262
|
+
// CONSIDERED for retry timing, not skipped. Such a request has NO fallback:
|
|
263
|
+
// providers are barred (signed thinking), so dropping the account here — and
|
|
264
|
+
// losing its REAL cooldown / rate-limit / 5h reset — collapses the oracle to
|
|
265
|
+
// Infinity and KILLS the session. Two reproduced kills (2026-06-27): a queue
|
|
266
|
+
// forms, then either (a) healthy accounts sit idle behind it, or (b) a
|
|
267
|
+
// network blip has cooled EVERY account, and the thinking newcomer dies with
|
|
268
|
+
// the false "all N are at their 5h or weekly limit". The gate lifts the
|
|
269
|
+
// instant the request registers a ticket, so bypass it for retry timing here.
|
|
270
|
+
// Still respected: admissionPaused (restart/shutdown shed → stays terminal),
|
|
271
|
+
// upstream-throttle (handled by the early return above), and structural
|
|
272
|
+
// profile / provider-thinking compatibility. Scoped to THINKING on purpose: a
|
|
273
|
+
// non-thinking newcomer can fall back to a provider or be cheaply retried, so
|
|
274
|
+
// the gate keeps shedding it as backpressure (never grow the queue past its
|
|
275
|
+
// cap under a pure-concurrency burst).
|
|
276
|
+
const fairnessOnlyBlock = !matches
|
|
277
|
+
&& this._requiresAnthropicThinkingIntegrity(requestInfo)
|
|
278
|
+
&& account.type !== 'provider'
|
|
279
|
+
&& this._isRequestCompatible(account, profile, requestInfo)
|
|
280
|
+
&& !this.admissionPaused
|
|
281
|
+
&& !this._isUpstreamThrottleBlocking()
|
|
282
|
+
&& this.queueState.waiting.length > 0
|
|
283
|
+
&& !requestInfo.queueTicket
|
|
284
|
+
&& !requestInfo.queueAdmitted;
|
|
285
|
+
|
|
286
|
+
if (!matches && !fairnessOnlyBlock) {
|
|
287
|
+
if (account.type === 'provider' && this._requiresAnthropicThinkingIntegrity(requestInfo)) {
|
|
288
288
|
note('provider_fallback_disabled_signed_thinking');
|
|
289
289
|
}
|
|
290
290
|
continue;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
matchingRoutes++;
|
|
294
|
-
|
|
294
|
+
|
|
295
|
+
// NEVER claim available:0 for a fairness-gated account — _selectNext still
|
|
296
|
+
// refuses it (the gate), so an available verdict would desync the oracle from
|
|
297
|
+
// selection and spin the caller. A healthy gated account holds a bounded
|
|
298
|
+
// re-poll (queued_behind_fairness); a transiently-blocked one (cooldown /
|
|
299
|
+
// rate-limit / 5h cap) falls through so its REAL short-term reset drives a
|
|
300
|
+
// finite hold. It must NEVER contribute the WEEKLY reset (days) — that is the
|
|
301
|
+
// multi-day-hang the bounded path fences off; see the !fairnessOnlyBlock
|
|
302
|
+
// guards on the weekly branches below.
|
|
303
|
+
if (!fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true })) {
|
|
295
304
|
return {
|
|
296
305
|
available: true,
|
|
297
306
|
retryAfterMs: 0,
|
|
@@ -300,10 +309,15 @@ export class AccountManager {
|
|
|
300
309
|
matchingRoutes,
|
|
301
310
|
};
|
|
302
311
|
}
|
|
312
|
+
if (fairnessOnlyBlock && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
|
|
313
|
+
soonestBoundedHold = Math.min(soonestBoundedHold, BOUNDED_REPOLL_HOLD_MS);
|
|
314
|
+
if (!boundedHoldCause) boundedHoldCause = 'queued_behind_fairness';
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
303
317
|
|
|
304
318
|
const retry = this._retryInfo(account);
|
|
305
319
|
note(retry.cause);
|
|
306
|
-
if (retry.weeklyCritical && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
|
|
320
|
+
if (!fairnessOnlyBlock && retry.weeklyCritical && this._isAvailable(account, { allowWeeklyReserve: true, allowWeeklyCritical: true })) {
|
|
307
321
|
return {
|
|
308
322
|
available: true,
|
|
309
323
|
retryAfterMs: 0,
|
|
@@ -334,10 +348,15 @@ export class AccountManager {
|
|
|
334
348
|
// hold when no weekly-critical account contributed it.
|
|
335
349
|
if (retry.weeklyCritical) boundedHoldCause = 'weekly_critical';
|
|
336
350
|
else if (!boundedHoldCause) boundedHoldCause = 'concurrency_cap';
|
|
337
|
-
} else if (retry.cause === 'weekly_exhausted' && retry.retryAt) {
|
|
351
|
+
} else if (!fairnessOnlyBlock && retry.cause === 'weekly_exhausted' && retry.retryAt) {
|
|
352
|
+
// A fairness-gated account NEVER contributes the weekly reset: holding a
|
|
353
|
+
// thinking session for days behind the queue is the over-correction we
|
|
354
|
+
// avoid (a weekly-exhausted gated fleet stays terminal → honest error,
|
|
355
|
+
// matching the no-newcomer-vs-queue distinction). Only its short-term reset
|
|
356
|
+
// (above) or the bounded hold may fire.
|
|
338
357
|
const ms = retry.retryAt - Date.now();
|
|
339
358
|
if (ms < soonestWeekly) soonestWeekly = ms;
|
|
340
|
-
} else if (retry.cause === 'weekly_exhausted' && !retry.retryAt) {
|
|
359
|
+
} else if (!fairnessOnlyBlock && retry.cause === 'weekly_exhausted' && !retry.retryAt) {
|
|
341
360
|
// Weekly-capped but we haven't learned the reset time (cold start /
|
|
342
361
|
// probe failure). We cannot estimate a wait — flag it so the caller
|
|
343
362
|
// emits an honest "reset time unknown" error instead of waiting forever.
|
|
@@ -1683,9 +1702,28 @@ export class AccountManager {
|
|
|
1683
1702
|
console.log(`[Maxpool] Account "${account.name}" disabled after HTTP ${status} (${reason})`);
|
|
1684
1703
|
}
|
|
1685
1704
|
|
|
1686
|
-
markTransientFailure(accountIndex, reason = 'transient_error') {
|
|
1705
|
+
markTransientFailure(accountIndex, reason = 'transient_error', { network = false } = {}) {
|
|
1687
1706
|
const account = this.accounts[accountIndex];
|
|
1688
1707
|
if (!account) return;
|
|
1708
|
+
|
|
1709
|
+
if (network) {
|
|
1710
|
+
// A network-class failure (lost connectivity / token-refresh `fetch failed`)
|
|
1711
|
+
// is a FLEET-WIDE condition, not this account's fault — every account fails at
|
|
1712
|
+
// once. Use a SHORT FIXED cooldown so the whole fleet retries within seconds of
|
|
1713
|
+
// connectivity returning and recovers AUTOMATICALLY, never the exponential
|
|
1714
|
+
// 15-min bench that stranded the fleet long after a multi-hour outage and forced
|
|
1715
|
+
// a manual restart (2026-06-29 hotel-network nightly cutoff). Deliberately does
|
|
1716
|
+
// NOT bump consecutiveFailures: a network blip must not poison the scoring
|
|
1717
|
+
// penalty (_scoreAccount) or prime the next REAL per-account failure for the max
|
|
1718
|
+
// cooldown — so the counter stays a pure request-health signal and needs no reset.
|
|
1719
|
+
account.failedRequests++;
|
|
1720
|
+
account.lastError = reason;
|
|
1721
|
+
account.lastErrorAt = Date.now();
|
|
1722
|
+
account.cooldownUntil = Date.now() + this.scheduler.networkCooldownMs;
|
|
1723
|
+
console.log(`[Maxpool] Account "${account.name}" cooling down for ${Math.ceil(this.scheduler.networkCooldownMs / 1000)}s after ${reason} (network — short fixed, auto-recovers)`);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1689
1727
|
const failures = Math.max(1, account.consecutiveFailures + 1);
|
|
1690
1728
|
const cooldown = Math.min(
|
|
1691
1729
|
this.scheduler.maxCooldownMs,
|
|
@@ -1797,7 +1835,11 @@ export class AccountManager {
|
|
|
1797
1835
|
// a failed proactive refresh shouldn't kill a still-valid token
|
|
1798
1836
|
if (!account.expiresAt || Date.now() >= account.expiresAt) {
|
|
1799
1837
|
if (err.retryable) {
|
|
1800
|
-
|
|
1838
|
+
// A retryable refresh failure with NO HTTP status is a network/connectivity
|
|
1839
|
+
// failure (fetch failed / timeout) → short fixed cooldown so it auto-recovers
|
|
1840
|
+
// when the network returns. A retryable HTTP status (429 / 5xx from the OAuth
|
|
1841
|
+
// endpoint) is server-side → keep the exponential backoff.
|
|
1842
|
+
this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`, { network: !err.status });
|
|
1801
1843
|
} else {
|
|
1802
1844
|
this.markAuthFailed(accountIndex, err.status || 401, 'token_refresh_failed');
|
|
1803
1845
|
}
|
package/src/config.js
CHANGED
|
@@ -59,6 +59,10 @@ export function createDefaultConfig() {
|
|
|
59
59
|
safetyMaxGlobalActive: 150,
|
|
60
60
|
cooldownMs: 30_000,
|
|
61
61
|
maxCooldownMs: 15 * 60_000,
|
|
62
|
+
// Fixed cooldown for network-class failures (lost connectivity / token-refresh
|
|
63
|
+
// fetch-failed) — short + non-escalating so the fleet auto-recovers seconds after
|
|
64
|
+
// connectivity returns, never the exponential maxCooldownMs bench.
|
|
65
|
+
networkCooldownMs: 5_000,
|
|
62
66
|
// Weekly (7d) quota tiers — how aggressively to de-prioritise an account
|
|
63
67
|
// as its weekly usage climbs. Each is a fraction (0..1) of the weekly
|
|
64
68
|
// limit. Below soft = full speed; soft..reserve = mild penalty;
|
package/src/oauth.js
CHANGED
|
@@ -17,6 +17,11 @@ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
|
17
17
|
export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
18
18
|
const maxRetries = 2;
|
|
19
19
|
const baseDelayMs = 500;
|
|
20
|
+
// Bound each refresh POST so a hung connect during an outage can't pin the
|
|
21
|
+
// single-flight _refreshPromise indefinitely (which would stall that account's
|
|
22
|
+
// recovery). Comfortably above a normal refresh latency; a timeout is classified
|
|
23
|
+
// as a network error below and retried / short-cooled.
|
|
24
|
+
const perAttemptTimeoutMs = 10_000;
|
|
20
25
|
|
|
21
26
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
22
27
|
try {
|
|
@@ -37,6 +42,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
37
42
|
refresh_token: refreshToken,
|
|
38
43
|
client_id: DEFAULT_CLIENT_ID,
|
|
39
44
|
}),
|
|
45
|
+
signal: AbortSignal.timeout(perAttemptTimeoutMs),
|
|
40
46
|
});
|
|
41
47
|
|
|
42
48
|
if (!res.ok) {
|
|
@@ -60,6 +66,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
60
66
|
} catch (err) {
|
|
61
67
|
const isNetworkError = err instanceof Error &&
|
|
62
68
|
(err.message.includes('fetch failed') ||
|
|
69
|
+
err.name === 'TimeoutError' || err.name === 'AbortError' ||
|
|
63
70
|
(err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
64
71
|
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
|
|
65
72
|
|
package/src/server.js
CHANGED
|
@@ -855,7 +855,10 @@ async function forwardRequest(
|
|
|
855
855
|
err.message.includes('terminated'));
|
|
856
856
|
|
|
857
857
|
if (isTransient) {
|
|
858
|
-
|
|
858
|
+
// Network-class (ECONNRESET / fetch failed / timeout / terminated): short fixed
|
|
859
|
+
// cooldown, no exponential escalation — the fleet auto-recovers seconds after
|
|
860
|
+
// connectivity returns instead of being benched for up to 15 min.
|
|
861
|
+
accountManager.markTransientFailure(account.index, err.code || err.message || 'network_error', { network: true });
|
|
859
862
|
accountManager.releaseAccount(lease);
|
|
860
863
|
excludedIndexes.add(account.index);
|
|
861
864
|
if (canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
@@ -1158,7 +1161,18 @@ async function queueAndRetry(
|
|
|
1158
1161
|
}
|
|
1159
1162
|
return false;
|
|
1160
1163
|
}
|
|
1161
|
-
|
|
1164
|
+
// A 'proxy' (non-transient upstream) error fails fast. A 'network' error (the
|
|
1165
|
+
// upstream fetch threw — ECONNRESET / ETIMEDOUT / a VPN or internet blip) is the
|
|
1166
|
+
// MOST transient failure there is, and must NOT kill a live session: when the
|
|
1167
|
+
// request is a STREAMING, replayable one (heartbeat keeps it alive, buffered body
|
|
1168
|
+
// can be re-sent) and ≥1 account will recover on a finite schedule, hold it and
|
|
1169
|
+
// resume when connectivity returns — exactly the "a network drop shouldn't break
|
|
1170
|
+
// my session" goal (2026-06-27). The hold-vs-error gate below still error-fasts if
|
|
1171
|
+
// nextRetryForRequest reports no finite recovery (all accounts genuinely gone), so
|
|
1172
|
+
// a real outage doesn't spin. A non-streaming network failure (no keepalive) still
|
|
1173
|
+
// fails fast — it would die on the client's own timeout anyway.
|
|
1174
|
+
if (cause === 'proxy') return false;
|
|
1175
|
+
if (cause === 'network' && (!requestInfo.stream || !canQueueBufferedBody)) return false;
|
|
1162
1176
|
|
|
1163
1177
|
const maxWaitMs = Math.max(0, Number(queueConfig.maxWaitMs) || 0);
|
|
1164
1178
|
const autoMaxWaitMs = queueConfig.autoMaxWaitMs == null
|
|
@@ -1176,11 +1190,16 @@ async function queueAndRetry(
|
|
|
1176
1190
|
};
|
|
1177
1191
|
|
|
1178
1192
|
// Honest, cause-/thinking-aware message used for every give-up path below.
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1193
|
+
// A 'network'-cause hold that ultimately gives up means connectivity never came
|
|
1194
|
+
// back within the window — say THAT (check your internet), not the misleading
|
|
1195
|
+
// "all accounts at their quota limit" (the accounts are fine; the network isn't).
|
|
1196
|
+
const honestMessage = cause === 'network'
|
|
1197
|
+
? 'Could not connect to Claude (network error) and connectivity did not return in time. Check your internet connection and try again. This is not an account quota issue.'
|
|
1198
|
+
: unavailableMessage(
|
|
1199
|
+
accountManager, requestInfo,
|
|
1200
|
+
Math.ceil((Number.isFinite(retryPlan.retryAfterMs) ? retryPlan.retryAfterMs : 0) / 1000),
|
|
1201
|
+
false,
|
|
1202
|
+
);
|
|
1184
1203
|
|
|
1185
1204
|
// Weekly-capped but the reset time is unknown (cold start / probe failure):
|
|
1186
1205
|
// we can't estimate a wait, so don't pretend to — error honestly now.
|