new-api/middleware/distributor_concurrency_test.go
zizi fa77659fe8 fix(concurrency): wait when channel slots are saturated
Add a channel concurrency wait plan so saturated account/channel selection can distinguish no-wait, queue-full, and wait-for-slot outcomes while preserving fallback to other available channels first.
2026-05-25 00:37:43 +08:00

183 lines
6.1 KiB
Go

package middleware
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func restoreDistributorConcurrencyStubs(t *testing.T) {
t.Helper()
previousCheckQuota := checkChannelQuotaForDistribute
previousAcquire := acquireChannelConcurrencyForDistribute
previousSelect := cacheGetRandomSatisfiedChannelForDistribute
previousWaitPlan := getChannelConcurrencyWaitPlanForDistribute
previousEnterWaitQueue := tryEnterChannelConcurrencyWaitQueueForDistribute
previousWaitForSlot := waitForChannelConcurrencySlotForDistribute
t.Cleanup(func() {
checkChannelQuotaForDistribute = previousCheckQuota
acquireChannelConcurrencyForDistribute = previousAcquire
cacheGetRandomSatisfiedChannelForDistribute = previousSelect
getChannelConcurrencyWaitPlanForDistribute = previousWaitPlan
tryEnterChannelConcurrencyWaitQueueForDistribute = previousEnterWaitQueue
waitForChannelConcurrencySlotForDistribute = previousWaitForSlot
})
}
func newDistributorTestContext() *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
return c
}
func TestAcquireDistributedChannelConcurrencyReturns503WithoutWaitPlan(t *testing.T) {
restoreDistributorConcurrencyStubs(t)
checkChannelQuotaForDistribute = func(channelId int) error { return nil }
acquireChannelConcurrencyForDistribute = func(channelId, limit int) (bool, func()) {
return false, func() {}
}
cacheGetRandomSatisfiedChannelForDistribute = func(param *service.RetryParam) (*model.Channel, string, error) {
return nil, param.TokenGroup, nil
}
getChannelConcurrencyWaitPlanForDistribute = func() operation_setting.ChannelConcurrencyWaitPlanSetting {
return operation_setting.ChannelConcurrencyWaitPlanSetting{Enabled: false}
}
selected, release, response := acquireDistributedChannelConcurrency(
newDistributorTestContext(),
&model.Channel{Id: 1, ConcurrencyLimit: 1},
"gpt-test",
"default",
)
require.Nil(t, selected)
require.Nil(t, release)
require.NotNil(t, response)
require.Equal(t, http.StatusServiceUnavailable, response.statusCode)
require.Equal(t, "No available accounts", response.message)
}
func TestAcquireDistributedChannelConcurrencyReturns429WhenWaitQueueFull(t *testing.T) {
restoreDistributorConcurrencyStubs(t)
checkChannelQuotaForDistribute = func(channelId int) error { return nil }
acquireChannelConcurrencyForDistribute = func(channelId, limit int) (bool, func()) {
return false, func() {}
}
cacheGetRandomSatisfiedChannelForDistribute = func(param *service.RetryParam) (*model.Channel, string, error) {
return nil, param.TokenGroup, nil
}
getChannelConcurrencyWaitPlanForDistribute = func() operation_setting.ChannelConcurrencyWaitPlanSetting {
return operation_setting.ChannelConcurrencyWaitPlanSetting{
Enabled: true,
TimeoutSeconds: 30,
MaxWaitingRequests: 1,
}
}
tryEnterChannelConcurrencyWaitQueueForDistribute = func(channelId, maxWaiting int) (bool, func()) {
return false, func() {}
}
selected, release, response := acquireDistributedChannelConcurrency(
newDistributorTestContext(),
&model.Channel{Id: 1, ConcurrencyLimit: 1},
"gpt-test",
"default",
)
require.Nil(t, selected)
require.Nil(t, release)
require.NotNil(t, response)
require.Equal(t, http.StatusTooManyRequests, response.statusCode)
require.Equal(t, "Too many pending requests, please retry later", response.message)
}
func TestAcquireDistributedChannelConcurrencyWaitsForSlot(t *testing.T) {
restoreDistributorConcurrencyStubs(t)
released := false
checkChannelQuotaForDistribute = func(channelId int) error { return nil }
acquireChannelConcurrencyForDistribute = func(channelId, limit int) (bool, func()) {
return false, func() {}
}
cacheGetRandomSatisfiedChannelForDistribute = func(param *service.RetryParam) (*model.Channel, string, error) {
return nil, param.TokenGroup, nil
}
getChannelConcurrencyWaitPlanForDistribute = func() operation_setting.ChannelConcurrencyWaitPlanSetting {
return operation_setting.ChannelConcurrencyWaitPlanSetting{
Enabled: true,
TimeoutSeconds: 30,
MaxWaitingRequests: 1,
}
}
tryEnterChannelConcurrencyWaitQueueForDistribute = func(channelId, maxWaiting int) (bool, func()) {
return true, func() {}
}
waitForChannelConcurrencySlotForDistribute = func(ctx context.Context, channelId, limit int, timeout time.Duration) (bool, func(), error) {
return true, func() { released = true }, nil
}
selected, release, response := acquireDistributedChannelConcurrency(
newDistributorTestContext(),
&model.Channel{Id: 1, ConcurrencyLimit: 1},
"gpt-test",
"default",
)
require.Nil(t, response)
require.NotNil(t, selected)
require.Equal(t, 1, selected.Id)
require.NotNil(t, release)
release()
require.True(t, released)
}
func TestAcquireDistributedChannelConcurrencyUsesAnotherAvailableChannel(t *testing.T) {
restoreDistributorConcurrencyStubs(t)
checkChannelQuotaForDistribute = func(channelId int) error {
if channelId == 2 {
return nil
}
return nil
}
acquireChannelConcurrencyForDistribute = func(channelId, limit int) (bool, func()) {
if channelId == 2 {
return true, func() {}
}
return false, func() {}
}
cacheGetRandomSatisfiedChannelForDistribute = func(param *service.RetryParam) (*model.Channel, string, error) {
if _, excluded := param.ExcludedChannelIDs[1]; excluded {
return &model.Channel{Id: 2, ConcurrencyLimit: 1}, param.TokenGroup, nil
}
return nil, param.TokenGroup, errors.New("expected saturated channel to be excluded")
}
getChannelConcurrencyWaitPlanForDistribute = func() operation_setting.ChannelConcurrencyWaitPlanSetting {
return operation_setting.ChannelConcurrencyWaitPlanSetting{Enabled: false}
}
selected, release, response := acquireDistributedChannelConcurrency(
newDistributorTestContext(),
&model.Channel{Id: 1, ConcurrencyLimit: 1},
"gpt-test",
"default",
)
require.Nil(t, response)
require.NotNil(t, selected)
require.Equal(t, 2, selected.Id)
require.NotNil(t, release)
release()
}