Files
CLIProxyAPI/internal/runtime/executor/antigravity_refresh_test.go
sususu 516ec3a000 fix(antigravity): harden per-credential transport pooling
Review and live-test follow-ups to the shared upstream transport.

Bound the cache with an LRU that closes idle connections on eviction, so
rotating a credential's proxy or supplying a per-request base transport can
no longer leak pools.

Stop deriving a pool scope from Auth.Label: it is documented as an optional
human readable label for logging and carries no uniqueness guarantee, so two
OAuth identities sharing a label would share one TCP/TLS pool. Prefer a
refresh-token digest, which stays stable across access-token rotation and is
available to refresh requests that run before any access token exists.

Replace a typed-nil *http.Transport taken from the request context. It passes
the interface nil check, so leaving it in place made http.Client fall back to
http.DefaultTransport, which advertises h2 over ALPN and breaks the
HTTP/1.1-only fingerprint.

Only widen pool limits: treat MaxIdleConns == 0 and IdleConnTimeout == 0 as
unlimited, and leave a negative MaxIdleConnsPerHost alone because that is how
an operator disables pooling.

Size the cache for large deployments. An unused entry costs under 1 KB and no
goroutines, whereas evicting a live pool forces a fresh TCP + TLS handshake,
so capacity is not the lever for bounding memory.
2026-08-11 18:33:50 +08:00

148 lines
4.1 KiB
Go

package executor
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"golang.org/x/sync/singleflight"
)
func resetAntigravityRefreshGroupForTest() {
antigravityRefreshGroup = singleflight.Group{}
}
func useAntigravityRefreshTestTransport(t *testing.T, targetHost string) {
t.Helper()
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, network, targetHost)
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
ForceAttemptHTTP2: false,
}
originalBase := antigravityBaseTransport
antigravityBaseTransport = transport
antigravityTransports.Purge()
t.Cleanup(func() {
antigravityBaseTransport = originalBase
antigravityTransports.Purge()
})
}
func TestAntigravityRefresh_DeduplicatesConcurrentRefresh(t *testing.T) {
resetAntigravityRefreshGroupForTest()
t.Cleanup(resetAntigravityRefreshGroupForTest)
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
var tokenCalls int32
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
atomic.AddInt32(&tokenCalls, 1)
once.Do(func() { close(started) })
<-release
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"access_token":"new-access",
"refresh_token":"new-refresh",
"token_type":"Bearer",
"expires_in":3600
}`)
case "/v1internal:loadCodeAssist":
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"paidTier":{"id":"tier","availableCredits":[]}}`)
default:
t.Errorf("unexpected antigravity test request path: %s", r.URL.Path)
http.Error(w, "unexpected path", http.StatusNotFound)
}
}))
defer server.Close()
serverURL, errParse := url.Parse(server.URL)
if errParse != nil {
t.Fatalf("parse test server URL: %v", errParse)
}
useAntigravityRefreshTestTransport(t, serverURL.Host)
executor := &AntigravityExecutor{}
authA := &cliproxyauth.Auth{
ID: "auth-a",
Provider: "antigravity",
Metadata: map[string]any{
"refresh_token": "shared-refresh-token",
"project_id": "project-a",
},
}
authB := &cliproxyauth.Auth{
ID: "auth-b",
Provider: "antigravity",
Metadata: map[string]any{
"refresh_token": "shared-refresh-token",
"project_id": "project-b",
},
}
results := make(chan *cliproxyauth.Auth, 2)
errs := make(chan error, 2)
runRefresh := func(auth *cliproxyauth.Auth, launched chan<- struct{}) {
if launched != nil {
close(launched)
}
updated, errRefresh := executor.Refresh(context.Background(), auth)
results <- updated
errs <- errRefresh
}
go runRefresh(authA, nil)
<-started
secondLaunched := make(chan struct{})
go runRefresh(authB, secondLaunched)
<-secondLaunched
time.Sleep(20 * time.Millisecond)
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
t.Fatalf("expected concurrent refresh to share a single upstream token call, got %d", got)
}
close(release)
for i := 0; i < 2; i++ {
if errRefresh := <-errs; errRefresh != nil {
t.Fatalf("expected refresh to succeed, got %v", errRefresh)
}
updated := <-results
if updated == nil {
t.Fatal("expected refreshed auth, got nil")
}
if got := metaStringValue(updated.Metadata, "access_token"); got != "new-access" {
t.Fatalf("access_token = %q, want new-access", got)
}
if got := metaStringValue(updated.Metadata, "refresh_token"); got != "new-refresh" {
t.Fatalf("refresh_token = %q, want new-refresh", got)
}
if projectID := strings.TrimSpace(updated.Metadata["project_id"].(string)); projectID == "" {
t.Fatalf("expected project_id to stay on refreshed auth: %#v", updated.Metadata)
}
}
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
t.Fatalf("expected both refresh callers to share a single upstream token call, got %d", got)
}
}