From 95f83a8d96cf653b808e9b74ddc853934929cc14 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 27 Aug 2026 17:32:07 +0800 Subject: [PATCH] fix(claude): treat allowed_warning as allowed in unified rate limit checks - Treat `allowed_warning` status as allowed for shared 5h and 7d rate limit windows. - Ensure Fable-only rejections with warning-level shared windows remain model-scoped instead of credential-scoped. Closes: #5275 --- .../claude_executor_fable_ratelimit_test.go | 23 ++++ .../executor/helps/claude_ratelimit.go | 10 +- .../executor/helps/claude_ratelimit_test.go | 114 ++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/internal/runtime/executor/claude_executor_fable_ratelimit_test.go b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go index f0d2f915..688883ca 100644 --- a/internal/runtime/executor/claude_executor_fable_ratelimit_test.go +++ b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go @@ -43,6 +43,29 @@ func TestClassifyClaudeUpstreamError_FableOnlyRejectionIsModelScoped(t *testing. } } +func TestClassifyClaudeUpstreamError_FableOnlyAllowedWarningIsModelScoped(t *testing.T) { + // Given: 7d window is in allowed_warning, 7d_oi is rejected + headers := http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + "Retry-After": []string{"120"}, + } + + // When + err := classifyClaudeUpstreamError(http.StatusTooManyRequests, headers, []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"Fable usage window rejected."}}`)) + + // Then + var scoped interface{ IsCredentialScoped() bool } + if !errors.As(err, &scoped) || scoped == nil { + t.Fatalf("expected %T to expose credential scope", err) + } + if scoped.IsCredentialScoped() { + t.Fatal("Fable-only 7d_oi rejection with allowed_warning was credential-scoped; want model-scoped") + } +} + func TestClassifyClaudeUpstreamError_SharedOrAmbiguousRejectionRemainsCredentialScoped(t *testing.T) { tests := []struct { name string diff --git a/internal/runtime/executor/helps/claude_ratelimit.go b/internal/runtime/executor/helps/claude_ratelimit.go index 3e265694..45fea296 100644 --- a/internal/runtime/executor/helps/claude_ratelimit.go +++ b/internal/runtime/executor/helps/claude_ratelimit.go @@ -18,7 +18,7 @@ const ( // ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly // declare an Anthropic shared 5h or 7d rate-limit rejection. A Fable-only 7d_oi rejection -// remains model-scoped when both shared windows are explicitly allowed. +// remains model-scoped when both shared windows are explicitly allowed (status "allowed" or "allowed_warning"). func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { if headers == nil { return false @@ -39,8 +39,12 @@ func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool { return !isFableOnlyRejection(status5h, status7d, status7dOI) } +func isClaudeWindowAllowed(status string) bool { + return status == "allowed" || status == "allowed_warning" +} + func isFableOnlyRejection(status5h, status7d, status7dOI string) bool { - return status5h == "allowed" && status7d == "allowed" && status7dOI == "rejected" + return isClaudeWindowAllowed(status5h) && isClaudeWindowAllowed(status7d) && status7dOI == "rejected" } // ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific @@ -117,7 +121,7 @@ func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFu // 5. Unified reset header: unifiedRejected := !fableOnlyRejection && (unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" || status7dOI == "rejected" || - (unifiedStatus == "" && status5h != "allowed" && status7d != "allowed")) + (unifiedStatus == "" && !isClaudeWindowAllowed(status5h) && !isClaudeWindowAllowed(status7d))) if unifiedRejected { if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Reset"); raw != "" { diff --git a/internal/runtime/executor/helps/claude_ratelimit_test.go b/internal/runtime/executor/helps/claude_ratelimit_test.go index c58b8f5f..4a0a861b 100644 --- a/internal/runtime/executor/helps/claude_ratelimit_test.go +++ b/internal/runtime/executor/helps/claude_ratelimit_test.go @@ -147,6 +147,42 @@ func TestParseClaudeRateLimitReset_AllCases(t *testing.T) { } }) + t.Run("fable-only rejection with allowed_warning on shared window uses retry-after only", func(t *testing.T) { + h := make(http.Header) + h.Set("Anthropic-Ratelimit-Unified-Status", "rejected") + h.Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed") + h.Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed_warning") + h.Set("Anthropic-Ratelimit-Unified-7d_oi-Status", "rejected") + h.Set("Anthropic-Ratelimit-Unified-7d_oi-Reset", strconv.FormatInt(now.Add(7*24*time.Hour).Unix(), 10)) + h.Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(now.Add(7*24*time.Hour).Unix(), 10)) + h.Set("Retry-After", "60") + + got := parseClaudeRateLimitResetWithFuzz(h, now, 0, 0) + if got == nil { + t.Fatal("expected non-nil RetryAfter") + } + if *got != 60*time.Second { + t.Fatalf("expected 60s from Retry-After, got %v", *got) + } + }) + + t.Run("missing unified status with allowed_warning shared windows ignores unified reset and uses retry-after", func(t *testing.T) { + h := make(http.Header) + // Missing Anthropic-Ratelimit-Unified-Status, shared windows are allowed_warning + h.Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed_warning") + h.Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed_warning") + h.Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(now.Add(7*24*time.Hour).Unix(), 10)) + h.Set("Retry-After", "60") + + got := parseClaudeRateLimitResetWithFuzz(h, now, 0, 0) + if got == nil { + t.Fatal("expected non-nil RetryAfter") + } + if *got != 60*time.Second { + t.Fatalf("expected 60s from Retry-After, got %v", *got) + } + }) + t.Run("non-fable combined rejection with 7d_oi reset keeps longer duration", func(t *testing.T) { h := make(http.Header) h.Set("Anthropic-Ratelimit-Unified-Status", "rejected") @@ -191,3 +227,81 @@ func TestParseClaudeRateLimitReset_AllCases(t *testing.T) { } }) } + +func TestClaudeHeadersIndicateUnifiedRateLimitRejection_AllowedWarning(t *testing.T) { + tests := []struct { + name string + headers http.Header + expected bool + }{ + { + name: "both shared windows allowed, 7d_oi rejected is fable-only", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: false, + }, + { + name: "7d allowed_warning and 5h allowed with 7d_oi rejected is fable-only", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: false, + }, + { + name: "5h allowed_warning and 7d allowed with 7d_oi rejected is fable-only", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: false, + }, + { + name: "both shared windows allowed_warning with 7d_oi rejected is fable-only", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: false, + }, + { + name: "5h rejected even if 7d allowed_warning is unified rejection", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: true, + }, + { + name: "7d rejected even if 5h allowed_warning is unified rejection", + headers: http.Header{ + "Anthropic-Ratelimit-Unified-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-5h-Status": []string{"allowed_warning"}, + "Anthropic-Ratelimit-Unified-7d-Status": []string{"rejected"}, + "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ClaudeHeadersIndicateUnifiedRateLimitRejection(tt.headers) + if got != tt.expected { + t.Fatalf("ClaudeHeadersIndicateUnifiedRateLimitRejection() = %v, want %v", got, tt.expected) + } + }) + } +} -- 2.54.0