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:
@@ -68,8 +68,10 @@ export class HybridStrategy extends BaseStrategy {
|
|||||||
const candidates = this.#getCandidates(accounts, modelId);
|
const candidates = this.#getCandidates(accounts, modelId);
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
logger.debug('[HybridStrategy] No candidates available');
|
// Diagnose why no candidates are available and compute wait time
|
||||||
return { account: null, index: 0, waitMs: 0 };
|
const { reason, waitMs } = this.#diagnoseNoCandidates(accounts, modelId);
|
||||||
|
logger.warn(`[HybridStrategy] No candidates available: ${reason}`);
|
||||||
|
return { account: null, index: 0, waitMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score and sort candidates
|
// Score and sort candidates
|
||||||
@@ -232,6 +234,58 @@ export class HybridStrategy extends BaseStrategy {
|
|||||||
getQuotaTracker() {
|
getQuotaTracker() {
|
||||||
return this.#quotaTracker;
|
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;
|
export default HybridStrategy;
|
||||||
|
|||||||
@@ -116,6 +116,40 @@ export class TokenBucketTracker {
|
|||||||
clear() {
|
clear() {
|
||||||
this.#buckets.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;
|
export default TokenBucketTracker;
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
|||||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
|
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
|
||||||
await sleep(minWaitMs + 500); // Add 500ms buffer
|
await sleep(minWaitMs + 500); // Add 500ms buffer
|
||||||
accountManager.clearExpiredLimits();
|
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
|
continue; // Retry the loop
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,11 +178,13 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
|||||||
if (!account && waitMs > 0) {
|
if (!account && waitMs > 0) {
|
||||||
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
|
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
|
||||||
await sleep(waitMs + 500);
|
await sleep(waitMs + 500);
|
||||||
|
attempt--; // CRITICAL FIX: Don't count strategy wait as failure
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
continue; // Shouldn't happen, but safety check
|
logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
|||||||
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
|
logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`);
|
||||||
await sleep(minWaitMs + 500); // Add 500ms buffer
|
await sleep(minWaitMs + 500); // Add 500ms buffer
|
||||||
accountManager.clearExpiredLimits();
|
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
|
continue; // Retry the loop
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +176,13 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
|||||||
if (!account && waitMs > 0) {
|
if (!account && waitMs > 0) {
|
||||||
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
|
logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`);
|
||||||
await sleep(waitMs + 500);
|
await sleep(waitMs + 500);
|
||||||
|
attempt--; // CRITICAL FIX: Don't count strategy wait as failure
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
continue; // Shouldn't happen, but safety check
|
logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user