chore: claude绑定账号响应限流提示

This commit is contained in:
shaw
2025-10-04 11:31:21 +08:00
parent cd72a29674
commit 2872198259
4 changed files with 245 additions and 66 deletions

View File

@@ -102,11 +102,32 @@ async function handleMessagesRequest(req, res) {
// 使用统一调度选择账号(传递请求的模型) // 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model const requestedModel = req.body.model
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( let accountId
let accountType
try {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
sessionHash, sessionHash,
requestedModel requestedModel
) )
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
)
res.status(403)
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify({
error: 'upstream_rate_limited',
message: limitMessage
})
)
return
}
throw error
}
// 根据账号类型选择对应的转发服务并调用 // 根据账号类型选择对应的转发服务并调用
if (accountType === 'claude-official') { if (accountType === 'claude-official') {
@@ -513,11 +534,27 @@ async function handleMessagesRequest(req, res) {
// 使用统一调度选择账号(传递请求的模型) // 使用统一调度选择账号(传递请求的模型)
const requestedModel = req.body.model const requestedModel = req.body.model
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( let accountId
let accountType
try {
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, req.apiKey,
sessionHash, sessionHash,
requestedModel requestedModel
) )
;({ accountId, accountType } = selection)
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
error.rateLimitEndAt
)
return res.status(403).json({
error: 'upstream_rate_limited',
message: limitMessage
})
}
throw error
}
// 根据账号类型选择对应的转发服务 // 根据账号类型选择对应的转发服务
let response let response

View File

@@ -206,11 +206,23 @@ async function handleChatCompletion(req, res, apiKeyData) {
const sessionHash = sessionHelper.generateSessionHash(claudeRequest) const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
// 选择可用的Claude账户 // 选择可用的Claude账户
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( let accountSelection
try {
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
apiKeyData, apiKeyData,
sessionHash, sessionHash,
claudeRequest.model claudeRequest.model
) )
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt)
return res.status(403).json({
error: 'upstream_rate_limited',
message: limitMessage
})
}
throw error
}
const { accountId } = accountSelection const { accountId } = accountSelection
// 获取该账号存储的 Claude Code headers // 获取该账号存储的 Claude Code headers

View File

@@ -22,6 +22,14 @@ class ClaudeRelayService {
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
} }
_buildStandardRateLimitMessage(resetTime) {
if (!resetTime) {
return '此专属账号已触发 Anthropic 限流控制。'
}
const formattedReset = formatDateWithTimezone(resetTime)
return `此专属账号已触发 Anthropic 限流控制,将于 ${formattedReset} 自动恢复。`
}
_buildOpusLimitMessage(resetTime) { _buildOpusLimitMessage(resetTime) {
if (!resetTime) { if (!resetTime) {
return '此专属账号的Opus模型已达到周使用限制请尝试切换其他模型后再试。' return '此专属账号的Opus模型已达到周使用限制请尝试切换其他模型后再试。'
@@ -71,11 +79,31 @@ class ClaudeRelayService {
const sessionHash = sessionHelper.generateSessionHash(requestBody) const sessionHash = sessionHelper.generateSessionHash(requestBody)
// 选择可用的Claude账户支持专属绑定和sticky会话 // 选择可用的Claude账户支持专属绑定和sticky会话
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( let accountSelection
try {
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
apiKeyData, apiKeyData,
sessionHash, sessionHash,
requestBody.model requestBody.model
) )
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt)
logger.warn(
`🚫 Dedicated account ${error.accountId} is rate limited for API key ${apiKeyData.name}, returning 403`
)
return {
statusCode: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: 'upstream_rate_limited',
message: limitMessage
}),
accountId: error.accountId
}
}
throw error
}
const { accountId } = accountSelection const { accountId } = accountSelection
const { accountType } = accountSelection const { accountType } = accountSelection
@@ -170,6 +198,7 @@ class ClaudeRelayService {
if (response.statusCode !== 200 && response.statusCode !== 201) { if (response.statusCode !== 200 && response.statusCode !== 201) {
let isRateLimited = false let isRateLimited = false
let rateLimitResetTimestamp = null let rateLimitResetTimestamp = null
let dedicatedRateLimitMessage = null
// 检查是否为401状态码未授权 // 检查是否为401状态码未授权
if (response.statusCode === 401) { if (response.statusCode === 401) {
@@ -258,6 +287,11 @@ class ClaudeRelayService {
`🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})`
) )
} }
if (isDedicatedOfficialAccount) {
dedicatedRateLimitMessage = this._buildStandardRateLimitMessage(
rateLimitResetTimestamp || account?.rateLimitEndAt
)
}
} }
} else { } else {
// 检查响应体中的错误信息 // 检查响应体中的错误信息
@@ -284,6 +318,11 @@ class ClaudeRelayService {
} }
if (isRateLimited) { if (isRateLimited) {
if (isDedicatedOfficialAccount && !dedicatedRateLimitMessage) {
dedicatedRateLimitMessage = this._buildStandardRateLimitMessage(
rateLimitResetTimestamp || account?.rateLimitEndAt
)
}
logger.warn( logger.warn(
`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}` `🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`
) )
@@ -294,6 +333,18 @@ class ClaudeRelayService {
sessionHash, sessionHash,
rateLimitResetTimestamp rateLimitResetTimestamp
) )
if (dedicatedRateLimitMessage) {
return {
statusCode: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: 'upstream_rate_limited',
message: dedicatedRateLimitMessage
}),
accountId
}
}
} }
} else if (response.statusCode === 200 || response.statusCode === 201) { } else if (response.statusCode === 200 || response.statusCode === 201) {
// 提取5小时会话窗口状态 // 提取5小时会话窗口状态
@@ -886,11 +937,31 @@ class ClaudeRelayService {
const sessionHash = sessionHelper.generateSessionHash(requestBody) const sessionHash = sessionHelper.generateSessionHash(requestBody)
// 选择可用的Claude账户支持专属绑定和sticky会话 // 选择可用的Claude账户支持专属绑定和sticky会话
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( let accountSelection
try {
accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
apiKeyData, apiKeyData,
sessionHash, sessionHash,
requestBody.model requestBody.model
) )
} catch (error) {
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt)
if (!responseStream.headersSent) {
responseStream.status(403)
responseStream.setHeader('Content-Type', 'application/json')
}
responseStream.write(
JSON.stringify({
error: 'upstream_rate_limited',
message: limitMessage
})
)
responseStream.end()
return
}
throw error
}
const { accountId } = accountSelection const { accountId } = accountSelection
const { accountType } = accountSelection const { accountType } = accountSelection
@@ -1049,14 +1120,18 @@ class ClaudeRelayService {
// 错误响应处理 // 错误响应处理
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
if (res.statusCode === 429 && isOpusModelRequest) { if (res.statusCode === 429) {
const resetHeader = res.headers const resetHeader = res.headers
? res.headers['anthropic-ratelimit-unified-reset'] ? res.headers['anthropic-ratelimit-unified-reset']
: null : null
const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN
if (isOpusModelRequest) {
if (!Number.isNaN(parsedResetTimestamp)) { if (!Number.isNaN(parsedResetTimestamp)) {
await claudeAccountService.markAccountOpusRateLimited(accountId, parsedResetTimestamp) await claudeAccountService.markAccountOpusRateLimited(
accountId,
parsedResetTimestamp
)
logger.warn( logger.warn(
`🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` `🚫 [Stream] Account ${accountId} hit Opus limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}`
) )
@@ -1079,6 +1154,38 @@ class ClaudeRelayService {
resolve() resolve()
return return
} }
} else {
const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp)
? null
: parsedResetTimestamp
await unifiedClaudeScheduler.markAccountRateLimited(
accountId,
accountType,
sessionHash,
rateLimitResetTimestamp
)
logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`)
if (isDedicatedOfficialAccount) {
const limitMessage = this._buildStandardRateLimitMessage(
rateLimitResetTimestamp || account?.rateLimitEndAt
)
if (!responseStream.headersSent) {
responseStream.status(403)
responseStream.setHeader('Content-Type', 'application/json')
}
responseStream.write(
JSON.stringify({
error: 'upstream_rate_limited',
message: limitMessage
})
)
responseStream.end()
res.resume()
resolve()
return
}
}
} }
// 将错误处理逻辑封装在一个异步函数中 // 将错误处理逻辑封装在一个异步函数中

View File

@@ -159,12 +159,22 @@ class UnifiedClaudeScheduler {
// 普通专属账户 // 普通专属账户
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
if ( if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
boundAccount && const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
boundAccount.isActive === 'true' && if (isRateLimited) {
boundAccount.status !== 'error' && const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
this._isSchedulable(boundAccount.schedulable) const error = new Error('Dedicated Claude account is rate limited')
) { error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool`
)
} else {
if (isOpusRequest) { if (isOpusRequest) {
await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id)
} }
@@ -175,9 +185,10 @@ class UnifiedClaudeScheduler {
accountId: apiKeyData.claudeAccountId, accountId: apiKeyData.claudeAccountId,
accountType: 'claude-official' accountType: 'claude-official'
} }
}
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable}), falling back to pool` `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool`
) )
} }
} }
@@ -334,11 +345,23 @@ class UnifiedClaudeScheduler {
boundAccount.isActive === 'true' && boundAccount.isActive === 'true' &&
boundAccount.status !== 'error' && boundAccount.status !== 'error' &&
boundAccount.status !== 'blocked' && boundAccount.status !== 'blocked' &&
boundAccount.status !== 'temp_error' && boundAccount.status !== 'temp_error'
this._isSchedulable(boundAccount.schedulable)
) { ) {
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) { if (isRateLimited) {
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
const error = new Error('Dedicated Claude account is rate limited')
error.code = 'CLAUDE_DEDICATED_RATE_LIMITED'
error.accountId = boundAccount.id
error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null
throw error
}
if (!this._isSchedulable(boundAccount.schedulable)) {
logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})`
)
} else {
logger.info( logger.info(
`🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})` `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})`
) )
@@ -354,7 +377,7 @@ class UnifiedClaudeScheduler {
} }
} else { } else {
logger.warn( logger.warn(
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}, schedulable: ${boundAccount?.schedulable})` `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})`
) )
} }
} }