Merge pull request #2120 from gaoren002/fix/rate-limit-429-cooldown-config
fix(rate-limit): make 429 fallback cooldown configurable
This commit is contained in:
commit
37f7c7128c
@ -1117,8 +1117,9 @@ type DefaultConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
|
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
|
||||||
OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟)
|
RateLimit429CooldownSeconds int `mapstructure:"rate_limit_429_cooldown_seconds"` // 429无重置时间时的默认回避时间(秒)
|
||||||
|
OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIKeyAuthCacheConfig API Key 认证缓存配置
|
// APIKeyAuthCacheConfig API Key 认证缓存配置
|
||||||
@ -1578,6 +1579,7 @@ func setDefaults() {
|
|||||||
|
|
||||||
// RateLimit
|
// RateLimit
|
||||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||||
|
viper.SetDefault("rate_limit.rate_limit_429_cooldown_seconds", 5)
|
||||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||||
|
|
||||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||||
|
|||||||
@ -2462,6 +2462,58 @@ func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRateLimit429CooldownSettings 获取429默认回避配置
|
||||||
|
// GET /api/v1/admin/settings/rate-limit-429-cooldown
|
||||||
|
func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) {
|
||||||
|
settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.RateLimit429CooldownSettings{
|
||||||
|
Enabled: settings.Enabled,
|
||||||
|
CooldownSeconds: settings.CooldownSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求
|
||||||
|
type UpdateRateLimit429CooldownSettingsRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CooldownSeconds int `json:"cooldown_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRateLimit429CooldownSettings 更新429默认回避配置
|
||||||
|
// PUT /api/v1/admin/settings/rate-limit-429-cooldown
|
||||||
|
func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
|
||||||
|
var req UpdateRateLimit429CooldownSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := &service.RateLimit429CooldownSettings{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
CooldownSeconds: req.CooldownSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.RateLimit429CooldownSettings{
|
||||||
|
Enabled: updatedSettings.Enabled,
|
||||||
|
CooldownSeconds: updatedSettings.CooldownSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetStreamTimeoutSettings 获取流超时处理配置
|
// GetStreamTimeoutSettings 获取流超时处理配置
|
||||||
// GET /api/v1/admin/settings/stream-timeout
|
// GET /api/v1/admin/settings/stream-timeout
|
||||||
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
|
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
|
||||||
|
|||||||
@ -264,6 +264,12 @@ type OverloadCooldownSettings struct {
|
|||||||
CooldownMinutes int `json:"cooldown_minutes"`
|
CooldownMinutes int `json:"cooldown_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimit429CooldownSettings 429默认回避配置 DTO
|
||||||
|
type RateLimit429CooldownSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CooldownSeconds int `json:"cooldown_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置 DTO
|
// StreamTimeoutSettings 流超时处理配置 DTO
|
||||||
type StreamTimeoutSettings struct {
|
type StreamTimeoutSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|||||||
@ -408,6 +408,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
// 529过载冷却配置
|
// 529过载冷却配置
|
||||||
adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings)
|
adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings)
|
||||||
adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings)
|
adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings)
|
||||||
|
// 429默认回避配置
|
||||||
|
adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings)
|
||||||
|
adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings)
|
||||||
// 流超时处理配置
|
// 流超时处理配置
|
||||||
adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings)
|
adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings)
|
||||||
adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings)
|
adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings)
|
||||||
|
|||||||
@ -287,6 +287,9 @@ const (
|
|||||||
// SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling.
|
// SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling.
|
||||||
SettingKeyOverloadCooldownSettings = "overload_cooldown_settings"
|
SettingKeyOverloadCooldownSettings = "overload_cooldown_settings"
|
||||||
|
|
||||||
|
// SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling.
|
||||||
|
SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings"
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// Stream Timeout Handling
|
// Stream Timeout Handling
|
||||||
// =========================
|
// =========================
|
||||||
|
|||||||
114
backend/internal/service/rate_limit_429_cooldown_test.go
Normal file
114
backend/internal/service/rate_limit_429_cooldown_test.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimit429AccountRepoStub struct {
|
||||||
|
mockAccountRepoForGemini
|
||||||
|
rateLimitCalls int
|
||||||
|
lastRateLimitID int64
|
||||||
|
lastRateLimitReset time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rateLimit429AccountRepoStub) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
|
||||||
|
r.rateLimitCalls++
|
||||||
|
r.lastRateLimitID = id
|
||||||
|
r.lastRateLimitReset = resetAt
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateLimit429CooldownSettings_DefaultsWhenNotSet(t *testing.T) {
|
||||||
|
repo := newMockSettingRepo()
|
||||||
|
svc := NewSettingService(repo, &config.Config{})
|
||||||
|
|
||||||
|
settings, err := svc.GetRateLimit429CooldownSettings(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, settings.Enabled)
|
||||||
|
require.Equal(t, 5, settings.CooldownSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateLimit429CooldownSettings_ReadsFromDB(t *testing.T) {
|
||||||
|
repo := newMockSettingRepo()
|
||||||
|
data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12})
|
||||||
|
repo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
|
||||||
|
svc := NewSettingService(repo, &config.Config{})
|
||||||
|
|
||||||
|
settings, err := svc.GetRateLimit429CooldownSettings(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, settings.Enabled)
|
||||||
|
require.Equal(t, 12, settings.CooldownSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRateLimit429CooldownSettings_EnabledRejectsOutOfRange(t *testing.T) {
|
||||||
|
svc := NewSettingService(newMockSettingRepo(), &config.Config{})
|
||||||
|
|
||||||
|
for _, seconds := range []int{0, -1, 7201, 99999} {
|
||||||
|
err := svc.SetRateLimit429CooldownSettings(context.Background(), &RateLimit429CooldownSettings{
|
||||||
|
Enabled: true, CooldownSeconds: seconds,
|
||||||
|
})
|
||||||
|
require.Error(t, err, "should reject enabled=true + cooldown_seconds=%d", seconds)
|
||||||
|
require.Contains(t, err.Error(), "cooldown_seconds must be between 1-7200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle429_FallbackUsesDBSeconds(t *testing.T) {
|
||||||
|
accountRepo := &rateLimit429AccountRepoStub{}
|
||||||
|
settingRepo := newMockSettingRepo()
|
||||||
|
data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12})
|
||||||
|
settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
|
||||||
|
|
||||||
|
settingSvc := NewSettingService(settingRepo, &config.Config{})
|
||||||
|
svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
|
||||||
|
svc.SetSettingService(settingSvc)
|
||||||
|
|
||||||
|
account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||||
|
before := time.Now()
|
||||||
|
svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`))
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
require.Equal(t, 1, accountRepo.rateLimitCalls)
|
||||||
|
require.Equal(t, int64(42), accountRepo.lastRateLimitID)
|
||||||
|
require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) {
|
||||||
|
accountRepo := &rateLimit429AccountRepoStub{}
|
||||||
|
settingRepo := newMockSettingRepo()
|
||||||
|
data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12})
|
||||||
|
settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
|
||||||
|
|
||||||
|
settingSvc := NewSettingService(settingRepo, &config.Config{})
|
||||||
|
svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
|
||||||
|
svc.SetSettingService(settingSvc)
|
||||||
|
|
||||||
|
account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||||
|
svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`))
|
||||||
|
|
||||||
|
require.Zero(t, accountRepo.rateLimitCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandle429_FallbackUsesConfigSecondsWhenSettingServiceMissing(t *testing.T) {
|
||||||
|
accountRepo := &rateLimit429AccountRepoStub{}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.RateLimit.RateLimit429CooldownSeconds = 9
|
||||||
|
svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil)
|
||||||
|
|
||||||
|
account := &Account{ID: 44, Platform: PlatformGemini, Type: AccountTypeAPIKey}
|
||||||
|
before := time.Now()
|
||||||
|
svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"message":"slow down"}}`))
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
require.Equal(t, 1, accountRepo.rateLimitCalls)
|
||||||
|
require.Equal(t, int64(44), accountRepo.lastRateLimitID)
|
||||||
|
require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(9*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(9*time.Second)))
|
||||||
|
}
|
||||||
@ -55,6 +55,11 @@ type geminiUsageTotalsBatchProvider interface {
|
|||||||
|
|
||||||
const geminiPrecheckCacheTTL = time.Minute
|
const geminiPrecheckCacheTTL = time.Minute
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRateLimit429CooldownSeconds = 5
|
||||||
|
maxRateLimit429CooldownSeconds = 7200
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
openAI403CooldownMinutesDefault = 10
|
openAI403CooldownMinutesDefault = 10
|
||||||
openAI403DisableThreshold = 3
|
openAI403DisableThreshold = 3
|
||||||
@ -891,12 +896,8 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他平台:没有重置时间,使用默认5分钟
|
// 其他平台:没有重置时间,使用可配置的秒级默认回避,避免误伤长时间不可调度。
|
||||||
resetAt := time.Now().Add(5 * time.Minute)
|
s.apply429FallbackRateLimit(ctx, account, "no_reset_time")
|
||||||
slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m")
|
|
||||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
|
||||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -904,10 +905,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
ts, err := strconv.ParseInt(resetTimestamp, 10, 64)
|
ts, err := strconv.ParseInt(resetTimestamp, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err)
|
slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err)
|
||||||
resetAt := time.Now().Add(5 * time.Minute)
|
s.apply429FallbackRateLimit(ctx, account, "reset_parse_failed")
|
||||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
|
||||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -929,6 +927,51 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt)
|
slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RateLimitService) apply429FallbackRateLimit(ctx context.Context, account *Account, reason string) {
|
||||||
|
cooldown, enabled := s.get429FallbackCooldown(ctx, account)
|
||||||
|
if !enabled {
|
||||||
|
slog.Info("rate_limit_429_fallback_ignored", "account_id", account.ID, "platform", account.Platform, "reason", reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAt := time.Now().Add(cooldown)
|
||||||
|
slog.Warn("rate_limit_429_fallback_used", "account_id", account.ID, "platform", account.Platform, "reason", reason, "using_default", cooldown.String())
|
||||||
|
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
||||||
|
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RateLimitService) get429FallbackCooldown(ctx context.Context, account *Account) (time.Duration, bool) {
|
||||||
|
if s.settingService != nil {
|
||||||
|
settings, err := s.settingService.GetRateLimit429CooldownSettings(ctx)
|
||||||
|
if err == nil && settings != nil {
|
||||||
|
if !settings.Enabled {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
seconds := clampRateLimit429CooldownSeconds(settings.CooldownSeconds)
|
||||||
|
return time.Duration(seconds) * time.Second, true
|
||||||
|
}
|
||||||
|
slog.Warn("rate_limit_429_settings_read_failed", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := defaultRateLimit429CooldownSeconds
|
||||||
|
if s.cfg != nil && s.cfg.RateLimit.RateLimit429CooldownSeconds > 0 {
|
||||||
|
seconds = s.cfg.RateLimit.RateLimit429CooldownSeconds
|
||||||
|
}
|
||||||
|
seconds = clampRateLimit429CooldownSeconds(seconds)
|
||||||
|
return time.Duration(seconds) * time.Second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampRateLimit429CooldownSeconds(seconds int) int {
|
||||||
|
if seconds < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if seconds > maxRateLimit429CooldownSeconds {
|
||||||
|
return maxRateLimit429CooldownSeconds
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
|
||||||
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
|
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
|
||||||
// 返回 nil 表示无法从响应头中确定重置时间
|
// 返回 nil 表示无法从响应头中确定重置时间
|
||||||
func calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
func calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
||||||
|
|||||||
@ -2778,6 +2778,55 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin
|
|||||||
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
|
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRateLimit429CooldownSettings 获取429默认回避配置
|
||||||
|
func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) {
|
||||||
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSettingNotFound) {
|
||||||
|
return DefaultRateLimit429CooldownSettings(), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get 429 cooldown settings: %w", err)
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return DefaultRateLimit429CooldownSettings(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings RateLimit429CooldownSettings
|
||||||
|
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||||
|
return DefaultRateLimit429CooldownSettings(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.CooldownSeconds < 1 {
|
||||||
|
settings.CooldownSeconds = 1
|
||||||
|
}
|
||||||
|
if settings.CooldownSeconds > 7200 {
|
||||||
|
settings.CooldownSeconds = 7200
|
||||||
|
}
|
||||||
|
|
||||||
|
return &settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRateLimit429CooldownSettings 设置429默认回避配置
|
||||||
|
func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error {
|
||||||
|
if settings == nil {
|
||||||
|
return fmt.Errorf("settings cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 {
|
||||||
|
if settings.Enabled {
|
||||||
|
return fmt.Errorf("cooldown_seconds must be between 1-7200")
|
||||||
|
}
|
||||||
|
settings.CooldownSeconds = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal 429 cooldown settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
|
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
|
||||||
//
|
//
|
||||||
// 优先级:
|
// 优先级:
|
||||||
|
|||||||
@ -381,6 +381,14 @@ type OverloadCooldownSettings struct {
|
|||||||
CooldownMinutes int `json:"cooldown_minutes"`
|
CooldownMinutes int `json:"cooldown_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimit429CooldownSettings 429默认回避配置
|
||||||
|
type RateLimit429CooldownSettings struct {
|
||||||
|
// Enabled 是否在无法解析上游重置时间时应用默认429回避
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// CooldownSeconds 默认回避时长(秒)
|
||||||
|
CooldownSeconds int `json:"cooldown_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟)
|
// DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟)
|
||||||
func DefaultOverloadCooldownSettings() *OverloadCooldownSettings {
|
func DefaultOverloadCooldownSettings() *OverloadCooldownSettings {
|
||||||
return &OverloadCooldownSettings{
|
return &OverloadCooldownSettings{
|
||||||
@ -389,6 +397,14 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒)
|
||||||
|
func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings {
|
||||||
|
return &RateLimit429CooldownSettings{
|
||||||
|
Enabled: true,
|
||||||
|
CooldownSeconds: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultBetaPolicySettings 返回默认的 Beta 策略配置
|
// DefaultBetaPolicySettings 返回默认的 Beta 策略配置
|
||||||
func DefaultBetaPolicySettings() *BetaPolicySettings {
|
func DefaultBetaPolicySettings() *BetaPolicySettings {
|
||||||
return &BetaPolicySettings{
|
return &BetaPolicySettings{
|
||||||
|
|||||||
@ -948,6 +948,10 @@ rate_limit:
|
|||||||
# 上游返回 529(过载)时的冷却时间(分钟)
|
# 上游返回 529(过载)时的冷却时间(分钟)
|
||||||
overload_cooldown_minutes: 10
|
overload_cooldown_minutes: 10
|
||||||
|
|
||||||
|
# Default cooldown time (in seconds) when upstream returns 429 without a reset time
|
||||||
|
# 上游返回 429 且无明确重置时间时的默认回避时间(秒)
|
||||||
|
rate_limit_429_cooldown_seconds: 5
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Pricing Data Source (Optional)
|
# Pricing Data Source (Optional)
|
||||||
# 定价数据源(可选)
|
# 定价数据源(可选)
|
||||||
|
|||||||
@ -805,6 +805,30 @@ export async function updateOverloadCooldownSettings(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 429 Rate Limit Cooldown Settings ====================
|
||||||
|
|
||||||
|
export interface RateLimit429CooldownSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
cooldown_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRateLimit429CooldownSettings(): Promise<RateLimit429CooldownSettings> {
|
||||||
|
const { data } = await apiClient.get<RateLimit429CooldownSettings>(
|
||||||
|
"/admin/settings/rate-limit-429-cooldown",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRateLimit429CooldownSettings(
|
||||||
|
settings: RateLimit429CooldownSettings,
|
||||||
|
): Promise<RateLimit429CooldownSettings> {
|
||||||
|
const { data } = await apiClient.put<RateLimit429CooldownSettings>(
|
||||||
|
"/admin/settings/rate-limit-429-cooldown",
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Stream Timeout Settings ====================
|
// ==================== Stream Timeout Settings ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1024,6 +1048,8 @@ export const settingsAPI = {
|
|||||||
deleteAdminApiKey,
|
deleteAdminApiKey,
|
||||||
getOverloadCooldownSettings,
|
getOverloadCooldownSettings,
|
||||||
updateOverloadCooldownSettings,
|
updateOverloadCooldownSettings,
|
||||||
|
getRateLimit429CooldownSettings,
|
||||||
|
updateRateLimit429CooldownSettings,
|
||||||
getStreamTimeoutSettings,
|
getStreamTimeoutSettings,
|
||||||
updateStreamTimeoutSettings,
|
updateStreamTimeoutSettings,
|
||||||
getRectifierSettings,
|
getRectifierSettings,
|
||||||
|
|||||||
@ -5543,6 +5543,16 @@ export default {
|
|||||||
saved: 'Overload cooldown settings saved',
|
saved: 'Overload cooldown settings saved',
|
||||||
saveFailed: 'Failed to save overload cooldown settings'
|
saveFailed: 'Failed to save overload cooldown settings'
|
||||||
},
|
},
|
||||||
|
rateLimit429Cooldown: {
|
||||||
|
title: '429 Default Cooldown',
|
||||||
|
description: 'Configure the default account cooldown when upstream returns 429 without an explicit reset time',
|
||||||
|
enabled: 'Enable 429 Default Cooldown',
|
||||||
|
enabledHint: 'Pause account scheduling when a 429 has no reset time, then auto-recover after cooldown',
|
||||||
|
cooldownSeconds: 'Cooldown Duration (seconds)',
|
||||||
|
cooldownSecondsHint: 'Default cooldown duration (1-7200 seconds); explicit upstream reset times still take precedence',
|
||||||
|
saved: '429 default cooldown settings saved',
|
||||||
|
saveFailed: 'Failed to save 429 default cooldown settings'
|
||||||
|
},
|
||||||
streamTimeout: {
|
streamTimeout: {
|
||||||
title: 'Stream Timeout Handling',
|
title: 'Stream Timeout Handling',
|
||||||
description: 'Configure account handling strategy when upstream response times out',
|
description: 'Configure account handling strategy when upstream response times out',
|
||||||
|
|||||||
@ -5703,6 +5703,16 @@ export default {
|
|||||||
saved: '过载冷却设置保存成功',
|
saved: '过载冷却设置保存成功',
|
||||||
saveFailed: '保存过载冷却设置失败'
|
saveFailed: '保存过载冷却设置失败'
|
||||||
},
|
},
|
||||||
|
rateLimit429Cooldown: {
|
||||||
|
title: '429 默认回避',
|
||||||
|
description: '配置上游返回 429 且没有明确重置时间时的默认账号回避策略',
|
||||||
|
enabled: '启用 429 默认回避',
|
||||||
|
enabledHint: '收到无重置时间的 429 时暂停该账号调度,冷却后自动恢复',
|
||||||
|
cooldownSeconds: '回避时长(秒)',
|
||||||
|
cooldownSecondsHint: '默认回避持续时间(1-7200 秒);上游返回明确 reset 时仍优先使用上游时间',
|
||||||
|
saved: '429 默认回避设置保存成功',
|
||||||
|
saveFailed: '保存 429 默认回避设置失败'
|
||||||
|
},
|
||||||
streamTimeout: {
|
streamTimeout: {
|
||||||
title: '流超时处理',
|
title: '流超时处理',
|
||||||
description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中',
|
description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中',
|
||||||
|
|||||||
@ -291,6 +291,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limit Cooldown (429) Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<div
|
||||||
|
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t("admin.settings.rateLimit429Cooldown.title") }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.settings.rateLimit429Cooldown.description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<div
|
||||||
|
v-if="rateLimit429CooldownLoading"
|
||||||
|
class="flex items-center gap-2 text-gray-500"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
|
||||||
|
></div>
|
||||||
|
{{ t("common.loading") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||||
|
t("admin.settings.rateLimit429Cooldown.enabled")
|
||||||
|
}}</label>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t("admin.settings.rateLimit429Cooldown.enabledHint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="rateLimit429CooldownForm.enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="rateLimit429CooldownForm.enabled"
|
||||||
|
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
"admin.settings.rateLimit429Cooldown.cooldownSeconds",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="rateLimit429CooldownForm.cooldown_seconds"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="7200"
|
||||||
|
class="input w-32"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
"admin.settings.rateLimit429Cooldown.cooldownSecondsHint",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="saveRateLimit429CooldownSettings"
|
||||||
|
:disabled="rateLimit429CooldownSaving"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="rateLimit429CooldownSaving"
|
||||||
|
class="mr-1 h-4 w-4 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{{
|
||||||
|
rateLimit429CooldownSaving
|
||||||
|
? t("common.saving")
|
||||||
|
: t("common.save")
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Stream Timeout Settings -->
|
<!-- Stream Timeout Settings -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div
|
<div
|
||||||
@ -5605,6 +5712,14 @@ const overloadCooldownForm = reactive({
|
|||||||
cooldown_minutes: 10,
|
cooldown_minutes: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rate Limit Cooldown (429) 状态
|
||||||
|
const rateLimit429CooldownLoading = ref(true);
|
||||||
|
const rateLimit429CooldownSaving = ref(false);
|
||||||
|
const rateLimit429CooldownForm = reactive({
|
||||||
|
enabled: true,
|
||||||
|
cooldown_seconds: 5,
|
||||||
|
});
|
||||||
|
|
||||||
// Stream Timeout 状态
|
// Stream Timeout 状态
|
||||||
const streamTimeoutLoading = ref(true);
|
const streamTimeoutLoading = ref(true);
|
||||||
const streamTimeoutSaving = ref(false);
|
const streamTimeoutSaving = ref(false);
|
||||||
@ -7054,6 +7169,40 @@ async function saveOverloadCooldownSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate Limit Cooldown (429) 方法
|
||||||
|
async function loadRateLimit429CooldownSettings() {
|
||||||
|
rateLimit429CooldownLoading.value = true;
|
||||||
|
try {
|
||||||
|
const settings = await adminAPI.settings.getRateLimit429CooldownSettings();
|
||||||
|
Object.assign(rateLimit429CooldownForm, settings);
|
||||||
|
} catch (_error: unknown) {
|
||||||
|
// Silent fail - settings will use defaults
|
||||||
|
} finally {
|
||||||
|
rateLimit429CooldownLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRateLimit429CooldownSettings() {
|
||||||
|
rateLimit429CooldownSaving.value = true;
|
||||||
|
try {
|
||||||
|
const updated = await adminAPI.settings.updateRateLimit429CooldownSettings({
|
||||||
|
enabled: rateLimit429CooldownForm.enabled,
|
||||||
|
cooldown_seconds: rateLimit429CooldownForm.cooldown_seconds,
|
||||||
|
});
|
||||||
|
Object.assign(rateLimit429CooldownForm, updated);
|
||||||
|
appStore.showSuccess(t("admin.settings.rateLimit429Cooldown.saved"));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
appStore.showError(
|
||||||
|
extractApiErrorMessage(
|
||||||
|
error,
|
||||||
|
t("admin.settings.rateLimit429Cooldown.saveFailed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rateLimit429CooldownSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stream Timeout 方法
|
// Stream Timeout 方法
|
||||||
async function loadStreamTimeoutSettings() {
|
async function loadStreamTimeoutSettings() {
|
||||||
streamTimeoutLoading.value = true;
|
streamTimeoutLoading.value = true;
|
||||||
@ -7665,6 +7814,7 @@ onMounted(() => {
|
|||||||
loadSubscriptionGroups();
|
loadSubscriptionGroups();
|
||||||
loadAdminApiKey();
|
loadAdminApiKey();
|
||||||
loadOverloadCooldownSettings();
|
loadOverloadCooldownSettings();
|
||||||
|
loadRateLimit429CooldownSettings();
|
||||||
loadStreamTimeoutSettings();
|
loadStreamTimeoutSettings();
|
||||||
loadRectifierSettings();
|
loadRectifierSettings();
|
||||||
loadBetaPolicySettings();
|
loadBetaPolicySettings();
|
||||||
|
|||||||
@ -11,6 +11,8 @@ const {
|
|||||||
updateWebSearchEmulationConfig,
|
updateWebSearchEmulationConfig,
|
||||||
getAdminApiKey,
|
getAdminApiKey,
|
||||||
getOverloadCooldownSettings,
|
getOverloadCooldownSettings,
|
||||||
|
getRateLimit429CooldownSettings,
|
||||||
|
updateRateLimit429CooldownSettings,
|
||||||
getStreamTimeoutSettings,
|
getStreamTimeoutSettings,
|
||||||
getRectifierSettings,
|
getRectifierSettings,
|
||||||
getBetaPolicySettings,
|
getBetaPolicySettings,
|
||||||
@ -31,6 +33,8 @@ const {
|
|||||||
updateWebSearchEmulationConfig: vi.fn(),
|
updateWebSearchEmulationConfig: vi.fn(),
|
||||||
getAdminApiKey: vi.fn(),
|
getAdminApiKey: vi.fn(),
|
||||||
getOverloadCooldownSettings: vi.fn(),
|
getOverloadCooldownSettings: vi.fn(),
|
||||||
|
getRateLimit429CooldownSettings: vi.fn(),
|
||||||
|
updateRateLimit429CooldownSettings: vi.fn(),
|
||||||
getStreamTimeoutSettings: vi.fn(),
|
getStreamTimeoutSettings: vi.fn(),
|
||||||
getRectifierSettings: vi.fn(),
|
getRectifierSettings: vi.fn(),
|
||||||
getBetaPolicySettings: vi.fn(),
|
getBetaPolicySettings: vi.fn(),
|
||||||
@ -57,6 +61,8 @@ vi.mock("@/api", () => ({
|
|||||||
updateWebSearchEmulationConfig,
|
updateWebSearchEmulationConfig,
|
||||||
getAdminApiKey,
|
getAdminApiKey,
|
||||||
getOverloadCooldownSettings,
|
getOverloadCooldownSettings,
|
||||||
|
getRateLimit429CooldownSettings,
|
||||||
|
updateRateLimit429CooldownSettings,
|
||||||
getStreamTimeoutSettings,
|
getStreamTimeoutSettings,
|
||||||
getRectifierSettings,
|
getRectifierSettings,
|
||||||
getBetaPolicySettings,
|
getBetaPolicySettings,
|
||||||
@ -454,6 +460,8 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
updateWebSearchEmulationConfig.mockReset();
|
updateWebSearchEmulationConfig.mockReset();
|
||||||
getAdminApiKey.mockReset();
|
getAdminApiKey.mockReset();
|
||||||
getOverloadCooldownSettings.mockReset();
|
getOverloadCooldownSettings.mockReset();
|
||||||
|
getRateLimit429CooldownSettings.mockReset();
|
||||||
|
updateRateLimit429CooldownSettings.mockReset();
|
||||||
getStreamTimeoutSettings.mockReset();
|
getStreamTimeoutSettings.mockReset();
|
||||||
getRectifierSettings.mockReset();
|
getRectifierSettings.mockReset();
|
||||||
getBetaPolicySettings.mockReset();
|
getBetaPolicySettings.mockReset();
|
||||||
@ -490,6 +498,11 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
cooldown_minutes: 10,
|
cooldown_minutes: 10,
|
||||||
});
|
});
|
||||||
|
getRateLimit429CooldownSettings.mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
cooldown_seconds: 5,
|
||||||
|
});
|
||||||
|
updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload);
|
||||||
getStreamTimeoutSettings.mockResolvedValue({
|
getStreamTimeoutSettings.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
action: "temp_unsched",
|
action: "temp_unsched",
|
||||||
@ -690,6 +703,8 @@ describe("admin SettingsView wechat connect controls", () => {
|
|||||||
updateWebSearchEmulationConfig.mockReset();
|
updateWebSearchEmulationConfig.mockReset();
|
||||||
getAdminApiKey.mockReset();
|
getAdminApiKey.mockReset();
|
||||||
getOverloadCooldownSettings.mockReset();
|
getOverloadCooldownSettings.mockReset();
|
||||||
|
getRateLimit429CooldownSettings.mockReset();
|
||||||
|
updateRateLimit429CooldownSettings.mockReset();
|
||||||
getStreamTimeoutSettings.mockReset();
|
getStreamTimeoutSettings.mockReset();
|
||||||
getRectifierSettings.mockReset();
|
getRectifierSettings.mockReset();
|
||||||
getBetaPolicySettings.mockReset();
|
getBetaPolicySettings.mockReset();
|
||||||
@ -729,6 +744,11 @@ describe("admin SettingsView wechat connect controls", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
cooldown_minutes: 10,
|
cooldown_minutes: 10,
|
||||||
});
|
});
|
||||||
|
getRateLimit429CooldownSettings.mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
cooldown_seconds: 5,
|
||||||
|
});
|
||||||
|
updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload);
|
||||||
getStreamTimeoutSettings.mockResolvedValue({
|
getStreamTimeoutSettings.mockResolvedValue({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
action: "temp_unsched",
|
action: "temp_unsched",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user