Skip to content

Commit 31fc08a

Browse files
lawrencecchenclaude
andcommitted
Don't overload-retry a rejected 5xx; let it fail over
Addresses autoreview P2: the broad 5xx overload branch preempted the rejected-header failover, so a depleted account answering 5xx with unified-status=rejected would burn same-account retries instead of rerouting to a healthy account. Gate the overload path on the absence of the rejected header; rejected responses fall through to the usage-limit failover as before. Test: rejected 500 fails over immediately (no backoff waits), marks the account exhausted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2df40db commit 31fc08a

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

internal/proxy/claude_ratelimit_routing_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,3 +875,50 @@ func TestClaudeOverloadBackoff(t *testing.T) {
875875
t.Fatal("claudeOverloadStatus classification wrong")
876876
}
877877
}
878+
879+
// TestClaudeRejected5xxFailsOverNotOverloadRetried: a 5xx that carries the
880+
// rejected unified-status header is a depleted account, not API overload, so it
881+
// must fail over to a healthy account instead of burning same-account retries.
882+
func TestClaudeRejected5xxFailsOverNotOverloadRetried(t *testing.T) {
883+
server, store := claudeFailoverServer(t)
884+
if _, err := store.Put("claude", "session-r5", "cooked@example.com", ""); err != nil {
885+
t.Fatal(err)
886+
}
887+
var cookedCalls, freshCalls int
888+
stub := &stubRoundTripper{responses: func(req *http.Request) *http.Response {
889+
if strings.Contains(req.Header.Get("Authorization"), "tok-cooked") {
890+
cookedCalls++
891+
h := http.Header{}
892+
h.Set("Anthropic-Ratelimit-Unified-Status", "rejected")
893+
return &http.Response{StatusCode: http.StatusInternalServerError, Header: h, Body: io.NopCloser(strings.NewReader(`{"type":"error"}`))}
894+
}
895+
freshCalls++
896+
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"id":"msg_fresh"}`))}
897+
}}
898+
var waits []time.Duration
899+
transport := usageLimitRetryTransport{
900+
base: stub, server: &server, provider: accounts.ProviderClaude,
901+
agent: "claude", session: "session-r5", account: "cooked@example.com",
902+
method: http.MethodPost, path: "/v1/messages", maxAttempts: 6,
903+
sleep: recordSleep(&waits),
904+
}
905+
req, _ := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader([]byte(`{}`)))
906+
req.Header.Set("Authorization", "Bearer tok-cooked")
907+
response, err := transport.RoundTrip(req)
908+
if err != nil {
909+
t.Fatal(err)
910+
}
911+
defer response.Body.Close()
912+
if response.StatusCode != http.StatusOK {
913+
t.Fatalf("status = %d, want 200 via failover", response.StatusCode)
914+
}
915+
if cookedCalls != 1 || freshCalls != 1 {
916+
t.Fatalf("calls cooked=%d fresh=%d, want 1/1 (failover, not same-account overload retry)", cookedCalls, freshCalls)
917+
}
918+
if len(waits) != 0 {
919+
t.Fatalf("overload backoff waits = %v, want none for a rejected 5xx", waits)
920+
}
921+
if !server.SchedulerRef.Get().Exhausted(accounts.ProviderClaude, "cooked@example.com") {
922+
t.Fatal("a rejected 5xx must mark the depleted account exhausted")
923+
}
924+
}

internal/proxy/proxy.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,8 +2847,11 @@ func (t usageLimitRetryTransport) RoundTrip(req *http.Request) (*http.Response,
28472847
// backoff. Overload is API-wide, not account-specific, so no failover, no
28482848
// exhaustion-marking, and no failover-budget consumption. Once the small
28492849
// overload budget is spent the 5xx passes through and the client's own
2850-
// backoff takes over.
2851-
if t.provider == accounts.ProviderClaude && claudeOverloadStatus(response.StatusCode) {
2850+
// backoff takes over. A 5xx that carries the rejected unified-status
2851+
// header is NOT overload-retried: rejected means this account is out of
2852+
// quota regardless of HTTP status, so it falls through to the usage-limit
2853+
// path below and fails over to a healthy account instead.
2854+
if t.provider == accounts.ProviderClaude && claudeOverloadStatus(response.StatusCode) && !claudeResponseRejected(response.Header) {
28522855
if overloadRetries >= claudeOverloadMaxRetries {
28532856
return response, nil
28542857
}

0 commit comments

Comments
 (0)