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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
- if (!this._matchesRequest(account, profile, requestInfo)) {
254
- // A signed-thinking request behind the TRANSIENT FIFO queue-fairness gate
255
- // (a queue already exists and this newcomer hasn't registered a ticket yet
256
- // — the `!requestInfo.queueTicket` clause in _matchesRequest) must HOLD,
257
- // not error-fast. Such a request has NO fallback: providers are barred
258
- // (signed thinking), so shedding it with a 429 KILLS the session — the
259
- // reproduced "all N are at their 5h or weekly limit" false kill
260
- // (2026-06-27) while healthy accounts sat idle behind the queue. The
261
- // instant it registers a ticket the gate lifts and it waits its FIFO turn
262
- // behind the existing waiters (which drain as the healthy accounts finish),
263
- // so a bounded re-poll hold is the right answer.
264
- // Scoped to THINKING requests on purpose: a non-thinking request can fall
265
- // back to a provider or be cheaply retried, so the fairness gate keeps
266
- // shedding it as backpressure (never grow the queue past its cap under a
267
- // pure-concurrency burst). admissionPaused (restart/shutdown shed) and
268
- // upstream-throttle keep their existing behavior (throttle is handled by
269
- // the early return above). NOTE: queueAndRetry does NOT special-case
270
- // 'queued_behind_fairness' in its window switch, so a STREAMING thinking
271
- // hold rides streamHoldMaxMs (kept alive by the heartbeat) — intended: a
272
- // signed-thinking stream should wait its turn, bounded by the per-ticket
273
- // deadlineAt + _reapStaleQueueHead + the client's own disconnect, not the
274
- // 15-min concurrency-cap clamp.
275
- const thinkingFairnessGated = this._requiresAnthropicThinkingIntegrity(requestInfo)
276
- && !this.admissionPaused
277
- && !this._isUpstreamThrottleBlocking()
278
- && this.queueState.waiting.length > 0
279
- && !requestInfo.queueTicket
280
- && !requestInfo.queueAdmitted;
281
- if (thinkingFairnessGated
282
- && account.type !== 'provider'
283
- && this._isRequestCompatible(account, profile, requestInfo)
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
- if (this._isAvailable(account, { allowWeeklyReserve: true })) {
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
- this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`);
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
- accountManager.markTransientFailure(account.index, err.code || err.message || 'network_error');
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
- if (cause === 'network' || cause === 'proxy') return false;
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
- const honestMessage = unavailableMessage(
1180
- accountManager, requestInfo,
1181
- Math.ceil((Number.isFinite(retryPlan.retryAfterMs) ? retryPlan.retryAfterMs : 0) / 1000),
1182
- false,
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.