From 6fd4df3b6b2e1280d839d39e88c9e12cd634dc62 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 14:58:21 +0800 Subject: [PATCH 1/2] fix(auth): cooldown credentials returning invalid_grant instead of retrying them indefinitely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity OAuth credentials that return HTTP 400 with invalid_grant (revoked/banned accounts) were never cooled down because: 1. isRequestInvalidError matched the outer invalid_request_error wrapper, causing the conductor to return immediately without trying other creds 2. MarkResult's switch on statusCode fell into the default case for 400, applying no cooldown — the bad credential was reselected immediately Now: - isRequestInvalidError returns false when the message contains invalid_grant, so the conductor falls through to try other credentials - MarkResult and applyAuthFailureState treat invalid_grant like 401 (30-minute cooldown + model suspension) Closes #4120 --- sdk/cliproxy/auth/conductor.go | 37 +++++++++- sdk/cliproxy/auth/cooldown_backoff_test.go | 81 ++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 00e4d4e51ee..dddf670f92a 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -3734,7 +3734,18 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { state.NextRetryAfter = nextTransientErrorRetryAfter(now) } default: - state.NextRetryAfter = time.Time{} + if isOAuthInvalidGrantResultError(result.Error) { + if disableCooling { + state.NextRetryAfter = time.Time{} + } else { + next := now.Add(30 * time.Minute) + state.NextRetryAfter = next + suspendReason = "unauthorized" + shouldSuspendModel = true + } + } else { + state.NextRetryAfter = time.Time{} + } } } @@ -3983,6 +3994,20 @@ func isUnauthorizedError(err error) bool { return strings.Contains(raw, "status 401") || strings.Contains(raw, "401 unauthorized") } +// isOAuthInvalidGrantMessage checks whether an error message indicates an OAuth +// invalid_grant failure (revoked or expired refresh token). These are credential-level +// errors that should be cooled down, not treated as invalid request payloads. +func isOAuthInvalidGrantMessage(msg string) bool { + return strings.Contains(msg, "invalid_grant") +} + +func isOAuthInvalidGrantResultError(err *Error) bool { + if err == nil { + return false + } + return isOAuthInvalidGrantMessage(err.Message) || isOAuthInvalidGrantMessage(err.Code) +} + func hasUnauthorizedAuthFailure(auth *Auth) bool { if auth == nil || auth.LastError == nil { return false @@ -4152,6 +4177,9 @@ func isRequestInvalidError(err error) bool { switch status { case http.StatusBadRequest: msg := err.Error() + if isOAuthInvalidGrantMessage(msg) { + return false + } return strings.Contains(msg, "invalid_request_error") || strings.Contains(msg, "INVALID_ARGUMENT") || strings.Contains(msg, "FAILED_PRECONDITION") @@ -4241,7 +4269,12 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati auth.NextRetryAfter = nextTransientErrorRetryAfter(now) } default: - if auth.StatusMessage == "" { + if resultErr != nil && isOAuthInvalidGrantMessage(resultErr.Error()) { + auth.StatusMessage = "unauthorized" + if !disableCooling { + auth.NextRetryAfter = now.Add(30 * time.Minute) + } + } else if auth.StatusMessage == "" { auth.StatusMessage = "request failed" } } diff --git a/sdk/cliproxy/auth/cooldown_backoff_test.go b/sdk/cliproxy/auth/cooldown_backoff_test.go index b1d77ebce80..0175cd901c8 100644 --- a/sdk/cliproxy/auth/cooldown_backoff_test.go +++ b/sdk/cliproxy/auth/cooldown_backoff_test.go @@ -152,6 +152,87 @@ func TestApplyAuthFailureStateQuotaBackoffOncePerWindow(t *testing.T) { } } +func TestMarkResultInvalidGrantAppliesCooldown(t *testing.T) { + manager := NewManager(nil, nil, nil) + auth := &Auth{ + ID: "auth-invalid-grant", + Provider: "antigravity", + Metadata: map[string]any{"type": "antigravity"}, + } + if _, errRegister := manager.Register(WithSkipPersist(context.Background()), auth); errRegister != nil { + t.Fatalf("Register returned error: %v", errRegister) + } + + manager.MarkResult(context.Background(), Result{ + AuthID: auth.ID, + Provider: "antigravity", + Model: "claude-sonnet-4-20250514", + Success: false, + Error: &Error{ + Code: "invalid_request_error", + Message: `{"error":"invalid_grant","error_description":"Bad Request"}`, + HTTPStatus: http.StatusBadRequest, + }, + }) + + updated, ok := manager.GetByID(auth.ID) + if !ok || updated == nil { + t.Fatal("expected auth to exist") + } + state := updated.ModelStates["claude-sonnet-4-20250514"] + if state == nil { + t.Fatal("expected model state after failure") + } + if state.NextRetryAfter.IsZero() { + t.Fatal("expected cooldown (non-zero NextRetryAfter) for invalid_grant, got zero") + } + if !state.NextRetryAfter.After(time.Now()) { + t.Fatalf("expected cooldown in the future, got %v", state.NextRetryAfter) + } +} + +func TestApplyAuthFailureStateInvalidGrantAppliesCooldown(t *testing.T) { + now := time.Now() + invalidGrantErr := &Error{ + Code: "invalid_request_error", + Message: `{"error":"invalid_grant","error_description":"Bad Request"}`, + HTTPStatus: http.StatusBadRequest, + } + auth := &Auth{ID: "auth-grant-cooldown"} + + applyAuthFailureState(auth, invalidGrantErr, nil, now, false) + + if auth.StatusMessage != "unauthorized" { + t.Fatalf("StatusMessage = %q, want %q", auth.StatusMessage, "unauthorized") + } + if auth.NextRetryAfter.IsZero() { + t.Fatal("expected NextRetryAfter to be set for invalid_grant") + } + if !auth.NextRetryAfter.Equal(now.Add(30 * time.Minute)) { + t.Fatalf("NextRetryAfter = %v, want %v", auth.NextRetryAfter, now.Add(30*time.Minute)) + } +} + +func TestIsRequestInvalidErrorExcludesInvalidGrant(t *testing.T) { + grantErr := &Error{ + Code: "invalid_request_error", + Message: `{"error":"invalid_grant","error_description":"Bad Request"}`, + HTTPStatus: http.StatusBadRequest, + } + if isRequestInvalidError(grantErr) { + t.Fatal("isRequestInvalidError should return false for invalid_grant wrapped in invalid_request_error") + } + + normalBadReq := &Error{ + Code: "invalid_request_error", + Message: "invalid parameter: temperature must be between 0 and 2", + HTTPStatus: http.StatusBadRequest, + } + if !isRequestInvalidError(normalBadReq) { + t.Fatal("isRequestInvalidError should return true for genuine invalid_request_error") + } +} + func TestJitteredCooldownWaitBounds(t *testing.T) { cases := []struct { wait time.Duration From 181006d6526c22f9c6ce1c9410a75fa99c34f2f1 Mon Sep 17 00:00:00 2001 From: li Date: Tue, 7 Jul 2026 15:27:47 +0800 Subject: [PATCH 2/2] refactor(auth): address review feedback on invalid_grant handling - Case-insensitive invalid_grant check (strings.ToLower) - Simplify isOAuthInvalidGrantResultError to use err.Error() - Explicit auth.NextRetryAfter = time.Time{} when disableCooling - Set auth-level NextRetryAfter alongside model-level so the banned credential is blocked across all models, not just result.Model --- sdk/cliproxy/auth/conductor.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index dddf670f92a..a068f9e1c0a 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -3737,9 +3737,11 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { if isOAuthInvalidGrantResultError(result.Error) { if disableCooling { state.NextRetryAfter = time.Time{} + auth.NextRetryAfter = time.Time{} } else { next := now.Add(30 * time.Minute) state.NextRetryAfter = next + auth.NextRetryAfter = next suspendReason = "unauthorized" shouldSuspendModel = true } @@ -3998,14 +4000,14 @@ func isUnauthorizedError(err error) bool { // invalid_grant failure (revoked or expired refresh token). These are credential-level // errors that should be cooled down, not treated as invalid request payloads. func isOAuthInvalidGrantMessage(msg string) bool { - return strings.Contains(msg, "invalid_grant") + return strings.Contains(strings.ToLower(msg), "invalid_grant") } func isOAuthInvalidGrantResultError(err *Error) bool { if err == nil { return false } - return isOAuthInvalidGrantMessage(err.Message) || isOAuthInvalidGrantMessage(err.Code) + return isOAuthInvalidGrantMessage(err.Error()) } func hasUnauthorizedAuthFailure(auth *Auth) bool { @@ -4271,7 +4273,9 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati default: if resultErr != nil && isOAuthInvalidGrantMessage(resultErr.Error()) { auth.StatusMessage = "unauthorized" - if !disableCooling { + if disableCooling { + auth.NextRetryAfter = time.Time{} + } else { auth.NextRetryAfter = now.Add(30 * time.Minute) } } else if auth.StatusMessage == "" {