diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go index 489d796f1..7eae0394a 100644 --- a/internal/auth/antigravity/auth.go +++ b/internal/auth/antigravity/auth.go @@ -30,6 +30,26 @@ type userInfo struct { Email string `json:"email"` } +// HTTPStatusError represents an HTTP error response with status code. +type HTTPStatusError struct { + StatusCodeValue int + Message string +} + +func (e *HTTPStatusError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *HTTPStatusError) StatusCode() int { + if e == nil { + return 0 + } + return e.StatusCodeValue +} + // AntigravityAuth handles Antigravity OAuth authentication type AntigravityAuth struct { httpClient *http.Client @@ -165,10 +185,11 @@ func (o *AntigravityAuth) ExchangeCodeForTokens(ctx context.Context, code, redir return nil, fmt.Errorf("antigravity token exchange: read response: %w", errRead) } body := strings.TrimSpace(string(bodyBytes)) - if body == "" { - return nil, fmt.Errorf("antigravity token exchange: request failed: status %d", resp.StatusCode) + msg := fmt.Sprintf("antigravity token exchange: request failed: status %d", resp.StatusCode) + if body != "" { + msg = fmt.Sprintf("antigravity token exchange: request failed: status %d: %s", resp.StatusCode, body) } - return nil, fmt.Errorf("antigravity token exchange: request failed: status %d: %s", resp.StatusCode, body) + return nil, &HTTPStatusError{StatusCodeValue: resp.StatusCode, Message: msg} } var token TokenResponse @@ -207,10 +228,11 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string) return "", fmt.Errorf("antigravity userinfo: read response: %w", errRead) } body := strings.TrimSpace(string(bodyBytes)) - if body == "" { - return "", fmt.Errorf("antigravity userinfo: request failed: status %d", resp.StatusCode) + msg := fmt.Sprintf("antigravity userinfo: request failed: status %d", resp.StatusCode) + if body != "" { + msg = fmt.Sprintf("antigravity userinfo: request failed: status %d: %s", resp.StatusCode, body) } - return "", fmt.Errorf("antigravity userinfo: request failed: status %d: %s", resp.StatusCode, body) + return "", &HTTPStatusError{StatusCodeValue: resp.StatusCode, Message: msg} } var info userInfo if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil { @@ -261,7 +283,10 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + return "", &HTTPStatusError{ + StatusCodeValue: resp.StatusCode, + Message: fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))), + } } var loadResp map[string]any @@ -371,7 +396,10 @@ func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID s if len(responseErr) > 200 { responseErr = responseErr[:200] } - return "", fmt.Errorf("http %d: %s", resp.StatusCode, responseErr) + return "", &HTTPStatusError{ + StatusCodeValue: resp.StatusCode, + Message: fmt.Sprintf("http %d: %s", resp.StatusCode, responseErr), + } } return "", fmt.Errorf("onboard user did not complete after %d attempts", maxAttempts) diff --git a/internal/auth/antigravity/auth_test.go b/internal/auth/antigravity/auth_test.go index 7e8112ad0..10acc2a7e 100644 --- a/internal/auth/antigravity/auth_test.go +++ b/internal/auth/antigravity/auth_test.go @@ -73,6 +73,28 @@ func TestFetchProjectIDFallsBackToDailyOnboardUser(t *testing.T) { } } +func TestFetchProjectIDUpstreamForbiddenReturnsStatus403(t *testing.T) { + auth := NewAntigravityAuth(nil, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":403,"message":"The caller does not have permission"}}`)), + }, nil + })}) + + _, err := auth.FetchProjectID(context.Background(), "access-token") + if err == nil { + t.Fatalf("expected error from 403 response") + } + type statusCoder interface { + StatusCode() int + } + sc, ok := err.(statusCoder) + if !ok || sc.StatusCode() != http.StatusForbidden { + t.Fatalf("expected status code %d, got %T (%v)", http.StatusForbidden, err, err) + } +} + func assertLoadCodeAssistHeaders(t *testing.T, req *http.Request) { t.Helper() if got := req.Header.Get("Authorization"); got != "Bearer access-token" { diff --git a/internal/runtime/executor/antigravity_executor_auth.go b/internal/runtime/executor/antigravity_executor_auth.go index e0b52fcce..5b3bf9760 100644 --- a/internal/runtime/executor/antigravity_executor_auth.go +++ b/internal/runtime/executor/antigravity_executor_auth.go @@ -3,6 +3,7 @@ package executor import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -254,10 +255,28 @@ func antigravityProjectIDFromAuth(auth *cliproxyauth.Auth) string { func missingAntigravityProjectIDError(cause error) statusErr { msg := "antigravity auth missing project_id" + statusCode := http.StatusBadRequest + var retryAfter *time.Duration if cause != nil { msg = fmt.Sprintf("%s: %v", msg, cause) + type statusCoder interface { + StatusCode() int + } + var sc statusCoder + if errors.As(cause, &sc) && sc != nil { + if code := sc.StatusCode(); code > 0 { + statusCode = code + } + } + type retryAfterProvider interface { + RetryAfter() *time.Duration + } + var rap retryAfterProvider + if errors.As(cause, &rap) && rap != nil { + retryAfter = rap.RetryAfter() + } } - return statusErr{code: http.StatusBadRequest, msg: msg} + return statusErr{code: statusCode, msg: msg, retryAfter: retryAfter} } func metaStringValue(metadata map[string]any, key string) string { diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go index 485db1978..835cbdbed 100644 --- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go +++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go @@ -295,6 +295,33 @@ func TestAntigravityPrepareRequestAuth_FetchesMissingProjectID(t *testing.T) { } } +func TestAntigravityPrepareRequestAuth_UpstreamForbiddenPreserves403(t *testing.T) { + executor := &AntigravityExecutor{} + auth := &cliproxyauth.Auth{Metadata: map[string]any{ + "access_token": "token", + "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339), + }} + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":403,"message":"The caller does not have permission"}}`)), + }, nil + })) + + _, err := executor.PrepareRequestAuth(ctx, auth) + if err == nil { + t.Fatalf("PrepareRequestAuth should fail on upstream 403") + } + status, ok := err.(interface{ StatusCode() int }) + if !ok { + t.Fatalf("error should expose StatusCode(), got %T (%v)", err, err) + } + if got := status.StatusCode(); got != http.StatusForbidden { + t.Fatalf("status code = %d, want %d", got, http.StatusForbidden) + } +} + func TestAntigravityBuildRequest_RejectsMissingProjectID(t *testing.T) { executor := &AntigravityExecutor{} auth := &cliproxyauth.Auth{Metadata: map[string]any{}} diff --git a/sdk/cliproxy/auth/request_auth_prepare_test.go b/sdk/cliproxy/auth/request_auth_prepare_test.go index cf5ef6fed..7665a5777 100644 --- a/sdk/cliproxy/auth/request_auth_prepare_test.go +++ b/sdk/cliproxy/auth/request_auth_prepare_test.go @@ -483,6 +483,47 @@ func TestManagerExecute_PreparesAndPersistsMissingRequestAuthMetadata(t *testing } } +func TestManagerExecute_PrepareAuth403TriggersCooldown(t *testing.T) { + previous := quotaCooldownDisabled.Load() + quotaCooldownDisabled.Store(false) + t.Cleanup(func() { quotaCooldownDisabled.Store(previous) }) + + const model = "gemini-3.1-pro" + store := &requestPrepareStore{} + executor := &requestPrepareExecutor{ + prepareErr: customStatusError{code: http.StatusForbidden, msg: "forbidden"}, + } + manager := NewManager(store, nil, nil) + manager.RegisterExecutor(executor) + + auth := &Auth{ + ID: "auth-prepare-403", + Provider: "antigravity", + Metadata: map[string]any{"access_token": "token"}, + } + if _, errRegister := manager.Register(WithSkipPersist(context.Background()), auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, "antigravity", []*registry.ModelInfo{{ID: model}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) }) + + _, errExecute := manager.Execute(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if errExecute == nil { + t.Fatal("expected Execute error on 403 prepare failure") + } + + current, ok := manager.GetByID(auth.ID) + if !ok { + t.Fatal("expected auth in manager") + } + if !current.Unavailable { + t.Fatal("expected auth to be marked unavailable after 403 prepare failure") + } + if current.Quota.NextRecoverAt.IsZero() && current.NextRetryAfter.IsZero() { + t.Fatal("expected auth cooldown to be scheduled after 403 prepare failure") + } +} + func testStringValue(value any) string { if value == nil { return ""