From 67c20fa30eda3e9dabebf27e81e61df2b3890003 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 24 Dec 2025 19:54:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=20claude-official=20=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E6=B7=BB=E5=8A=A0=20403=20=E9=94=99=E8=AF=AF=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时: - 休息 2 秒后进行重试 - 最多重试 2 次(总共最多 3 次请求) - 重试后仍是 403 才标记账户为 blocked 同时支持流式和非流式请求,并修复了流式请求中的竞态条件问题。 --- src/services/claudeRelayService.js | 118 +++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 14 deletions(-) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 40c6103b..8fb90685 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -333,17 +333,46 @@ class ClaudeRelayService { } // 发送请求到Claude API(传入回调以获取请求对象) - const response = await this._makeClaudeRequest( - processedBody, - accessToken, - proxyAgent, - clientHeaders, - accountId, - (req) => { - upstreamRequest = req - }, - options - ) + // 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token) + const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 + let retryCount = 0 + let response + let shouldRetry = false + + do { + response = await this._makeClaudeRequest( + processedBody, + accessToken, + proxyAgent, + clientHeaders, + accountId, + (req) => { + upstreamRequest = req + }, + options + ) + + // 检查是否需要重试 403 + shouldRetry = response.statusCode === 403 && retryCount < maxRetries + if (shouldRetry) { + retryCount++ + logger.warn( + `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + ) + await this._sleep(2000) + } + } while (shouldRetry) + + // 如果进行了重试,记录最终结果 + if (retryCount > 0) { + if (response.statusCode === 403) { + logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`) + } else { + logger.info( + `✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}` + ) + } + } // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) // 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻 @@ -408,9 +437,10 @@ class ClaudeRelayService { } } // 检查是否为403状态码(禁止访问) + // 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果 else if (response.statusCode === 403) { logger.error( - `🚫 Forbidden error (403) detected for account ${accountId}, marking as blocked` + `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked` ) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) } @@ -1517,8 +1547,10 @@ class ClaudeRelayService { streamTransformer = null, requestOptions = {}, isDedicatedOfficialAccount = false, - onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁 + onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁 + retryCount = 0 // 🔄 403 重试计数器 ) { + const maxRetries = 2 // 最大重试次数 // 获取账户信息用于统一 User-Agent const account = await claudeAccountService.getAccount(accountId) @@ -1631,6 +1663,51 @@ class ClaudeRelayService { } } + // 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理) + // 否则重试时旧响应的 on('end') 会与新请求产生竞态条件 + if (res.statusCode === 403) { + const canRetry = + this._shouldRetryOn403(accountType) && + retryCount < maxRetries && + !responseStream.headersSent + + if (canRetry) { + logger.warn( + `🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s` + ) + // 消费当前响应并销毁请求 + res.resume() + req.destroy() + + // 等待 2 秒后递归重试 + await this._sleep(2000) + + try { + // 递归调用自身进行重试 + const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer, + requestOptions, + isDedicatedOfficialAccount, + onResponseStart, + retryCount + 1 + ) + resolve(retryResult) + } catch (retryError) { + reject(retryError) + } + return // 重要:提前返回,不设置后续的错误处理器 + } + } + // 将错误处理逻辑封装在一个异步函数中 const handleErrorResponse = async () => { if (res.statusCode === 401) { @@ -1654,8 +1731,10 @@ class ClaudeRelayService { ) } } else if (res.statusCode === 403) { + // 403 处理:走到这里说明重试已用尽或不适用重试,直接标记 blocked + // 注意:重试逻辑已在 handleErrorResponse 外部提前处理 logger.error( - `🚫 [Stream] Forbidden error (403) detected for account ${accountId}, marking as blocked` + `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, marking as blocked` ) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) } else if (res.statusCode === 529) { @@ -2693,6 +2772,17 @@ class ClaudeRelayService { } } } + + // 🔄 判断账户是否应该在 403 错误时进行重试 + // 仅 claude-official 类型账户(OAuth 或 Setup Token 授权)需要重试 + _shouldRetryOn403(accountType) { + return accountType === 'claude-official' + } + + // ⏱️ 等待指定毫秒数 + _sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } } module.exports = new ClaudeRelayService()