feat: 为 claude-official 账户添加 403 错误重试机制

针对 OAuth 和 Setup Token 类型的 Claude 账户,遇到 403 错误时:
- 休息 2 秒后进行重试
- 最多重试 2 次(总共最多 3 次请求)
- 重试后仍是 403 才标记账户为 blocked

同时支持流式和非流式请求,并修复了流式请求中的竞态条件问题。
This commit is contained in:
shaw
2025-12-24 19:54:25 +08:00
parent 671451253f
commit 67c20fa30e

View File

@@ -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()