@@ -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+
141217func 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}
0 commit comments