maxpool 1.5.57 → 1.5.58

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/oauth.js +31 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.57",
3
+ "version": "1.5.58",
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/oauth.js CHANGED
@@ -27,14 +27,17 @@ export function tokenFingerprint(token) {
27
27
  * Refresh an expired OAuth access token using the refresh token.
28
28
  * Retries on 5xx and network errors with exponential backoff.
29
29
  */
30
+ // How long to wait for the user to finish the browser login. Env-overridable.
31
+ const LOGIN_TIMEOUT_MS = Math.max(60_000, Number(process.env.MAXPOOL_LOGIN_TIMEOUT_MS) || 300_000);
32
+
30
33
  export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
31
34
  const maxRetries = 2;
32
35
  const baseDelayMs = 500;
33
- // Bound each refresh POST so a hung connect during an outage can't pin the
34
- // single-flight _refreshPromise indefinitely (which would stall that account's
35
- // recovery). Comfortably above a normal refresh latency; a timeout is classified
36
- // as a network error below and retried / short-cooled.
37
- const perAttemptTimeoutMs = 10_000;
36
+ // 30s, not 10s. A refresh aborted by OUR timeout is the dangerous case (see below), so
37
+ // the timeout must be generous enough that a merely slow network never triggers it.
38
+ // Measured 2026-08-03: refreshes aborted at 10s during a degraded-network window, and
39
+ // the retries that followed destroyed 4 accounts.
40
+ const perAttemptTimeoutMs = Math.max(10_000, Number(process.env.MAXPOOL_TOKEN_REFRESH_TIMEOUT_MS) || 30_000);
38
41
 
39
42
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
43
  try {
@@ -83,10 +86,26 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
83
86
  (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
84
87
  err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
85
88
 
86
- if (attempt < maxRetries && isNetworkError) {
89
+ // NEVER re-send a single-use refresh token after an AMBIGUOUS failure. A timeout,
90
+ // an abort, or a reset AFTER the request left us means the server may have processed
91
+ // it and rotated the token — retrying then spends a token that is already dead and
92
+ // the account is destroyed with invalid_grant. Only a failure that provably happened
93
+ // BEFORE the server saw the request (connection refused, DNS) is safe to retry.
94
+ //
95
+ // Measured 2026-08-03: a degraded network aborted refreshes at 10s; the retry loop
96
+ // re-sent each token up to 3 times within seconds, and 4 accounts died with
97
+ // "Refresh token not found or invalid" — requiring a manual re-login each.
98
+ const sentToServer = !(err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND'
99
+ || err.code === 'EAI_AGAIN' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
100
+ if (attempt < maxRetries && isNetworkError && !sentToServer) {
87
101
  continue;
88
102
  }
89
- if (isNetworkError) err.retryable = true;
103
+ if (isNetworkError) {
104
+ err.retryable = true;
105
+ // Tell the caller the token's fate is UNKNOWN, so it cools the account and tries
106
+ // once later instead of treating this as a clean failure.
107
+ err.ambiguousRefresh = sentToServer;
108
+ }
90
109
  throw err;
91
110
  }
92
111
  }
@@ -555,11 +574,13 @@ function startCallbackServer(expectedState) {
555
574
  });
556
575
  server.on('error', reject);
557
576
 
558
- // Timeout after 2 minutes (unref so it doesn't keep the process alive)
577
+ // Bounded so a forgotten browser tab can't pin the process. 5 minutes, not 2:
578
+ // reported 2026-08-03 that a real login (password + 2FA, several accounts in a row)
579
+ // repeatedly overran 2 minutes and had to be restarted from scratch.
559
580
  const timer = setTimeout(() => {
560
- rejectCode(new Error('Login timed out after 2 minutes'));
581
+ rejectCode(new Error(`Login timed out after ${Math.round(LOGIN_TIMEOUT_MS / 60_000)} minutes`));
561
582
  server.close();
562
- }, 120_000);
583
+ }, LOGIN_TIMEOUT_MS);
563
584
  timer.unref();
564
585
  });
565
586
  }