maxpool 1.24.21 → 1.24.22

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +32 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.24.21",
3
+ "version": "1.24.22",
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",
package/src/server.js CHANGED
@@ -366,7 +366,10 @@ export function createProxyServer(accountManager, config, hooks = {}) {
366
366
  message: 'Maxpool did not receive the full request in time (the upload stalled — usually a network problem on this machine). Retrying should work once the network recovers.',
367
367
  },
368
368
  });
369
- socket.end(`HTTP/1.1 408 Request Timeout\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
369
+ // end-then-DESTROY: a half-closed socket kept parsing, so a client that resumed its
370
+ // upload after the 408 got its request forwarded upstream anyway — a duplicate,
371
+ // possibly billed call (red-team 2026-10-12, finding 6).
372
+ socket.end(`HTTP/1.1 408 Request Timeout\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, () => socket.destroy());
370
373
  return;
371
374
  }
372
375
  socket.destroy(err);
@@ -516,10 +519,10 @@ async function forwardRequest(
516
519
  // useful header is "retry soon": every SDK retries ≤60s values with backoff,
517
520
  // and the pool answers again immediately with a fresh estimate. The body text
518
521
  // still carries the real wait.
519
- const RETRY_AFTER_RETRIABLE_S = 60;
520
- const retryAfter = willRecoverSoon
521
- ? Math.min(Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)), RETRY_AFTER_RETRIABLE_S)
522
- : 60;
522
+ // Header = CLI-retriable cap; message = the REAL wait (2026-10-12: the capped
523
+ // number leaked into the text as "Retry in ~1m" while the real wait was ~1h).
524
+ const retryAfter = clientRetryAfterSeconds(retryPlan.retryAfterMs);
525
+ const realWaitS = willRecoverSoon ? Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)) : 60;
523
526
  // Surface the routing decision (logs go to the TUI; this is the only record
524
527
  // of WHY a request was rejected rather than queued).
525
528
  console.log(`[Maxpool] No route for request — returning 429 (cause: ${retryPlan.cause || 'unavailable'}, recovers-soon: ${willRecoverSoon})`);
@@ -527,7 +530,7 @@ async function forwardRequest(
527
530
  type: 'error',
528
531
  error: {
529
532
  type: 'rate_limit_error',
530
- message: unavailableMessage(accountManager, requestInfo, retryAfter, willRecoverSoon),
533
+ message: unavailableMessage(accountManager, requestInfo, realWaitS, willRecoverSoon),
531
534
  },
532
535
  }, { 'retry-after': String(retryAfter) });
533
536
  return;
@@ -900,15 +903,15 @@ async function forwardRequest(
900
903
  if (logDir) writeRequestLog(logDir, reqId, logSections);
901
904
  const retryPlan = accountManager.nextRetryForRequest?.(requestInfo, new Set()) || {};
902
905
  const willRecoverSoon = Number.isFinite(retryPlan.retryAfterMs);
903
- const clientRetryAfter = willRecoverSoon ? Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)) : 60;
906
+ const realWaitS = willRecoverSoon ? Math.max(1, Math.ceil(retryPlan.retryAfterMs / 1000)) : 60;
904
907
  console.log(`[Maxpool] No route after failover — returning 429 (cause: ${retryPlan.cause || 'unavailable'}, recovers-soon: ${willRecoverSoon})`);
905
908
  sendErrorResponse(res, requestInfo, 429, {
906
909
  type: 'error',
907
910
  error: {
908
911
  type: 'rate_limit_error',
909
- message: unavailableMessage(accountManager, requestInfo, clientRetryAfter, willRecoverSoon),
912
+ message: unavailableMessage(accountManager, requestInfo, realWaitS, willRecoverSoon),
910
913
  },
911
- }, { 'retry-after': String(clientRetryAfter) });
914
+ }, { 'retry-after': String(clientRetryAfterSeconds(retryPlan.retryAfterMs)) });
912
915
  return;
913
916
  }
914
917
 
@@ -2727,8 +2730,11 @@ async function queueAndRetry(
2727
2730
  // the capacity window exists precisely to shed that load (the soft-deadlock
2728
2731
  // guard — pinned at cap forever must 429 within the window, never spin).
2729
2732
  // Only quota-class resets (a scheduled 5h/weekly rollover) chain.
2733
+ // Keyed on WHAT the reset is (retryPlan.cause), never on the caller's coarse label:
2734
+ // the failover path calls with cause='capacity' even when the plan is a scheduled
2735
+ // quota rollover, which wrongly lost the chain (red-team 2026-10-12, finding 3).
2730
2736
  if (requestInfo.stream && queueWindowMs > 0 && Number.isFinite(retryAfterMs)
2731
- && retryPlan.cause !== 'concurrency_cap' && cause !== 'capacity') {
2737
+ && !CAPACITY_CLASS_RETRY_CAUSES.has(retryPlan.cause)) {
2732
2738
  requestInfo.queueHoldChain = true;
2733
2739
  } else {
2734
2740
  return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
@@ -3729,9 +3735,24 @@ function markThinkingFromResponse(buffer, accountManager, requestInfo = {}) {
3729
3735
  }
3730
3736
  }
3731
3737
 
3738
+ // CLI-RETRIABLE retry-after (2026-10-11/12). Claude Code 2.1.296 retries a 429 whose
3739
+ // retry-after is ≤60s (+25% headroom) and treats anything longer as TERMINAL — the
3740
+ // turn dies (binary-verified, then reproduced against the real CLI: retry-after 3600
3741
+ // → "API Error: Request rejected (429)" at once; retry-after 60 → waits, retries,
3742
+ // recovers). Every 429 the pool emits goes through this cap; the real wait still
3743
+ // rides in the message text.
3744
+ const RETRY_AFTER_RETRIABLE_S = 60;
3745
+ // Retry causes whose 'reset' is a re-poll guess (load), not a scheduled quota rollover —
3746
+ // these shed within the capacity window instead of hold-chaining (soft-deadlock guard).
3747
+ const CAPACITY_CLASS_RETRY_CAUSES = new Set(['concurrency_cap']);
3748
+ function clientRetryAfterSeconds(retryAfterMs) {
3749
+ if (!Number.isFinite(retryAfterMs)) return RETRY_AFTER_RETRIABLE_S;
3750
+ return Math.min(Math.max(1, Math.ceil(retryAfterMs / 1000)), RETRY_AFTER_RETRIABLE_S);
3751
+ }
3752
+
3732
3753
  function computeRetryAfter(accountManager, requestInfo = {}) {
3733
3754
  const ms = accountManager.nextRetryForRequest?.(requestInfo, new Set())?.retryAfterMs ?? Infinity;
3734
- return ms === Infinity ? 60 : Math.max(1, Math.ceil(ms / 1000));
3755
+ return clientRetryAfterSeconds(ms);
3735
3756
  }
3736
3757
 
3737
3758
  // Test seam: prepareRuntimeProviders with a plain headers object.