mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat(claude): limit 5-hour warning notifications to prevent spam
## Problem - Original implementation sends webhook notification on EVERY request when account reaches 5-hour limit warning status - Users receive hundreds of duplicate notifications within same 5-hour window ## Solution - Add `maxFiveHourWarningsPerWindow` config (default: 1, max: 10) - Track warning count per session window with metadata: - fiveHourWarningWindow: identifies current window - fiveHourWarningCount: tracks notifications sent - fiveHourWarningLastSentAt: last notification timestamp - Only send notification if count < max limit - Auto-reset counters when entering new 5-hour window ## Changes - Add warning limit control in constructor - Add `_clearFiveHourWarningMetadata()` helper method - Update `updateSessionWindowStatus()` with notification throttling - Clear warning metadata on window refresh and manual schedule recovery ## Configuration - Environment: CLAUDE_5H_WARNING_MAX_NOTIFICATIONS (1-10) - Config: config.claude.fiveHourWarning.maxNotificationsPerWindow - Default: 1 notification per window ## Testing - Tested with accounts reaching 5h limit - Verified single notification per window - Confirmed counter reset on new window
This commit is contained in:
@@ -21,6 +21,17 @@ class ClaudeAccountService {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token'
|
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token'
|
||||||
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
||||||
|
let maxWarnings = parseInt(process.env.CLAUDE_5H_WARNING_MAX_NOTIFICATIONS || '', 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(maxWarnings) && config.claude?.fiveHourWarning) {
|
||||||
|
maxWarnings = parseInt(config.claude.fiveHourWarning.maxNotificationsPerWindow, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(maxWarnings) || maxWarnings < 1) {
|
||||||
|
maxWarnings = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maxFiveHourWarningsPerWindow = Math.min(maxWarnings, 10)
|
||||||
|
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||||
@@ -683,6 +694,8 @@ class ClaudeAccountService {
|
|||||||
delete updatedData.stoppedReason
|
delete updatedData.stoppedReason
|
||||||
shouldClearAutoStopFields = true
|
shouldClearAutoStopFields = true
|
||||||
|
|
||||||
|
await this._clearFiveHourWarningMetadata(accountId, updatedData)
|
||||||
|
|
||||||
// 如果是手动启用调度,记录日志
|
// 如果是手动启用调度,记录日志
|
||||||
if (updates.schedulable === true || updates.schedulable === 'true') {
|
if (updates.schedulable === true || updates.schedulable === 'true') {
|
||||||
logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
|
logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
|
||||||
@@ -1284,14 +1297,17 @@ class ClaudeAccountService {
|
|||||||
const accountKey = `claude:account:${accountId}`
|
const accountKey = `claude:account:${accountId}`
|
||||||
|
|
||||||
// 清除限流状态
|
// 清除限流状态
|
||||||
|
const redisKey = `claude:account:${accountId}`
|
||||||
|
await redis.client.hdel(redisKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitEndAt')
|
||||||
delete accountData.rateLimitedAt
|
delete accountData.rateLimitedAt
|
||||||
delete accountData.rateLimitStatus
|
delete accountData.rateLimitStatus
|
||||||
delete accountData.rateLimitEndAt // 清除限流结束时间
|
delete accountData.rateLimitEndAt // 清除限流结束时间
|
||||||
|
|
||||||
|
const hadAutoStop = accountData.rateLimitAutoStopped === 'true'
|
||||||
|
|
||||||
// 只恢复因限流而自动停止的账户
|
// 只恢复因限流而自动停止的账户
|
||||||
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
|
if (hadAutoStop && accountData.schedulable === 'false') {
|
||||||
accountData.schedulable = 'true'
|
accountData.schedulable = 'true'
|
||||||
delete accountData.rateLimitAutoStopped
|
|
||||||
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
|
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}`
|
`📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}`
|
||||||
@@ -1301,6 +1317,11 @@ class ClaudeAccountService {
|
|||||||
`ℹ️ Account ${accountId} did not need auto-resume: autoStopped=${accountData.rateLimitAutoStopped}, schedulable=${accountData.schedulable}`
|
`ℹ️ Account ${accountId} did not need auto-resume: autoStopped=${accountData.rateLimitAutoStopped}, schedulable=${accountData.schedulable}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hadAutoStop) {
|
||||||
|
await redis.client.hdel(redisKey, 'rateLimitAutoStopped')
|
||||||
|
delete accountData.rateLimitAutoStopped
|
||||||
|
}
|
||||||
await redis.setClaudeAccount(accountId, accountData)
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
|
|
||||||
// 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度
|
// 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度
|
||||||
@@ -1467,6 +1488,7 @@ class ClaudeAccountService {
|
|||||||
if (accountData.sessionWindowStatus) {
|
if (accountData.sessionWindowStatus) {
|
||||||
delete accountData.sessionWindowStatus
|
delete accountData.sessionWindowStatus
|
||||||
delete accountData.sessionWindowStatusUpdatedAt
|
delete accountData.sessionWindowStatusUpdatedAt
|
||||||
|
await this._clearFiveHourWarningMetadata(accountId, accountData)
|
||||||
shouldClearSessionStatus = true
|
shouldClearSessionStatus = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,6 +1500,7 @@ class ClaudeAccountService {
|
|||||||
accountData.schedulable = 'true'
|
accountData.schedulable = 'true'
|
||||||
delete accountData.fiveHourAutoStopped
|
delete accountData.fiveHourAutoStopped
|
||||||
delete accountData.fiveHourStoppedAt
|
delete accountData.fiveHourStoppedAt
|
||||||
|
await this._clearFiveHourWarningMetadata(accountId, accountData)
|
||||||
shouldClearFiveHourFlags = true
|
shouldClearFiveHourFlags = true
|
||||||
|
|
||||||
// 发送Webhook通知
|
// 发送Webhook通知
|
||||||
@@ -1537,6 +1560,29 @@ class ClaudeAccountService {
|
|||||||
return endTime
|
return endTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _clearFiveHourWarningMetadata(accountId, accountData = null) {
|
||||||
|
if (accountData) {
|
||||||
|
delete accountData.fiveHourWarningWindow
|
||||||
|
delete accountData.fiveHourWarningCount
|
||||||
|
delete accountData.fiveHourWarningLastSentAt
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis.client && typeof redis.client.hdel === 'function') {
|
||||||
|
await redis.client.hdel(
|
||||||
|
`claude:account:${accountId}`,
|
||||||
|
'fiveHourWarningWindow',
|
||||||
|
'fiveHourWarningCount',
|
||||||
|
'fiveHourWarningLastSentAt'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Failed to clear five-hour warning metadata for account ${accountId}: ${error.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 获取会话窗口信息
|
// 📊 获取会话窗口信息
|
||||||
async getSessionWindowInfo(accountId) {
|
async getSessionWindowInfo(accountId) {
|
||||||
try {
|
try {
|
||||||
@@ -2145,6 +2191,9 @@ class ClaudeAccountService {
|
|||||||
delete updatedAccountData.fiveHourAutoStopped
|
delete updatedAccountData.fiveHourAutoStopped
|
||||||
delete updatedAccountData.fiveHourStoppedAt
|
delete updatedAccountData.fiveHourStoppedAt
|
||||||
delete updatedAccountData.tempErrorAutoStopped
|
delete updatedAccountData.tempErrorAutoStopped
|
||||||
|
delete updatedAccountData.fiveHourWarningWindow
|
||||||
|
delete updatedAccountData.fiveHourWarningCount
|
||||||
|
delete updatedAccountData.fiveHourWarningLastSentAt
|
||||||
// 兼容旧的标记
|
// 兼容旧的标记
|
||||||
delete updatedAccountData.autoStoppedAt
|
delete updatedAccountData.autoStoppedAt
|
||||||
delete updatedAccountData.stoppedReason
|
delete updatedAccountData.stoppedReason
|
||||||
@@ -2178,6 +2227,9 @@ class ClaudeAccountService {
|
|||||||
'rateLimitAutoStopped',
|
'rateLimitAutoStopped',
|
||||||
'fiveHourAutoStopped',
|
'fiveHourAutoStopped',
|
||||||
'fiveHourStoppedAt',
|
'fiveHourStoppedAt',
|
||||||
|
'fiveHourWarningWindow',
|
||||||
|
'fiveHourWarningCount',
|
||||||
|
'fiveHourWarningLastSentAt',
|
||||||
'tempErrorAutoStopped',
|
'tempErrorAutoStopped',
|
||||||
// 兼容旧的标记
|
// 兼容旧的标记
|
||||||
'autoStoppedAt',
|
'autoStoppedAt',
|
||||||
@@ -2445,22 +2497,51 @@ class ClaudeAccountService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const nowIso = now.toISOString()
|
||||||
|
|
||||||
// 更新会话窗口状态
|
// 更新会话窗口状态
|
||||||
accountData.sessionWindowStatus = status
|
accountData.sessionWindowStatus = status
|
||||||
accountData.sessionWindowStatusUpdatedAt = new Date().toISOString()
|
accountData.sessionWindowStatusUpdatedAt = nowIso
|
||||||
|
|
||||||
// 如果状态是 allowed_warning 且账户设置了自动停止调度
|
// 如果状态是 allowed_warning 且账户设置了自动停止调度
|
||||||
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
|
if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') {
|
||||||
|
const alreadyAutoStopped =
|
||||||
|
accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true'
|
||||||
|
|
||||||
|
if (!alreadyAutoStopped) {
|
||||||
|
const windowIdentifier =
|
||||||
|
accountData.sessionWindowEnd || accountData.sessionWindowStart || 'unknown'
|
||||||
|
|
||||||
|
let warningCount = 0
|
||||||
|
if (accountData.fiveHourWarningWindow === windowIdentifier) {
|
||||||
|
const parsedCount = parseInt(accountData.fiveHourWarningCount || '0', 10)
|
||||||
|
warningCount = Number.isNaN(parsedCount) ? 0 : parsedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWarningsPerWindow = this.maxFiveHourWarningsPerWindow
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
|
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
|
||||||
)
|
)
|
||||||
accountData.schedulable = 'false'
|
accountData.schedulable = 'false'
|
||||||
// 使用独立的5小时限制自动停止标记
|
// 使用独立的5小时限制自动停止标记
|
||||||
accountData.fiveHourAutoStopped = 'true'
|
accountData.fiveHourAutoStopped = 'true'
|
||||||
accountData.fiveHourStoppedAt = new Date().toISOString()
|
accountData.fiveHourStoppedAt = nowIso
|
||||||
// 设置停止原因,供前端显示
|
// 设置停止原因,供前端显示
|
||||||
accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度'
|
accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度'
|
||||||
|
|
||||||
|
const canSendWarning = warningCount < maxWarningsPerWindow
|
||||||
|
let updatedWarningCount = warningCount
|
||||||
|
|
||||||
|
accountData.fiveHourWarningWindow = windowIdentifier
|
||||||
|
if (canSendWarning) {
|
||||||
|
updatedWarningCount += 1
|
||||||
|
accountData.fiveHourWarningLastSentAt = nowIso
|
||||||
|
}
|
||||||
|
accountData.fiveHourWarningCount = updatedWarningCount.toString()
|
||||||
|
|
||||||
|
if (canSendWarning) {
|
||||||
// 发送Webhook通知
|
// 发送Webhook通知
|
||||||
try {
|
try {
|
||||||
const webhookNotifier = require('../utils/webhookNotifier')
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
@@ -2471,11 +2552,21 @@ class ClaudeAccountService {
|
|||||||
status: 'warning',
|
status: 'warning',
|
||||||
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
errorCode: 'CLAUDE_5H_LIMIT_WARNING',
|
||||||
reason: '5小时使用量接近限制,已自动停止调度',
|
reason: '5小时使用量接近限制,已自动停止调度',
|
||||||
timestamp: getISOStringWithTimezone(new Date())
|
timestamp: getISOStringWithTimezone(now)
|
||||||
})
|
})
|
||||||
} catch (webhookError) {
|
} catch (webhookError) {
|
||||||
logger.error('Failed to send webhook notification:', webhookError)
|
logger.error('Failed to send webhook notification:', webhookError)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`⚠️ Account ${accountData.name} (${accountId}) reached max ${maxWarningsPerWindow} warning notifications for current 5h window, skipping webhook`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`⚠️ Account ${accountData.name} (${accountId}) already auto-stopped for 5h limit, skipping duplicate warning`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setClaudeAccount(accountId, accountData)
|
await redis.setClaudeAccount(accountId, accountData)
|
||||||
@@ -2692,6 +2783,7 @@ class ClaudeAccountService {
|
|||||||
updatedAccountData.schedulable = 'true'
|
updatedAccountData.schedulable = 'true'
|
||||||
delete updatedAccountData.fiveHourAutoStopped
|
delete updatedAccountData.fiveHourAutoStopped
|
||||||
delete updatedAccountData.fiveHourStoppedAt
|
delete updatedAccountData.fiveHourStoppedAt
|
||||||
|
await this._clearFiveHourWarningMetadata(account.id, updatedAccountData)
|
||||||
delete updatedAccountData.stoppedReason
|
delete updatedAccountData.stoppedReason
|
||||||
|
|
||||||
// 更新会话窗口(如果有新窗口)
|
// 更新会话窗口(如果有新窗口)
|
||||||
|
|||||||
Reference in New Issue
Block a user