Skip to content

Commit de38d62

Browse files
authored
Merge pull request #3250 from WesleyZiwen/codex/anthropic-429-window-reset
fix: preserve Anthropic window cooldowns
2 parents 9e9e154 + f6e0ebc commit de38d62

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

backend/internal/service/ratelimit_service.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
//
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//go:build unit
2+
3+
package service
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"strconv"
9+
"testing"
10+
"time"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
type anthropicWindowLimitRepo struct {
16+
mockAccountRepoForGemini
17+
rateLimitCalls int
18+
tempUnschedCalls int
19+
lastRateLimitReset time.Time
20+
}
21+
22+
func (r *anthropicWindowLimitRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error {
23+
r.rateLimitCalls++
24+
r.lastRateLimitReset = resetAt
25+
return nil
26+
}
27+
28+
func (r *anthropicWindowLimitRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
29+
r.tempUnschedCalls++
30+
return nil
31+
}
32+
33+
func TestHandleUpstreamError_AnthropicWindowLimitPreemptsTempUnschedRule(t *testing.T) {
34+
resetAt := time.Now().Add(3 * time.Hour).Truncate(time.Second)
35+
headers := http.Header{}
36+
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.02")
37+
headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(resetAt.Unix(), 10))
38+
39+
repo := &anthropicWindowLimitRepo{}
40+
svc := NewRateLimitService(repo, nil, nil, nil, nil)
41+
account := &Account{
42+
ID: 42,
43+
Type: AccountTypeOAuth,
44+
Platform: PlatformAnthropic,
45+
Credentials: map[string]any{
46+
"temp_unschedulable_enabled": true,
47+
"temp_unschedulable_rules": []any{
48+
map[string]any{
49+
"error_code": float64(http.StatusTooManyRequests),
50+
"keywords": []any{"rate limit"},
51+
"duration_minutes": float64(10),
52+
},
53+
},
54+
},
55+
}
56+
57+
svc.HandleUpstreamError(
58+
context.Background(),
59+
account,
60+
http.StatusTooManyRequests,
61+
headers,
62+
[]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."}}`),
63+
)
64+
65+
require.Zero(t, repo.tempUnschedCalls, "official Anthropic window limits should not be shortened by local temp-unsched rules")
66+
require.Equal(t, 1, repo.rateLimitCalls)
67+
require.Equal(t, resetAt, repo.lastRateLimitReset)
68+
}

0 commit comments

Comments
 (0)