Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions backend/internal/handler/admin/grok_oauth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,13 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
ID: 42,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}}
upstream := &grokQuotaHandlerUpstream{}
Expand Down
8 changes: 4 additions & 4 deletions backend/internal/handler/admin/ops_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
filter.EndTime = &endTime
}
filter.View = "all"
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
// Provider-health list includes recovered inference and credential rows.
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
Expand Down Expand Up @@ -470,8 +470,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
}

filter.View = parseOpsViewParam(c)
filter.Phase = "upstream"
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
// Provider-health list includes recovered inference and credential rows.
filter.IncludeRecoveredUpstream = true
filter.Owner = "provider"
filter.Source = strings.TrimSpace(c.Query("error_source"))
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/handler/failover_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ func (s *FailoverState) HandleFailoverError(
retryLimit int,
failoverErr *service.UpstreamFailoverError,
) FailoverAction {
if ctx != nil && ctx.Err() != nil {
return FailoverCanceled
}
s.LastFailoverErr = failoverErr
if failoverErr == nil || !failoverErr.ShouldRetryNextAccount() {
return FailoverExhausted
}

// 缓存计费判断
if needForceCacheBilling(s.hasBoundSession, failoverErr) {
Expand Down
49 changes: 47 additions & 2 deletions backend/internal/handler/failover_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"context"
"net/http"
"testing"
"time"

Expand Down Expand Up @@ -128,6 +129,50 @@ func TestSleepWithContext(t *testing.T) {
// ---------------------------------------------------------------------------

func TestHandleFailoverError_BasicSwitch(t *testing.T) {
t.Run("显式停止不切换账号且旧错误默认仍切换", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, false)
stopErr := &service.UpstreamFailoverError{
Stage: service.GatewayFailureStageAccountAuth,
Scope: service.GatewayFailureScopeProvider,
NextAccountAction: service.NextAccountStop,
}

action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformGrok, maxSameAccountRetries, stopErr)

require.Equal(t, FailoverExhausted, action)
require.Zero(t, fs.SwitchCount)
require.Empty(t, fs.FailedAccountIDs)
require.Equal(t, stopErr, fs.LastFailoverErr)

legacyErr := newTestFailoverErr(http.StatusTooManyRequests, false, false)
action = fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformGrok, maxSameAccountRetries, legacyErr)

require.Equal(t, FailoverContinue, action)
require.Equal(t, 1, fs.SwitchCount)
require.Contains(t, fs.FailedAccountIDs, int64(100))
})

t.Run("已取消的认证失败不改变切换状态", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, false)
err := &service.UpstreamFailoverError{
Stage: service.GatewayFailureStageAccountAuth,
Scope: service.GatewayFailureScopeAccount,
NextAccountAction: service.NextAccountRetry,
}

action := fs.HandleFailoverError(ctx, mock, 101, service.PlatformGrok, maxSameAccountRetries, err)

require.Equal(t, FailoverCanceled, action)
require.Zero(t, fs.SwitchCount)
require.Empty(t, fs.FailedAccountIDs)
require.Nil(t, fs.LastFailoverErr)
require.Empty(t, mock.calls)
})

t.Run("非重试错误_非Antigravity_直接切换", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, false)
Expand Down Expand Up @@ -452,8 +497,8 @@ func TestHandleFailoverError_ContextCanceled(t *testing.T) {

require.Equal(t, FailoverCanceled, action)
require.Less(t, elapsed, 100*time.Millisecond, "应立即返回")
// 重试计数仍应递增
require.Equal(t, 1, fs.SameAccountRetryCount[100])
// 入口已取消时不得改变任何重试状态。
require.Zero(t, fs.SameAccountRetryCount[100])
})

t.Run("Antigravity延迟期间context取消", func(t *testing.T) {
Expand Down
98 changes: 98 additions & 0 deletions backend/internal/handler/gateway_handler_cancellation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//go:build unit

package handler

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "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 countingGatewaySchedulerCache struct {
*fakeSchedulerCache
snapshotCalls atomic.Int64
}

func (c *countingGatewaySchedulerCache) GetSnapshot(ctx context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) {
c.snapshotCalls.Add(1)
return c.fakeSchedulerCache.GetSnapshot(ctx, bucket)
}

func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(9100)
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformAnthropic, Status: service.StatusActive}
account := &service.Account{
ID: 9101, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true, Concurrency: 1,
AccountGroups: []service.AccountGroup{{AccountID: 9101, GroupID: groupID}},
}
schedulerCache := &countingGatewaySchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{account}}}
schedulerSnapshot := service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil)
gatewayService := service.NewGatewayService(
nil, &fakeGroupRepo{group: group}, nil, nil, nil, nil, nil, nil, nil,
schedulerSnapshot, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
cfg := &config.Config{RunMode: config.RunModeSimple}
billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billingCacheService.Stop)
h := &GatewayHandler{
gatewayService: gatewayService,
billingCacheService: billingCacheService,
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0),
maxAccountSwitches: 1,
cfg: cfg,
}
apiKey := &service.APIKey{
ID: 9102, UserID: 9103, GroupID: &groupID, Group: group, Status: service.StatusActive,
User: &service.User{ID: 9103, Concurrency: 10, Balance: 100},
}

tests := []struct {
name string
path string
body string
call func(*gin.Context)
}{
{
name: "responses", path: "/v1/responses", body: `{"model":"claude-test","input":"hello","stream":false}`,
call: h.Responses,
},
{
name: "chat completions", path: "/v1/chat/completions", body: `{"model":"claude-test","messages":[{"role":"user","content":"hello"}],"stream":false}`,
call: h.ChatCompletions,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schedulerCache.snapshotCalls.Store(0)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
ctx, cancel := context.WithCancel(context.Background())
cancel()
ctx = context.WithValue(ctx, ctxkey.Group, group)
req := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBufferString(tt.body)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
c.Request = req
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})

tt.call(c)

require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector")
_, selected := c.Get(opsAccountIDKey)
require.False(t, selected)
})
}
}
11 changes: 11 additions & 0 deletions backend/internal/handler/gateway_handler_chat_completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
}

for {
if c.Request.Context().Err() != nil {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
Expand Down Expand Up @@ -328,6 +331,14 @@ func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *serv
if streamStarted {
return
}
if lastErr != nil {
copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
}
if lastErr != nil && lastErr.IsCredentialFailure() {
status, message := credentialFailoverClientResponse(lastErr)
h.chatCompletionsErrorResponse(c, status, "server_error", message)
return
}
statusCode := http.StatusBadGateway
if lastErr != nil && lastErr.StatusCode > 0 {
statusCode = lastErr.StatusCode
Expand Down
11 changes: 11 additions & 0 deletions backend/internal/handler/gateway_handler_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
fs := NewFailoverState(h.maxAccountSwitches, false)

for {
if requestCtx.Err() != nil {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
Expand Down Expand Up @@ -307,6 +310,14 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr
if streamStarted {
return // Can't write error after stream started
}
if lastErr != nil {
copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
}
if lastErr != nil && lastErr.IsCredentialFailure() {
status, message := credentialFailoverClientResponse(lastErr)
h.responsesErrorResponse(c, status, "server_error", message)
return
}
statusCode := http.StatusBadGateway
if lastErr != nil && lastErr.StatusCode > 0 {
statusCode = lastErr.StatusCode
Expand Down
19 changes: 7 additions & 12 deletions backend/internal/handler/gateway_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,20 +163,15 @@ func wrapReleaseOnDone(ctx context.Context, releaseFunc func()) func() {
return nil
}
var once sync.Once
var stop func() bool

release := func() {
once.Do(func() {
if stop != nil {
_ = stop()
}
releaseFunc()
})
releaseOnce := func() {
once.Do(releaseFunc)
}
stop := context.AfterFunc(ctx, releaseOnce)

stop = context.AfterFunc(ctx, release)

return release
return func() {
_ = stop()
releaseOnce()
}
}

// IncrementWaitCount increments the wait count for a user
Expand Down
17 changes: 17 additions & 0 deletions backend/internal/handler/gateway_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/require"
)

// TestWrapReleaseOnDone_NoGoroutineLeak 验证 wrapReleaseOnDone 修复后不会泄露 goroutine
Expand Down Expand Up @@ -67,6 +69,21 @@ func TestWrapReleaseOnDone_ContextCancellation(t *testing.T) {
}
}

func TestWrapReleaseOnDone_AlreadyCancelledReleasesExactlyOnce(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

var releaseCount int32
release := wrapReleaseOnDone(ctx, func() {
atomic.AddInt32(&releaseCount, 1)
})
release()

require.Eventually(t, func() bool {
return atomic.LoadInt32(&releaseCount) == 1
}, time.Second, time.Millisecond)
}

// TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce 验证多次调用 release 只释放一次
func TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
Expand Down
19 changes: 18 additions & 1 deletion backend/internal/handler/grok_media.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
failedAccountIDs := make(map[int64]struct{})
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
var oauth429FailoverState service.OpenAIOAuth429FailoverState
switchCount := 0
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
Expand All @@ -174,6 +175,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
routingStart := time.Now()

for {
if requestCtx.Err() != nil {
return
}
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
Expand Down Expand Up @@ -257,11 +261,20 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if requestCtx.Err() != nil {
return
}
if failoverErr.ShouldReportAccountScheduleFailure() {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
}
if c.Writer.Size() != writerSizeBeforeForward {
h.handleFailoverExhausted(c, failoverErr, true)
return
}
if !failoverErr.ShouldRetryNextAccount() {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
Expand All @@ -288,6 +301,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
switchCount++
if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
reqLog.Warn("grok_media.upstream_failover_switching",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/handler/openai_alpha_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
failedAccountIDs := make(map[int64]struct{})
var lastFailoverErr *service.UpstreamFailoverError
switchCount := 0
var oauth429FailoverState service.OpenAIOAuth429FailoverState
routingStart := time.Now()

for {
Expand Down Expand Up @@ -188,7 +189,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
return
}
switchCount++
if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount) {
if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount, &oauth429FailoverState) {
h.handleFailoverExhausted(c, failoverErr, false)
return
}
Expand Down
Loading
Loading