maxpool 1.24.20 → 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.
- package/package.json +1 -1
- package/src/server.js +54 -13
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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,
|
|
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
|
|
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,
|
|
912
|
+
message: unavailableMessage(accountManager, requestInfo, realWaitS, willRecoverSoon),
|
|
910
913
|
},
|
|
911
|
-
}, { 'retry-after': String(
|
|
914
|
+
}, { 'retry-after': String(clientRetryAfterSeconds(retryPlan.retryAfterMs)) });
|
|
912
915
|
return;
|
|
913
916
|
}
|
|
914
917
|
|
|
@@ -2714,7 +2717,28 @@ async function queueAndRetry(
|
|
|
2714
2717
|
// (all accounts logged out, the only healthy account removed, etc.).
|
|
2715
2718
|
const retryAfterMs = retryPlan.retryAfterMs;
|
|
2716
2719
|
if (!Number.isFinite(retryAfterMs) || retryAfterMs > queueWindowMs) {
|
|
2717
|
-
|
|
2720
|
+
// FINITE-but-BEYOND-WINDOW (2026-10-12, "they should have just waited"): a
|
|
2721
|
+
// STREAMING request with a known reset 1h out used to instant-429 here for the
|
|
2722
|
+
// whole wait (measured 75 min, 08:13–09:34Z) while sessions burned their CLI
|
|
2723
|
+
// retry budgets and some died entirely. Hold the stream for the window with
|
|
2724
|
+
// the heartbeat keeping the client alive, then end with the retriable error —
|
|
2725
|
+
// the client re-enters and the hold chains window-by-window until the reset
|
|
2726
|
+
// lands. Non-streaming requests keep the fast error (no heartbeat = the hold
|
|
2727
|
+
// would die on the client's idle timeout anyway).
|
|
2728
|
+
// Capacity-class causes (concurrency cap, momentary saturation) are NOT
|
|
2729
|
+
// chained: their "reset" is a re-poll guess, not a scheduled quota reset, and
|
|
2730
|
+
// the capacity window exists precisely to shed that load (the soft-deadlock
|
|
2731
|
+
// guard — pinned at cap forever must 429 within the window, never spin).
|
|
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).
|
|
2736
|
+
if (requestInfo.stream && queueWindowMs > 0 && Number.isFinite(retryAfterMs)
|
|
2737
|
+
&& !CAPACITY_CLASS_RETRY_CAUSES.has(retryPlan.cause)) {
|
|
2738
|
+
requestInfo.queueHoldChain = true;
|
|
2739
|
+
} else {
|
|
2740
|
+
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
2741
|
+
}
|
|
2718
2742
|
}
|
|
2719
2743
|
|
|
2720
2744
|
requestInfo.queueStartedAt ||= Date.now();
|
|
@@ -2741,12 +2765,14 @@ async function queueAndRetry(
|
|
|
2741
2765
|
|
|
2742
2766
|
ctx.account = '(queued)';
|
|
2743
2767
|
hooks.onRequestRouted?.(reqId, { account: '(queued)' });
|
|
2744
|
-
|
|
2768
|
+
// Hold-chain note: when this hold expires still-full (queueHoldChain), the
|
|
2769
|
+
// finishQueuedStreamIfNeeded below ends the stream with the retriable error and
|
|
2770
|
+
// the client re-enters — the log line makes the chaining visible.
|
|
2771
|
+
console.log(`[Maxpool] ${reason}; queueing request for up to ${Math.ceil(remaining / 1000)}s (cause: ${cause}, retry: ${retryPlan.cause}${requestInfo.queueHoldChain ? ', hold-chain: reset beyond window' : ''})`);
|
|
2745
2772
|
ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager);
|
|
2746
2773
|
|
|
2747
2774
|
const available = await waitForAvailableRoute(req, res, accountManager, requestInfo, queueConfig, remaining);
|
|
2748
2775
|
if (!available) {
|
|
2749
|
-
if (res.destroyed || req.destroyed) return true;
|
|
2750
2776
|
accountManager.removeQueuedRequest?.(requestInfo);
|
|
2751
2777
|
return finishQueuedStreamIfNeeded(res, requestInfo, honestMessage);
|
|
2752
2778
|
}
|
|
@@ -3709,9 +3735,24 @@ function markThinkingFromResponse(buffer, accountManager, requestInfo = {}) {
|
|
|
3709
3735
|
}
|
|
3710
3736
|
}
|
|
3711
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
|
+
|
|
3712
3753
|
function computeRetryAfter(accountManager, requestInfo = {}) {
|
|
3713
3754
|
const ms = accountManager.nextRetryForRequest?.(requestInfo, new Set())?.retryAfterMs ?? Infinity;
|
|
3714
|
-
return
|
|
3755
|
+
return clientRetryAfterSeconds(ms);
|
|
3715
3756
|
}
|
|
3716
3757
|
|
|
3717
3758
|
// Test seam: prepareRuntimeProviders with a plain headers object.
|