Skip to content
2 changes: 2 additions & 0 deletions internal/runtime/executor/antigravity_executor_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ func (e *AntigravityExecutor) refreshTokenSingleFlight(ctx context.Context, auth
if retryAfter, parseErr := helps.ParseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
sErr.retryAfter = retryAfter
}
// oauth2.googleapis.com 429 is a token-endpoint throttle, not model quota.
sErr.transientRateLimit = true
}
return nil, sErr
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package executor

import (
"context"
"errors"
"testing"
"time"

"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)

// TestAntigravityShortCooldownErrorIsTransient pins the classification of the
// synthetic 429 the executor raises while an auth sits in a short cooldown.
// The cooldown is a local, self-imposed pause of at most a few minutes, so the
// conductor has to read it as a transient rate limit and rotate to the next
// auth. Unclassified, the same error looks like an exhausted quota carrying a
// retry hint, and the conductor escalates BackoffLevel toward the 30 minute
// ceiling — parking an account that was never actually throttled upstream.
func TestAntigravityShortCooldownErrorIsTransient(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
client := newFakeAntigravityKVClient()
useFakeAntigravityKVClient(t, client, true, nil)

exec := NewAntigravityExecutor(&config.Config{})
opts := cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatGemini,
ResponseFormat: sdktranslator.FormatGemini,
}
payload := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)

for _, tc := range []struct {
name string
model string
call func(auth *cliproxyauth.Auth, model string) error
}{
{
name: "execute",
model: "gemini-3.6-flash",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
{
name: "execute-claude",
model: "claude-sonnet-4-5",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
{
name: "execute-stream",
model: "gemini-3.6-flash",
call: func(auth *cliproxyauth.Auth, model string) error {
_, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, opts)
return err
},
},
} {
t.Run(tc.name, func(t *testing.T) {
auth := &cliproxyauth.Auth{ID: "cooldown-transient-" + tc.name}
if errMark := markAntigravityShortCooldownRequired(context.Background(), auth, tc.model, time.Now(), 30*time.Second); errMark != nil {
t.Fatalf("markAntigravityShortCooldownRequired() error = %v", errMark)
}

err := tc.call(auth, tc.model)
if err == nil {
t.Fatal("expected the short cooldown to surface a 429")
}

var classified interface{ TransientRateLimit() bool }
if !errors.As(err, &classified) {
t.Fatalf("short-cooldown error carries no 429 classification: %T", err)
}
if !classified.TransientRateLimit() {
t.Fatal("expected the synthetic short-cooldown 429 to be transient so the conductor rotates instead of escalating backoff")
}

var hinted interface{ RetryAfter() *time.Duration }
if !errors.As(err, &hinted) || hinted.RetryAfter() == nil || *hinted.RetryAfter() <= 0 {
t.Fatalf("expected a positive retry hint on the short-cooldown 429, got %v", err)
}
})
}
}
9 changes: 9 additions & 0 deletions internal/runtime/executor/antigravity_executor_credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ func newAntigravityStatusErr(statusCode int, body []byte) statusErr {
if retryAfter, parseErr := helps.ParseRetryDelay(body); parseErr == nil && retryAfter != nil {
err.retryAfter = retryAfter
}
// Only a decisively rate-limited 429 may keep its raw retry hint downstream;
// exhausted quota and unclassified bodies stay on the escalating cooldown ladder.
// A RATE_LIMIT_EXCEEDED reason without a RetryInfo hint is still a short-lived
// throttle, not an exhausted quota, so it is transient too — but only when the
// classification comes from the ErrorInfo reason, not from the bare
// "too many requests" message heuristic.
category := classifyAntigravity429(body)
err.transientRateLimit = category == antigravity429RateLimited ||
(category == antigravity429SoftRateLimit && strings.EqualFold(decideAntigravity429(body).reason, "RATE_LIMIT_EXCEEDED"))
}
return err
}
Expand Down
117 changes: 117 additions & 0 deletions internal/runtime/executor/antigravity_executor_credits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,79 @@ func TestClassifyAntigravity429(t *testing.T) {
})
}

func TestNewAntigravityStatusErrMarksTransientRateLimit(t *testing.T) {
rateLimited := []byte(`{
"error": {
"code": 429,
"message": "You have exhausted your capacity on this model. Your quota will reset after 0s.",
"status": "RESOURCE_EXHAUSTED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "RATE_LIMIT_EXCEEDED",
"domain": "cloudcode-pa.googleapis.com"
},
{
"@type": "type.googleapis.com/google.rpc.RetryInfo",
"retryDelay": "0.479417207s"
}
]
}
}`)
transient := newAntigravityStatusErr(http.StatusTooManyRequests, rateLimited)
if !transient.TransientRateLimit() {
t.Fatal("expected a RATE_LIMIT_EXCEEDED 429 with a sub-second hint to be marked transient")
}
if transient.RetryAfter() == nil {
t.Fatal("expected the provider retry hint to be preserved on a transient rate limit")
}

exhausted := []byte(`{
"error": {
"code": 429,
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "QUOTA_EXHAUSTED"},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.479417207s"}
]
}
}`)
quotaErr := newAntigravityStatusErr(http.StatusTooManyRequests, exhausted)
if quotaErr.TransientRateLimit() {
t.Fatal("expected a QUOTA_EXHAUSTED 429 not to be marked transient")
}
if quotaErr.RetryAfter() == nil {
t.Fatal("expected the provider retry hint to be preserved on an exhausted quota")
}

if soft := newAntigravityStatusErr(http.StatusTooManyRequests, []byte(`{"error":{"message":"too many requests"}}`)); soft.TransientRateLimit() {
t.Fatal("expected an unclassified 429 to stay on the escalating cooldown ladder")
}

// A reasoned RATE_LIMIT_EXCEEDED without a RetryInfo hint is still a
// short-lived throttle, not an exhausted quota.
noHint := []byte(`{
"error": {
"code": 429,
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED", "domain": "cloudcode-pa.googleapis.com"}
]
}
}`)
noHintErr := newAntigravityStatusErr(http.StatusTooManyRequests, noHint)
if !noHintErr.TransientRateLimit() {
t.Fatal("expected a RATE_LIMIT_EXCEEDED 429 without RetryInfo to be marked transient")
}
if noHintErr.RetryAfter() != nil {
t.Fatal("expected no retry hint when Google omits RetryInfo")
}

if nonRateLimit := newAntigravityStatusErr(http.StatusServiceUnavailable, rateLimited); nonRateLimit.TransientRateLimit() {
t.Fatal("expected a non-429 status not to be marked transient")
}
}

func TestInjectEnabledCreditTypes(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`)
got := injectEnabledCreditTypes(body)
Expand Down Expand Up @@ -734,3 +807,47 @@ func TestParseMetaFloat(t *testing.T) {
})
}
}

func TestAntigravityCountTokensClassifiesTransient429(t *testing.T) {
body := `{
"error": {
"code": 429,
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED"},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.479417207s"}
]
}
}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(body))
}))
defer server.Close()

exec := NewAntigravityExecutor(&config.Config{RequestRetry: 1})
_, errCount := exec.CountTokens(context.Background(), testAntigravityAuth(server.URL), cliproxyexecutor.Request{
Model: "gemini-3.6-flash-high",
Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatGemini,
ResponseFormat: sdktranslator.FormatGemini,
})
if errCount == nil {
t.Fatal("expected CountTokens to fail on an upstream 429")
}

var classified interface{ TransientRateLimit() bool }
if !errors.As(errCount, &classified) {
t.Fatalf("CountTokens error carries no 429 classification: %T", errCount)
}
if !classified.TransientRateLimit() {
t.Fatal("expected a RATE_LIMIT_EXCEEDED token-count 429 to be marked transient")
}

var hinted interface{ RetryAfter() *time.Duration }
if !errors.As(errCount, &hinted) || hinted.RetryAfter() == nil {
t.Fatal("expected the provider retry hint to survive the token-count path")
}
}
4 changes: 2 additions & 2 deletions internal/runtime/executor/antigravity_executor_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
} else if inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d, transientRateLimit: true}
}

isClaude := strings.Contains(strings.ToLower(baseModel), "claude")
Expand Down Expand Up @@ -187,7 +187,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
} else if inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d, transientRateLimit: true}
}

reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
Expand Down
2 changes: 1 addition & 1 deletion internal/runtime/executor/antigravity_executor_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
} else if inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d, transientRateLimit: true}
}

reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
Expand Down
8 changes: 1 addition & 7 deletions internal/runtime/executor/antigravity_executor_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,5 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
return cliproxyexecutor.Response{Payload: translated, Headers: httpResp.Header.Clone()}, nil
}

sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
if httpResp.StatusCode == http.StatusTooManyRequests {
if retryAfter, parseErr := helps.ParseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
sErr.retryAfter = retryAfter
}
}
return cliproxyexecutor.Response{}, sErr
return cliproxyexecutor.Response{}, newAntigravityStatusErr(httpResp.StatusCode, bodyBytes)
}
Loading
Loading