Skip to content

Commit 63b57ec

Browse files
Merge pull request #39 from manaflow-ai/fix/claude-try-all-oauth-accounts
Try every Claude OAuth account before surfacing 429
2 parents ddd4263 + 687af71 commit 63b57ec

2 files changed

Lines changed: 95 additions & 10 deletions

File tree

internal/proxy/claude_ratelimit_routing_test.go

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import (
66
"fmt"
77
"io"
88
"log/slog"
9-
"strconv"
109
"net/http"
1110
"net/http/httptest"
1211
"net/url"
1312
"path/filepath"
13+
"strconv"
1414
"strings"
1515
"testing"
1616
"time"
@@ -138,6 +138,82 @@ func TestClaude429FailoverEndToEndAndCaptured(t *testing.T) {
138138
}
139139
}
140140

141+
func TestClaude429FailoverTriesPastDefaultAttemptBudget(t *testing.T) {
142+
var hits []string
143+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
_, _ = io.Copy(io.Discard, r.Body)
145+
account := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer tok-")
146+
hits = append(hits, account)
147+
if account == "fresh-7@example.com" {
148+
w.WriteHeader(http.StatusOK)
149+
_, _ = w.Write([]byte(`{"id":"msg_fresh","type":"message"}`))
150+
return
151+
}
152+
w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
153+
w.Header().Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10))
154+
w.WriteHeader(http.StatusTooManyRequests)
155+
_, _ = w.Write([]byte(realisticAnthropic429Body))
156+
}))
157+
defer upstream.Close()
158+
upstreamURL, err := url.Parse(upstream.URL)
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
163+
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions.json"))
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
if _, err := store.Put("claude", "session-many", "cooked-0@example.com", ""); err != nil {
168+
t.Fatal(err)
169+
}
170+
accountsList := make([]accounts.Account, 0, 8)
171+
scores := make([]selectacct.Score, 0, 8)
172+
for i := 0; i < 8; i++ {
173+
id := fmt.Sprintf("cooked-%d@example.com", i)
174+
if i == 7 {
175+
id = "fresh-7@example.com"
176+
}
177+
accountsList = append(accountsList, accounts.Account{ID: id, Provider: accounts.ProviderClaude, AuthMode: accounts.AuthModeOAuth, Token: "tok-" + id})
178+
headroom := 0.90 - float64(i)*0.01
179+
scores = append(scores, selectacct.Score{AccountID: id, Provider: accounts.ProviderClaude, Headroom: headroom, ShortHeadroom: headroom})
180+
}
181+
182+
handler := Server{
183+
ClaudeUpstream: upstreamURL,
184+
Accounts: accountsList,
185+
Sessions: store,
186+
SchedulerRef: selectacct.NewSchedulerRef(selectacct.NewScheduler(scores)),
187+
UsageScoreTTL: 0,
188+
MaxBodyBytes: 1 << 20,
189+
}.Handler()
190+
subrouter := httptest.NewServer(handler)
191+
defer subrouter.Close()
192+
193+
req, err := http.NewRequest(http.MethodPost, subrouter.URL+"/v1/messages", strings.NewReader(`{"model":"claude-fable-5","messages":[]}`))
194+
if err != nil {
195+
t.Fatal(err)
196+
}
197+
req.Header.Set("Content-Type", "application/json")
198+
req.Header.Set("X-Subrouter-Agent", "claude")
199+
req.Header.Set("X-Subrouter-Session", "session-many")
200+
response, err := http.DefaultClient.Do(req)
201+
if err != nil {
202+
t.Fatal(err)
203+
}
204+
defer response.Body.Close()
205+
body, _ := io.ReadAll(response.Body)
206+
if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "msg_fresh") {
207+
t.Fatalf("status=%d body=%s hits=%v, want failover to reach the eighth account", response.StatusCode, string(body), hits)
208+
}
209+
if len(hits) != 8 {
210+
t.Fatalf("hits=%v, want all 8 accounts tried before success", hits)
211+
}
212+
if hits[len(hits)-1] != "fresh-7@example.com" {
213+
t.Fatalf("last hit=%q, want fresh-7@example.com; hits=%v", hits[len(hits)-1], hits)
214+
}
215+
}
216+
141217
func floatPtr(v float64) *float64 { return &v }
142218

143219
// TestClaudeUsageWindowsClassifyFiveHourAsShort guards the GTO routing fix:
@@ -943,13 +1019,13 @@ func TestClaudeExhaustionExpiry(t *testing.T) {
9431019
}
9441020
// Far-future reset is capped at 8d.
9451021
h.Set("Anthropic-Ratelimit-Unified-Reset", "1999999999")
946-
if got := claudeExhaustionExpiry(h, now); !got.Equal(now.Add(8*24*time.Hour)) {
1022+
if got := claudeExhaustionExpiry(h, now); !got.Equal(now.Add(8 * 24 * time.Hour)) {
9471023
t.Fatalf("far reset = %v, want cap now+8d", got)
9481024
}
9491025
// Retry-After honored when unified-reset absent.
9501026
h = http.Header{}
9511027
h.Set("Retry-After", "300")
952-
if got := claudeExhaustionExpiry(h, now); !got.Equal(now.Add(5*time.Minute)) {
1028+
if got := claudeExhaustionExpiry(h, now); !got.Equal(now.Add(5 * time.Minute)) {
9531029
t.Fatalf("retry-after = %v, want now+5m", got)
9541030
}
9551031
}

internal/proxy/proxy.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,12 @@ type Server struct {
5252
RefreshAccountFn func(context.Context, accounts.Account) (accounts.Account, error)
5353
Transport http.RoundTripper
5454
Logger *slog.Logger
55-
ActiveSessions *ActiveSessions
56-
Lifecycle *Lifecycle
57-
AdminToken string
58-
MaxBodyBytes int64
59-
Transcripts *transcript.Recorder
60-
ReadCache *readCache
55+
ActiveSessions *ActiveSessions
56+
Lifecycle *Lifecycle
57+
AdminToken string
58+
MaxBodyBytes int64
59+
Transcripts *transcript.Recorder
60+
ReadCache *readCache
6161
}
6262

6363
type ActiveSessions struct {
@@ -1490,7 +1490,7 @@ func (s Server) proxyHandler() http.Handler {
14901490
method: r.Method,
14911491
path: proxyRequest.URL.Path,
14921492
upstream: upstream.Host,
1493-
maxAttempts: replayablePostMaxAttempts,
1493+
maxAttempts: s.usageLimitRetryMaxAttempts(requestProvider),
14941494
}
14951495
}
14961496
if retryPost && postReplayable {
@@ -2663,6 +2663,15 @@ func (s Server) retryAccount(ctx context.Context, provider accounts.Provider, ag
26632663

26642664
const replayablePostMaxBodyBytes = 128 << 20
26652665
const replayablePostMaxAttempts = 6
2666+
2667+
func (s Server) usageLimitRetryMaxAttempts(provider accounts.Provider) int {
2668+
count := len(oauthAccounts(filterAccountsForProvider(s.accountList(), provider)))
2669+
if count > replayablePostMaxAttempts {
2670+
return count
2671+
}
2672+
return replayablePostMaxAttempts
2673+
}
2674+
26662675
const replayablePostMaxConcurrentUploads = 4
26672676

26682677
var replayablePostUploadLimiter = make(chan struct{}, replayablePostMaxConcurrentUploads)

0 commit comments

Comments
 (0)