| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- package handler
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "sync"
- "testing"
- "time"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type helperConcurrencyCacheStub struct {
- mu sync.Mutex
- accountSeq []bool
- userSeq []bool
- accountAcquireCalls int
- userAcquireCalls int
- accountReleaseCalls int
- userReleaseCalls int
- waitAllowed bool
- waitIncrementCalls int
- waitDecrementCalls int
- waitMaxWait int
- waitIncrementHook func()
- apiKeyTrackCalls int
- apiKeyReleaseCalls int
- apiKeyTrackIDs []int64
- }
- func (s *helperConcurrencyCacheStub) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.accountAcquireCalls++
- if len(s.accountSeq) == 0 {
- return false, nil
- }
- v := s.accountSeq[0]
- s.accountSeq = s.accountSeq[1:]
- return v, nil
- }
- func (s *helperConcurrencyCacheStub) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.accountReleaseCalls++
- return nil
- }
- func (s *helperConcurrencyCacheStub) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) {
- return 0, nil
- }
- func (s *helperConcurrencyCacheStub) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
- out := make(map[int64]int, len(accountIDs))
- for _, accountID := range accountIDs {
- out[accountID] = 0
- }
- return out, nil
- }
- func (s *helperConcurrencyCacheStub) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) {
- return true, nil
- }
- func (s *helperConcurrencyCacheStub) DecrementAccountWaitCount(ctx context.Context, accountID int64) error {
- return nil
- }
- func (s *helperConcurrencyCacheStub) GetAccountWaitingCount(ctx context.Context, accountID int64) (int, error) {
- return 0, nil
- }
- func (s *helperConcurrencyCacheStub) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.userAcquireCalls++
- if len(s.userSeq) == 0 {
- return false, nil
- }
- v := s.userSeq[0]
- s.userSeq = s.userSeq[1:]
- return v, nil
- }
- func (s *helperConcurrencyCacheStub) ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.userReleaseCalls++
- return nil
- }
- func (s *helperConcurrencyCacheStub) GetUserConcurrency(ctx context.Context, userID int64) (int, error) {
- return 0, nil
- }
- func (s *helperConcurrencyCacheStub) TrackAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.apiKeyTrackCalls++
- s.apiKeyTrackIDs = append(s.apiKeyTrackIDs, apiKeyID)
- return nil
- }
- func (s *helperConcurrencyCacheStub) ReleaseAPIKeySlot(ctx context.Context, apiKeyID int64, requestID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.apiKeyReleaseCalls++
- return nil
- }
- func (s *helperConcurrencyCacheStub) GetAPIKeyConcurrencyBatch(ctx context.Context, apiKeyIDs []int64) (map[int64]int, error) {
- out := make(map[int64]int, len(apiKeyIDs))
- for _, apiKeyID := range apiKeyIDs {
- out[apiKeyID] = 0
- }
- return out, nil
- }
- func (s *helperConcurrencyCacheStub) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
- s.mu.Lock()
- s.waitIncrementCalls++
- s.waitMaxWait = maxWait
- waitAllowed := s.waitAllowed
- hook := s.waitIncrementHook
- s.mu.Unlock()
- if hook != nil {
- hook()
- }
- if !waitAllowed {
- return false, nil
- }
- return true, nil
- }
- func (s *helperConcurrencyCacheStub) DecrementWaitCount(ctx context.Context, userID int64) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.waitDecrementCalls++
- return nil
- }
- func (s *helperConcurrencyCacheStub) GetAccountsLoadBatch(ctx context.Context, accounts []service.AccountWithConcurrency) (map[int64]*service.AccountLoadInfo, error) {
- out := make(map[int64]*service.AccountLoadInfo, len(accounts))
- for _, acc := range accounts {
- out[acc.ID] = &service.AccountLoadInfo{AccountID: acc.ID}
- }
- return out, nil
- }
- func (s *helperConcurrencyCacheStub) GetUsersLoadBatch(ctx context.Context, users []service.UserWithConcurrency) (map[int64]*service.UserLoadInfo, error) {
- out := make(map[int64]*service.UserLoadInfo, len(users))
- for _, user := range users {
- out[user.ID] = &service.UserLoadInfo{UserID: user.ID}
- }
- return out, nil
- }
- func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error {
- return nil
- }
- func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
- return nil
- }
- func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
- return nil
- }
- func newHelperTestContext(method, path string) (*gin.Context, *httptest.ResponseRecorder) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(method, path, nil)
- return c, rec
- }
- func validClaudeCodeBodyJSON() []byte {
- return []byte(`{
- "model":"claude-3-5-sonnet-20241022",
- "system":[{"text":"You are Claude Code, Anthropic's official CLI for Claude."}],
- "metadata":{"user_id":"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}
- }`)
- }
- func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
- t.Run("non_cli_user_agent_sets_false", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Request.Header.Set("User-Agent", "curl/8.6.0")
- SetClaudeCodeClientContext(c, validClaudeCodeBodyJSON(), nil)
- require.False(t, service.IsClaudeCodeClient(c.Request.Context()))
- })
- t.Run("cli_non_messages_path_sets_true", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodGet, "/v1/models")
- c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
- SetClaudeCodeClientContext(c, nil, nil)
- require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
- })
- t.Run("cli_messages_path_valid_body_sets_true", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
- c.Request.Header.Set("X-App", "claude-code")
- c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
- c.Request.Header.Set("anthropic-version", "2023-06-01")
- SetClaudeCodeClientContext(c, validClaudeCodeBodyJSON(), nil)
- require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
- })
- t.Run("cli_messages_path_invalid_body_sets_false", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
- // 缺少严格校验所需 header + body 字段
- SetClaudeCodeClientContext(c, []byte(`{"model":"x"}`), nil)
- require.False(t, service.IsClaudeCodeClient(c.Request.Context()))
- })
- }
- func TestSetClaudeCodeClientContext_ReuseParsedRequest(t *testing.T) {
- t.Run("reuse parsed request without body unmarshal", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Request.Header.Set("User-Agent", "claude-cli/1.0.1")
- c.Request.Header.Set("X-App", "claude-code")
- c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
- c.Request.Header.Set("anthropic-version", "2023-06-01")
- parsedReq, err := service.ParseGatewayRequest(service.NewRequestBodyRef(validClaudeCodeBodyJSON()), "")
- require.NoError(t, err)
- // body 非法 JSON,如果函数复用 parsedReq 成功则仍应判定为 Claude Code。
- SetClaudeCodeClientContext(c, []byte(`{invalid`), parsedReq)
- require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
- })
- }
- func TestWaitForSlotWithPingTimeout_AccountAndUserAcquire(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- accountSeq: []bool{false, true},
- userSeq: []bool{false, true},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- t.Run("account_slot_acquired_after_retry", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, time.Second, false, &streamStarted, true)
- require.NoError(t, err)
- require.NotNil(t, release)
- require.False(t, streamStarted)
- release()
- require.GreaterOrEqual(t, cache.accountAcquireCalls, 2)
- require.GreaterOrEqual(t, cache.accountReleaseCalls, 1)
- })
- t.Run("user_slot_acquired_after_retry", func(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "user", 202, 3, time.Second, false, &streamStarted, true)
- require.NoError(t, err)
- require.NotNil(t, release)
- release()
- require.GreaterOrEqual(t, cache.userAcquireCalls, 2)
- require.GreaterOrEqual(t, cache.userReleaseCalls, 1)
- })
- }
- func TestAcquireUserSlotWithWait_ImmediateAcquireSkipsWaitQueue(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{true},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
- require.NoError(t, err)
- require.NotNil(t, release)
- release()
- require.Equal(t, 1, cache.userAcquireCalls)
- require.Equal(t, 0, cache.waitIncrementCalls)
- require.Equal(t, 0, cache.waitDecrementCalls)
- require.Equal(t, 1, cache.userReleaseCalls)
- }
- func TestAcquireUserSlotWithWait_TracksAPIKeySlot(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{true},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 77})
- streamStarted := false
- release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
- require.NoError(t, err)
- require.NotNil(t, release)
- require.Equal(t, 1, cache.apiKeyTrackCalls)
- require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
- release()
- require.Equal(t, 1, cache.userReleaseCalls)
- require.Equal(t, 1, cache.apiKeyReleaseCalls)
- }
- func TestTryAcquireUserSlotForAPIKey_TracksAPIKeySlot(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{true},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- release, acquired, err := helper.TryAcquireUserSlotForAPIKey(context.Background(), 202, 3, 77)
- require.NoError(t, err)
- require.True(t, acquired)
- require.NotNil(t, release)
- require.Equal(t, 1, cache.apiKeyTrackCalls)
- require.Equal(t, []int64{77}, cache.apiKeyTrackIDs)
- release()
- require.Equal(t, 1, cache.userReleaseCalls)
- require.Equal(t, 1, cache.apiKeyReleaseCalls)
- }
- func TestAcquireUserSlotWithWait_WaitSuccessDecrementsBeforeReturn(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{false, true},
- waitAllowed: true,
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
- require.NoError(t, err)
- require.NotNil(t, release)
- require.Equal(t, 2, cache.userAcquireCalls)
- require.Equal(t, 1, cache.waitIncrementCalls)
- require.Equal(t, 20, cache.waitMaxWait)
- require.Equal(t, 1, cache.waitDecrementCalls)
- release()
- require.Equal(t, 1, cache.userReleaseCalls)
- }
- func TestAcquireUserSlotWithWait_TimeoutDecrementsWaitQueue(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{false, false, false},
- waitAllowed: true,
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, 30*time.Millisecond, false, &streamStarted)
- require.Nil(t, release)
- var cErr *ConcurrencyError
- require.ErrorAs(t, err, &cErr)
- require.True(t, cErr.IsTimeout)
- require.Equal(t, 1, cache.waitIncrementCalls)
- require.Equal(t, 1, cache.waitDecrementCalls)
- require.Equal(t, 0, cache.userReleaseCalls)
- }
- func TestAcquireUserSlotWithWait_RequestCancelDecrementsWaitQueue(t *testing.T) {
- cancelled := make(chan struct{})
- var cancel context.CancelFunc
- cache := &helperConcurrencyCacheStub{
- userSeq: []bool{false, false},
- waitAllowed: true,
- waitIncrementHook: func() {
- cancel()
- close(cancelled)
- },
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- reqCtx, cancelFunc := context.WithCancel(c.Request.Context())
- cancel = cancelFunc
- defer cancel()
- c.Request = c.Request.WithContext(reqCtx)
- streamStarted := false
- release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
- <-cancelled
- require.Nil(t, release)
- require.ErrorIs(t, err, context.Canceled)
- require.Equal(t, 1, cache.waitIncrementCalls)
- require.Equal(t, 1, cache.waitDecrementCalls)
- require.Equal(t, 0, cache.userReleaseCalls)
- }
- func TestWaitForSlotWithPingTimeout_TimeoutAndStreamPing(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- accountSeq: []bool{false, false, false},
- }
- concurrency := service.NewConcurrencyService(cache)
- t.Run("timeout_returns_concurrency_error", func(t *testing.T) {
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, 130*time.Millisecond, false, &streamStarted, true)
- require.Nil(t, release)
- var cErr *ConcurrencyError
- require.ErrorAs(t, err, &cErr)
- require.True(t, cErr.IsTimeout)
- })
- t.Run("stream_mode_sends_ping_before_timeout", func(t *testing.T) {
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatComment, 10*time.Millisecond)
- c, rec := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, 70*time.Millisecond, true, &streamStarted, true)
- require.Nil(t, release)
- var cErr *ConcurrencyError
- require.ErrorAs(t, err, &cErr)
- require.True(t, cErr.IsTimeout)
- require.True(t, streamStarted)
- require.Contains(t, rec.Body.String(), ":\n\n")
- })
- }
- func TestWaitForSlotWithPingTimeout_ParentContextCanceled(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- accountSeq: []bool{false},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- reqCtx, cancel := context.WithCancel(c.Request.Context())
- c.Request = c.Request.WithContext(reqCtx)
- cancel()
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "account", 101, 2, time.Second, false, &streamStarted, true)
- require.Nil(t, release)
- require.ErrorIs(t, err, context.Canceled)
- var cErr *ConcurrencyError
- require.False(t, errors.As(err, &cErr))
- }
- func TestWaitForSlotWithPingTimeout_AcquireError(t *testing.T) {
- errCache := &helperConcurrencyCacheStubWithError{
- err: errors.New("redis unavailable"),
- }
- concurrency := service.NewConcurrencyService(errCache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.waitForSlotWithPingTimeout(c, "account", 1, 1, 200*time.Millisecond, false, &streamStarted, true)
- require.Nil(t, release)
- require.Error(t, err)
- require.Contains(t, err.Error(), "redis unavailable")
- }
- func TestAcquireAccountSlotWithWaitTimeout_ImmediateAttemptBeforeBackoff(t *testing.T) {
- cache := &helperConcurrencyCacheStub{
- accountSeq: []bool{false},
- }
- concurrency := service.NewConcurrencyService(cache)
- helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- streamStarted := false
- release, err := helper.AcquireAccountSlotWithWaitTimeout(c, 301, 1, 30*time.Millisecond, false, &streamStarted)
- require.Nil(t, release)
- var cErr *ConcurrencyError
- require.ErrorAs(t, err, &cErr)
- require.True(t, cErr.IsTimeout)
- require.GreaterOrEqual(t, cache.accountAcquireCalls, 1)
- }
- type helperConcurrencyCacheStubWithError struct {
- helperConcurrencyCacheStub
- err error
- }
- func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
- return false, s.err
- }
- func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
- c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
- // The hot path reuses ParsedRequest instead of re-parsing the body; the probe
- // marker must survive that projection or the exemption only works cold.
- parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
- SetClaudeCodeClientContext(c, nil, parsed)
- require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
- c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
- c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
- SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
- require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
- }
|