fix: don't count rate limit waits as failed retry attempts

When all accounts are rate-limited or token-exhausted, the retry loop
was incorrectly counting the wait time as a failed attempt. This caused
premature "Max retries exceeded" errors when we were just patiently
waiting for accounts to become available.

- Add attempt-- after sleeping for rate limits or strategy waits
- Add #diagnoseNoCandidates() to hybrid strategy for better logging
- Add getTimeUntilNextToken() and getMinTimeUntilToken() to token tracker
- Return waitMs from hybrid strategy when all accounts are token-blocked

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2026-01-23 14:29:24 +05:30
parent 7aa1508b27
commit 0fa945b069
4 changed files with 104 additions and 4 deletions

View File

@@ -68,8 +68,10 @@ export class HybridStrategy extends BaseStrategy {
const candidates = this.#getCandidates(accounts, modelId);
if (candidates.length === 0) {
logger.debug('[HybridStrategy] No candidates available');
return { account: null, index: 0, waitMs: 0 };
// Diagnose why no candidates are available and compute wait time
const { reason, waitMs } = this.#diagnoseNoCandidates(accounts, modelId);
logger.warn(`[HybridStrategy] No candidates available: ${reason}`);
return { account: null, index: 0, waitMs };
}
// Score and sort candidates
@@ -232,6 +234,58 @@ export class HybridStrategy extends BaseStrategy {
getQuotaTracker() {
return this.#quotaTracker;
}
/**
* Diagnose why no candidates are available and compute wait time
* @private
* @param {Array} accounts - Array of account objects
* @param {string} modelId - The model ID
* @returns {{reason: string, waitMs: number}} Diagnosis result
*/
#diagnoseNoCandidates(accounts, modelId) {
let unusableCount = 0;
let unhealthyCount = 0;
let noTokensCount = 0;
let criticalQuotaCount = 0;
const accountsWithoutTokens = [];
for (const account of accounts) {
if (!this.isAccountUsable(account, modelId)) {
unusableCount++;
continue;
}
if (!this.#healthTracker.isUsable(account.email)) {
unhealthyCount++;
continue;
}
if (!this.#tokenBucketTracker.hasTokens(account.email)) {
noTokensCount++;
accountsWithoutTokens.push(account.email);
continue;
}
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
criticalQuotaCount++;
continue;
}
}
// If all accounts are blocked by token bucket, calculate wait time
if (noTokensCount > 0 && unusableCount === 0 && unhealthyCount === 0) {
const waitMs = this.#tokenBucketTracker.getMinTimeUntilToken(accountsWithoutTokens);
const reason = `all ${noTokensCount} account(s) exhausted token bucket, waiting for refill`;
return { reason, waitMs };
}
// Build reason string
const parts = [];
if (unusableCount > 0) parts.push(`${unusableCount} unusable/disabled`);
if (unhealthyCount > 0) parts.push(`${unhealthyCount} unhealthy`);
if (noTokensCount > 0) parts.push(`${noTokensCount} no tokens`);
if (criticalQuotaCount > 0) parts.push(`${criticalQuotaCount} critical quota`);
const reason = parts.length > 0 ? parts.join(', ') : 'unknown';
return { reason, waitMs: 0 };
}
}
export default HybridStrategy;

View File

@@ -116,6 +116,40 @@ export class TokenBucketTracker {
clear() {
this.#buckets.clear();
}
/**
* Get time in milliseconds until next token is available for an account
* @param {string} email - Account email
* @returns {number} Milliseconds until next token, 0 if tokens available now
*/
getTimeUntilNextToken(email) {
const currentTokens = this.getTokens(email);
if (currentTokens >= 1) {
return 0;
}
// Calculate time to regenerate 1 token
const tokensNeeded = 1 - currentTokens;
const minutesNeeded = tokensNeeded / this.#config.tokensPerMinute;
return Math.ceil(minutesNeeded * 60 * 1000);
}
/**
* Get the minimum time until any account in the list has a token
* @param {Array<string>} emails - List of account emails
* @returns {number} Minimum milliseconds until any account has a token
*/
getMinTimeUntilToken(emails) {
if (emails.length === 0) return 0;
let minWait = Infinity;
for (const email of emails) {
const wait = this.getTimeUntilNextToken(email);
if (wait === 0) return 0;
minWait = Math.min(minWait, wait);
}
return minWait === Infinity ? 0 : minWait;
}
}
export default TokenBucketTracker;

View File

@@ -160,6 +160,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
await sleep(minWaitMs + 500); // Add 500ms buffer
accountManager.clearExpiredLimits();
// CRITICAL FIX: Don't count waiting for rate limits as a failed attempt
// This prevents "Max retries exceeded" when we are just patiently waiting
attempt--;
continue; // Retry the loop
}
@@ -174,11 +178,13 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
if (!account && waitMs > 0) {
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
await sleep(waitMs + 500);
attempt--; // CRITICAL FIX: Don't count strategy wait as failure
continue;
}
if (!account) {
continue; // Shouldn't happen, but safety check
logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`);
continue;
}
try {

View File

@@ -158,6 +158,10 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
await sleep(minWaitMs + 500); // Add 500ms buffer
accountManager.clearExpiredLimits();
// CRITICAL FIX: Don't count waiting for rate limits as a failed attempt
// This prevents "Max retries exceeded" when we are just patiently waiting
attempt--;
continue; // Retry the loop
}
@@ -172,11 +176,13 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
if (!account && waitMs > 0) {
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
await sleep(waitMs + 500);
attempt--; // CRITICAL FIX: Don't count strategy wait as failure
continue;
}
if (!account) {
continue; // Shouldn't happen, but safety check
logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`);
continue;
}
try {