From f1943353ef96f7f5d600616babbde045415b8f6f Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 17 Jun 2026 04:40:45 +0700 Subject: [PATCH] fix: rotate proxy on every failure, return proper HTTP status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add rotateNow() to SessionProxyPool — force-rotate session to a different proxy immediately (excludes current index to ensure real rotation). Uses round-robin scan from oldIndex+1 so all proxies get used, not just bouncing between two. - fetchWithSessionRetry: call rotateNow() on every failure instead of markFailed() which only rotated after threshold. Return last HTTP response (e.g. 429) instead of classifying as 502 when we have one. - fetchWithRetry: rotate pool on every failure for consistency. Co-Authored-By: Claude --- src/lib/fetch-utils.ts | 44 +++++++++++++++++++++------------- src/lib/proxy-pool.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts index 01553b9..f5772db 100644 --- a/src/lib/fetch-utils.ts +++ b/src/lib/fetch-utils.ts @@ -168,18 +168,18 @@ export async function fetchWithRetry( return { response }; } - // Non-2xx — mark proxy as failed and retry + // Non-2xx — mark proxy as failed and rotate for next attempt lastError = new Error(`Upstream returned ${response.status}`); logProxy("fetchWithRetry", `non-2xx attempt=${attempt + 1} status=${response.status}`, { context }); if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) { proxyPool.markFailed(); - usedProxy = false; + proxyPool.rotate(); } } catch (err) { lastError = err; if (usedProxy && proxyPool && proxyPool.size > 0 && init.proxy) { proxyPool.markFailed(); - usedProxy = false; + proxyPool.rotate(); } // Log the actual error so operators can diagnose @@ -190,16 +190,17 @@ export async function fetchWithRetry( } } - // All attempts exhausted — classify the last error - if (!response) { - const err = lastError ?? new Error("All connection attempts failed"); - logProxy("fetchWithRetry", "exhausted all retries", { context }); - return { errorClassification: classifyFetchErrorSafe(err) }; + // All attempts exhausted + logProxy("fetchWithRetry", "exhausted all retries", { context, hadResponse: !!response }); + + // If we got at least one HTTP response, return it as-is so the caller + // can relay the proper status code (e.g. 429). + if (response) { + return { response }; } - // Non-2xx but we have a response — pass it along (caller handles it) - logProxy("fetchWithRetry", `returning non-2xx response status=${response.status}`, { context }); - return { response }; + const err = lastError ?? new Error("All connection attempts failed"); + return { errorClassification: classifyFetchErrorSafe(err) }; } /** @@ -270,6 +271,7 @@ export async function fetchWithSessionRetry( } let lastError: unknown; + let lastResponse: Response | undefined; for (let attempt = 0; attempt < maxRetries; attempt++) { // First attempt: acquire() assigns a proxy to this session (least-loaded @@ -303,9 +305,10 @@ export async function fetchWithSessionRetry( return { response }; } - // Non-2xx — mark session failed and retry + // Non-2xx — rotate proxy immediately for the next attempt lastError = new Error(`Upstream returned ${response.status}`); - const rotated = sessionPool.markFailed(sessionId); + lastResponse = response; + const rotated = sessionPool.rotateNow(sessionId); logProxy("fetchWithSessionRetry", `non-2xx attempt=${attempt + 1} status=${response.status} rotated=${rotated}`, { context, sessionId: sessionId.slice(0, 8), @@ -319,7 +322,7 @@ export async function fetchWithSessionRetry( ); } catch (err) { lastError = err; - const rotated = sessionPool.markFailed(sessionId); + const rotated = sessionPool.rotateNow(sessionId); const errMsg = err instanceof Error ? err.message : String(err); logProxy("fetchWithSessionRetry", `failed attempt=${attempt + 1} err=${errMsg} rotated=${rotated}`, { context, @@ -335,12 +338,21 @@ export async function fetchWithSessionRetry( } } - // All attempts exhausted — release session and classify last error - logProxy("fetchWithSessionRetry", "exhausted all retries — releasing session", { + // All attempts exhausted + logProxy("fetchWithSessionRetry", "exhausted all retries", { context, sessionId: sessionId.slice(0, 8), + hadResponse: !!lastResponse, }); sessionPool.release(sessionId); + + // If we got at least one HTTP response (e.g. 429), return it so the caller + // can relay the proper status code. Only classify as error on network + // failures where there's no response at all. + if (lastResponse) { + return { response: lastResponse }; + } + const err = lastError ?? new Error("All session proxy attempts failed"); return { errorClassification: classifyFetchErrorSafe(err) }; } diff --git a/src/lib/proxy-pool.ts b/src/lib/proxy-pool.ts index 05a04e7..3e764a9 100644 --- a/src/lib/proxy-pool.ts +++ b/src/lib/proxy-pool.ts @@ -361,6 +361,60 @@ export class SessionProxyPool { return true; } + /** + * Force-rotate this session to the least-loaded proxy immediately, + * regardless of failure count. Resets the session's failure counter. + * + * @returns true if the session was moved to a different proxy. + */ + rotateNow(sessionId: string): boolean { + const info = this.sessions.get(sessionId); + if (!info) return false; + + const oldIndex = info.proxyIndex; + const oldEntry = this.poolEntryAtIndex(oldIndex); + + // Remove from old proxy usage first + const usedBy = this.proxyUsage.get(oldIndex); + if (usedBy) { + usedBy.delete(sessionId); + if (usedBy.size === 0) this.proxyUsage.delete(oldIndex); + } + + // Find the least-loaded proxy that is NOT the current one. + // Start scanning from (oldIndex + 1) so we don't immediately + // bounce back to index 0 when all usage counts are equal. + let bestIndex = -1; + let bestCount = Infinity; + for (let step = 1; step <= this.pool.size; step++) { + const i = (oldIndex + step) % this.pool.size; + const count = this.proxyUsage.get(i)?.size ?? 0; + if (count < bestCount) { + bestCount = count; + bestIndex = i; + } + } + + if (bestIndex === -1) { + // Single-proxy pool — reinstate and give up + if (usedBy) usedBy.add(sessionId); + return false; + } + + this.sessions.set(sessionId, { proxyIndex: bestIndex, failures: 0 }); + + let newUsedBy = this.proxyUsage.get(bestIndex); + if (!newUsedBy) { + newUsedBy = new Set(); + this.proxyUsage.set(bestIndex, newUsedBy); + } + newUsedBy.add(sessionId); + + const newEntry = this.poolEntryAtIndex(bestIndex); + logPool(`rotateNow session=${sessionId.slice(0, 8)} ${oldEntry?.host} -> ${newEntry?.host}`); + return true; + } + /** Reset failure count for this session's proxy. */ markSuccess(sessionId: string): void { const info = this.sessions.get(sessionId);