@@ -722,3 +722,203 @@ func TestClientRemoteIP(t *testing.T) {
722722 }
723723 }
724724}
725+
726+ // recordSleep returns a sleep fn that records waits without actually sleeping.
727+ func recordSleep (waits * []time.Duration ) func (context.Context , time.Duration ) error {
728+ return func (ctx context.Context , d time.Duration ) error {
729+ * waits = append (* waits , d )
730+ return ctx .Err ()
731+ }
732+ }
733+
734+ // TestClaudeOverloadRetrySucceeds: a brief 529 blip is absorbed on the SAME
735+ // account with backoff; the client sees the eventual 200 and the account is
736+ // never marked exhausted.
737+ func TestClaudeOverloadRetrySucceeds (t * testing.T ) {
738+ server , store := claudeFailoverServer (t )
739+ if _ , err := store .Put ("claude" , "session-529" , "cooked@example.com" , "" ); err != nil {
740+ t .Fatal (err )
741+ }
742+ var calls int
743+ stub := & stubRoundTripper {responses : func (req * http.Request ) * http.Response {
744+ calls ++
745+ if calls <= 2 {
746+ return & http.Response {StatusCode : 529 , Header : http.Header {}, Body : io .NopCloser (strings .NewReader (`{"type":"error","error":{"type":"overloaded_error"}}` ))}
747+ }
748+ return & http.Response {StatusCode : http .StatusOK , Header : http.Header {}, Body : io .NopCloser (strings .NewReader (`{"id":"msg_ok"}` ))}
749+ }}
750+ var waits []time.Duration
751+ transport := usageLimitRetryTransport {
752+ base : stub , server : & server , provider : accounts .ProviderClaude ,
753+ agent : "claude" , session : "session-529" , account : "cooked@example.com" ,
754+ method : http .MethodPost , path : "/v1/messages" , maxAttempts : 6 ,
755+ sleep : recordSleep (& waits ),
756+ }
757+ req , _ := http .NewRequest (http .MethodPost , "https://api.anthropic.com/v1/messages" , bytes .NewReader ([]byte (`{"model":"claude-opus-4-8"}` )))
758+ req .Header .Set ("Authorization" , "Bearer tok-cooked" )
759+ response , err := transport .RoundTrip (req )
760+ if err != nil {
761+ t .Fatal (err )
762+ }
763+ defer response .Body .Close ()
764+ if response .StatusCode != http .StatusOK {
765+ t .Fatalf ("status = %d, want 200 after overload retries" , response .StatusCode )
766+ }
767+ if calls != 3 {
768+ t .Fatalf ("upstream calls = %d, want 3 (529, 529, 200)" , calls )
769+ }
770+ if len (waits ) != 2 || waits [0 ] != time .Second || waits [1 ] != 2 * time .Second {
771+ t .Fatalf ("backoff waits = %v, want [1s 2s]" , waits )
772+ }
773+ if server .SchedulerRef .Get ().Exhausted (accounts .ProviderClaude , "cooked@example.com" ) {
774+ t .Fatal ("an overload must NOT mark the account exhausted" )
775+ }
776+ }
777+
778+ // TestClaudeOverloadRetryGivesUpAfterBudget: a sustained outage passes the 5xx
779+ // through after the small retry budget so the client's own backoff takes over.
780+ func TestClaudeOverloadRetryGivesUpAfterBudget (t * testing.T ) {
781+ server , _ := claudeFailoverServer (t )
782+ var calls int
783+ stub := & stubRoundTripper {responses : func (req * http.Request ) * http.Response {
784+ calls ++
785+ return & http.Response {StatusCode : 529 , Header : http.Header {}, Body : io .NopCloser (strings .NewReader (`{"type":"error","error":{"type":"overloaded_error"}}` ))}
786+ }}
787+ var waits []time.Duration
788+ transport := usageLimitRetryTransport {
789+ base : stub , server : & server , provider : accounts .ProviderClaude ,
790+ agent : "claude" , session : "s" , account : "cooked@example.com" ,
791+ method : http .MethodPost , path : "/v1/messages" , maxAttempts : 6 ,
792+ sleep : recordSleep (& waits ),
793+ }
794+ req , _ := http .NewRequest (http .MethodPost , "https://api.anthropic.com/v1/messages" , bytes .NewReader ([]byte (`{}` )))
795+ req .Header .Set ("Authorization" , "Bearer tok-cooked" )
796+ response , err := transport .RoundTrip (req )
797+ if err != nil {
798+ t .Fatal (err )
799+ }
800+ defer response .Body .Close ()
801+ if response .StatusCode != 529 {
802+ t .Fatalf ("status = %d, want 529 passed through after budget" , response .StatusCode )
803+ }
804+ if calls != 1 + claudeOverloadMaxRetries {
805+ t .Fatalf ("upstream calls = %d, want %d" , calls , 1 + claudeOverloadMaxRetries )
806+ }
807+ }
808+
809+ // TestClaudeOverloadRetryPreservesFailoverAccount is the header-revert
810+ // regression: after failover moved the request to a second account, an overload
811+ // retry must keep that account's auth, not silently revert to the first.
812+ func TestClaudeOverloadRetryPreservesFailoverAccount (t * testing.T ) {
813+ server , store := claudeFailoverServer (t )
814+ if _ , err := store .Put ("claude" , "session-mix" , "cooked@example.com" , "" ); err != nil {
815+ t .Fatal (err )
816+ }
817+ var freshCalls int
818+ var authsSeen []string
819+ stub := & stubRoundTripper {responses : func (req * http.Request ) * http.Response {
820+ auth := req .Header .Get ("Authorization" )
821+ authsSeen = append (authsSeen , auth )
822+ if strings .Contains (auth , "tok-cooked" ) {
823+ h := http.Header {}
824+ h .Set ("Anthropic-Ratelimit-Unified-Status" , "rejected" )
825+ return & http.Response {StatusCode : http .StatusTooManyRequests , Header : h , Body : io .NopCloser (strings .NewReader (`{"type":"error","error":{"type":"rate_limit_error"}}` ))}
826+ }
827+ freshCalls ++
828+ if freshCalls == 1 {
829+ return & http.Response {StatusCode : 529 , Header : http.Header {}, Body : io .NopCloser (strings .NewReader (`{"type":"error","error":{"type":"overloaded_error"}}` ))}
830+ }
831+ return & http.Response {StatusCode : http .StatusOK , Header : http.Header {}, Body : io .NopCloser (strings .NewReader (`{"id":"msg_fresh"}` ))}
832+ }}
833+ var waits []time.Duration
834+ transport := usageLimitRetryTransport {
835+ base : stub , server : & server , provider : accounts .ProviderClaude ,
836+ agent : "claude" , session : "session-mix" , account : "cooked@example.com" ,
837+ method : http .MethodPost , path : "/v1/messages" , maxAttempts : 6 ,
838+ sleep : recordSleep (& waits ),
839+ }
840+ req , _ := http .NewRequest (http .MethodPost , "https://api.anthropic.com/v1/messages" , bytes .NewReader ([]byte (`{}` )))
841+ req .Header .Set ("Authorization" , "Bearer tok-cooked" )
842+ response , err := transport .RoundTrip (req )
843+ if err != nil {
844+ t .Fatal (err )
845+ }
846+ defer response .Body .Close ()
847+ body , _ := io .ReadAll (response .Body )
848+ if response .StatusCode != http .StatusOK || ! strings .Contains (string (body ), "msg_fresh" ) {
849+ t .Fatalf ("status=%d body=%s, want fresh 200 (429 -> failover -> 529 -> retry same fresh account)" , response .StatusCode , string (body ))
850+ }
851+ // Sequence must be cooked, fresh (529), fresh (200): the overload retry
852+ // stays on the failover account.
853+ if len (authsSeen ) != 3 || ! strings .Contains (authsSeen [1 ], "tok-fresh" ) || ! strings .Contains (authsSeen [2 ], "tok-fresh" ) {
854+ t .Fatalf ("auth sequence = %v, want cooked then fresh twice" , authsSeen )
855+ }
856+ }
857+
858+ func TestClaudeOverloadBackoff (t * testing.T ) {
859+ h := http.Header {}
860+ if d := claudeOverloadBackoff (h , 0 ); d != time .Second {
861+ t .Fatalf ("retry0 = %v, want 1s" , d )
862+ }
863+ if d := claudeOverloadBackoff (h , 1 ); d != 2 * time .Second {
864+ t .Fatalf ("retry1 = %v, want 2s" , d )
865+ }
866+ h .Set ("Retry-After" , "3" )
867+ if d := claudeOverloadBackoff (h , 0 ); d != 3 * time .Second {
868+ t .Fatalf ("retry-after 3 = %v, want 3s" , d )
869+ }
870+ h .Set ("Retry-After" , "9999" )
871+ if d := claudeOverloadBackoff (h , 0 ); d != claudeOverloadMaxWait {
872+ t .Fatalf ("retry-after 9999 = %v, want cap %v" , d , claudeOverloadMaxWait )
873+ }
874+ if ! claudeOverloadStatus (529 ) || ! claudeOverloadStatus (500 ) || claudeOverloadStatus (429 ) || claudeOverloadStatus (200 ) {
875+ t .Fatal ("claudeOverloadStatus classification wrong" )
876+ }
877+ }
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+ }
0 commit comments