fix(auth): preserve upstream status codes in antigravity auth errors

- Introduce `HTTPStatusError` to retain upstream HTTP status codes across Antigravity OAuth and project lookup calls.
- Propagate status codes and retry-after metadata from cause errors in `missingAntigravityProjectIDError`.

Closes: #5368
This commit is contained in:
Luis Pater
2026-09-01 08:37:02 +08:00
parent f2e2d713b2
commit 7070785140
5 changed files with 146 additions and 9 deletions

View File

@@ -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)

View File

@@ -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" {

View File

@@ -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 {

View File

@@ -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{}}

View File

@@ -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 ""