@@ -181,6 +181,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
181181 return true
182182 }
183183
184+ // Anthropic official 5h / 7d window exhaustion is a hard account limit.
185+ // It must take precedence over user-configured 429 temp-unsched rules,
186+ // otherwise a broad "rate limit" keyword rule can shorten a multi-hour
187+ // cooldown to a local temporary pause.
188+ if statusCode == http .StatusTooManyRequests && account .Platform == PlatformAnthropic {
189+ if s .persistAnthropicExhaustedWindowLimit (ctx , account , headers ) {
190+ return false
191+ }
192+ }
193+
184194 // 先尝试临时不可调度规则(401除外)
185195 // 如果匹配成功,直接返回,不执行后续禁用逻辑
186196 if statusCode != 401 {
@@ -1083,6 +1093,115 @@ type anthropic429Result struct {
10831093 fiveHourReset * time.Time // 5h window reset timestamp (for session window calculation), nil if not available
10841094}
10851095
1096+ type anthropicWindowLimit struct {
1097+ window string
1098+ resetAt time.Time
1099+ reason string
1100+ }
1101+
1102+ func selectAnthropicExhaustedWindow (headers http.Header , now time.Time ) * anthropicWindowLimit {
1103+ reset5h , ok5hReset := parseAnthropicWindowReset (headers , "5h" , now )
1104+ reset7d , ok7dReset := parseAnthropicWindowReset (headers , "7d" , now )
1105+
1106+ exceeded5h := isAnthropic5hRejected (headers ) || isAnthropicWindowExceeded (headers , "5h" )
1107+ exceeded7d := isAnthropicWindowExceeded (headers , "7d" )
1108+
1109+ if exceeded7d && ok7dReset {
1110+ return & anthropicWindowLimit {
1111+ window : "7d" ,
1112+ resetAt : reset7d ,
1113+ reason : "anthropic_7d_window_exhausted" ,
1114+ }
1115+ }
1116+ if exceeded5h && ok5hReset {
1117+ return & anthropicWindowLimit {
1118+ window : "5h" ,
1119+ resetAt : reset5h ,
1120+ reason : "anthropic_5h_window_exhausted" ,
1121+ }
1122+ }
1123+ return nil
1124+ }
1125+
1126+ func isAnthropic5hRejected (headers http.Header ) bool {
1127+ return strings .EqualFold (strings .TrimSpace (headers .Get ("anthropic-ratelimit-unified-5h-status" )), "rejected" )
1128+ }
1129+
1130+ func parseAnthropicWindowReset (headers http.Header , window string , now time.Time ) (time.Time , bool ) {
1131+ raw := strings .TrimSpace (headers .Get ("anthropic-ratelimit-unified-" + window + "-reset" ))
1132+ if raw == "" {
1133+ return time.Time {}, false
1134+ }
1135+ ts , err := strconv .ParseInt (raw , 10 , 64 )
1136+ if err != nil {
1137+ return time.Time {}, false
1138+ }
1139+ if ts > 1e11 {
1140+ ts = ts / 1000
1141+ }
1142+ resetAt := time .Unix (ts , 0 )
1143+ if ! resetAt .After (now ) {
1144+ return time.Time {}, false
1145+ }
1146+
1147+ maxAge := 8 * 24 * time .Hour
1148+ if window == "5h" {
1149+ maxAge = 6 * time .Hour
1150+ }
1151+ if resetAt .After (now .Add (maxAge )) {
1152+ return time.Time {}, false
1153+ }
1154+ return resetAt , true
1155+ }
1156+
1157+ func shouldPersistAnthropicWindowLimit (account * Account , limit * anthropicWindowLimit , now time.Time ) bool {
1158+ if account == nil || limit == nil || ! limit .resetAt .After (now ) {
1159+ return false
1160+ }
1161+ if account .RateLimitResetAt == nil {
1162+ return true
1163+ }
1164+ if ! account .RateLimitResetAt .After (now ) {
1165+ return true
1166+ }
1167+ return limit .resetAt .After (* account .RateLimitResetAt )
1168+ }
1169+
1170+ func (s * RateLimitService ) persistAnthropicExhaustedWindowLimit (ctx context.Context , account * Account , headers http.Header ) bool {
1171+ if s == nil || s .accountRepo == nil || account == nil {
1172+ return false
1173+ }
1174+ now := time .Now ()
1175+ limit := selectAnthropicExhaustedWindow (headers , now )
1176+ if limit == nil {
1177+ return false
1178+ }
1179+ if ! shouldPersistAnthropicWindowLimit (account , limit , now ) {
1180+ slog .Info ("anthropic_window_rate_limit_kept" ,
1181+ "account_id" , account .ID ,
1182+ "window" , limit .window ,
1183+ "reset_at" , limit .resetAt ,
1184+ "existing_reset_at" , account .RateLimitResetAt )
1185+ return true
1186+ }
1187+
1188+ s .notifyAccountSchedulingBlocked (account , limit .resetAt , limit .reason )
1189+ if err := s .accountRepo .SetRateLimited (ctx , account .ID , limit .resetAt ); err != nil {
1190+ slog .Warn ("anthropic_window_rate_limit_set_failed" ,
1191+ "account_id" , account .ID ,
1192+ "window" , limit .window ,
1193+ "reset_at" , limit .resetAt ,
1194+ "error" , err )
1195+ return true
1196+ }
1197+ slog .Info ("anthropic_window_rate_limited" ,
1198+ "account_id" , account .ID ,
1199+ "window" , limit .window ,
1200+ "reset_at" , limit .resetAt ,
1201+ "reset_in" , time .Until (limit .resetAt ).Truncate (time .Second ))
1202+ return true
1203+ }
1204+
10861205// calculateAnthropic429ResetTime parses Anthropic's per-window rate-limit headers
10871206// to determine which window (5h or 7d) actually triggered the 429.
10881207//
0 commit comments