mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user