Skip to content

[Feature] Configurable quota-cooldown-floor-seconds and transient-cooldown-by-status #5158

Description

@warelik

Summary

Adds two missing cooldown knobs from reports/recommended-config.md:

  • quota-cooldown-floor-seconds (default 1): makes the quota cooldown ladder base configurable and floors sub-second Retry-After hints.
  • transient-cooldown-by-status: per-HTTP-status overrides for 408/500/502/503/504 cooldowns, falling back to transient-error-cooldown-seconds for any status not listed.

This patch also incorporates the #5130 (quota ladder floor) refactor because quota-cooldown-floor-seconds lives inside that path. It therefore stacks on #5130 and relates to #5140 (lower transient cooldown default).

Areas touched

  • sdk/cliproxy/auth/conductor_cooldown.go (allowed area)
  • internal/config (allowed area)
  • internal/api/server.go, internal/api/server_reload.go, cmd/server/main.go
  • config.example.yaml
  • tests in sdk/cliproxy/auth/cooldown_backoff_test.go and internal/config/cooldown_config_test.go

Patch

cmd/server/main.go                         |  2 +
 config.example.yaml                        | 15 ++++++++
 internal/api/server.go                     |  2 +
 internal/api/server_reload.go              | 22 +++++++++++
 internal/config/config.go                  |  8 ++++
 internal/config/config_load.go             |  1 +
 internal/config/config_types.go            | 10 +++++
 internal/config/cooldown_config_test.go    | 60 ++++++++++++++++++++++++++++++
 internal/config/parse.go                   |  1 +
 sdk/cliproxy/auth/conductor_cooldown.go    | 60 +++++++++++++++++++++++++-----
 sdk/cliproxy/auth/cooldown_backoff_test.go | 43 +++++++++++++++++++++
 sdk/cliproxy/service_auth.go               |  2 +
 12 files changed, 216 insertions(+), 10 deletions(-)

Changes:

cmd/server/main.go
  @@ -563,6 +563,8 @@ func main() {
  +	coreauth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
  +	coreauth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
   
   	if err = logging.ConfigureLogOutput(cfg); err != nil {
   		log.Errorf("failed to configure log output: %v", err)
  +2 -0

config.example.yaml
  @@ -174,8 +174,23 @@ save-cooldown-status: false
  +# After router-for-me/CLIProxyAPI#5140, 0 means 10 s.
   transient-error-cooldown-seconds: 0
   
  +# Per-status overrides for transient error cooldowns.
  +# Statuses not listed fall back to transient-error-cooldown-seconds.
  +# Example:
  +# transient-cooldown-by-status:
  +#   - status: 408
  +#     cooldown-seconds: 2
  +#   - status: 503
  +#     cooldown-seconds: 10
  +
  +# Minimum base in seconds for the quota cooldown ladder.
  +# Sub-second Retry-After hints are never allowed below this floor. Default 1.
  +# Stacks on router-for-me/CLIProxyAPI#5130.
  +quota-cooldown-floor-seconds: 1
  +
   # When true, globally disable Claude request cloaking (the Claude Code CLI disguise and
   # system prompt replacement), so the original system prompt is passed through to Claude as-is.
   # Individual credentials can still override this: a claude-api-key entry via its "cloak.mode",
  +15 -0

internal/api/server.go
  @@ -199,6 +199,8 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
  +	auth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
  +	auth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
   	applySignatureCacheConfig(nil, cfg)
   	// Initialize management handler
   	s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
  +2 -0

internal/api/server_reload.go
  @@ -18,6 +18,22 @@ import (
  +func transientCooldownByStatusEqual(a, b []config.TransientCooldownByStatusRule) bool {
  +	if len(a) != len(b) {
  +		return false
  +	}
  +	m := make(map[int]int, len(a))
  +	for _, r := range a {
  +		m[r.Status] = r.CooldownSeconds
  +	}
  +	for _, r := range b {
  +		if m[r.Status] != r.CooldownSeconds {
  +			return false
  +		}
  +	}
  +	return true
  +}
  +
   func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) bool {
   	if s == nil || s.accessManager == nil || newCfg == nil {
   		return false
  @@ -104,6 +120,12 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
  +	if oldCfg == nil || oldCfg.QuotaCooldownFloorSeconds != cfg.QuotaCooldownFloorSeconds {
  +		auth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
  +	}
  +	if oldCfg == nil || !transientCooldownByStatusEqual(oldCfg.TransientCooldownByStatus, cfg.TransientCooldownByStatus) {
  +		auth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
  +	}
   
   	if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration {
   		log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration)
  +22 -0

internal/config/config.go
  @@ -72,6 +72,14 @@ type Config struct {
  +	// QuotaCooldownFloorSeconds is the minimum base for the quota cooldown ladder.
  +	// Sub-second Retry-After hints are never allowed below this floor. Default 1.
  +	QuotaCooldownFloorSeconds int `yaml:"quota-cooldown-floor-seconds" json:"quota-cooldown-floor-seconds"`
  +
  +	// TransientCooldownByStatus lets operators override the transient cooldown per HTTP status.
  +	// Statuses not listed fall back to TransientErrorCooldownSeconds.
  +	TransientCooldownByStatus []TransientCooldownByStatusRule `yaml:"transient-cooldown-by-status,omitempty" json:"transient-cooldown-by-status,omitempty"`
  +
   	// AuthAutoRefreshWorkers overrides the size of the core auth auto-refresh worker pool.
   	// When <= 0, the default worker count is used.
   	AuthAutoRefreshWorkers int `yaml:"auth-auto-refresh-workers" json:"auth-auto-refresh-workers"`
  +8 -0

internal/config/config_load.go
  @@ -72,6 +72,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
  +	cfg.QuotaCooldownFloorSeconds = 1
   	cfg.DisableImageGeneration = DisableImageGenerationOff
   	cfg.WebsocketAuth = true
   	cfg.Pprof.Enable = false
  +1 -0

internal/config/config_types.go
  @@ -8,6 +8,16 @@ import (
  +// TransientCooldownByStatusRule overrides the transient cooldown duration for a single HTTP status.
  +// Statuses not listed fall back to the global TransientErrorCooldownSeconds.
  +type TransientCooldownByStatusRule struct {
  +	// Status is the HTTP status code to match (e.g. 408, 500, 502, 503, 504).
  +	Status int `yaml:"status" json:"status"`
  +	// CooldownSeconds is the cooldown applied when this status is seen.
  +	// 0 keeps the legacy default for this status; negative values disable the cooldown.
  +	CooldownSeconds int `yaml:"cooldown-seconds" json:"cooldown-seconds"`
  +}
  +
   // RequestScopedErrorRule configures custom classification and handling for upstream errors.
   type RequestScopedErrorRule struct {
   	// Status matches the HTTP status code of the upstream response (e.g. 400).
  +10 -0

internal/config/cooldown_config_test.go
  @@ -0,0 +1,60 @@
  +package config
  +
  +import "testing"
  +
  +func TestCooldownConfigDefaults(t *testing.T) {
  +	data := []byte(`
  +host: "127.0.0.1"
  +port: 8080
  +`)
  +	cfg, err := ParseConfigBytes(data)
  +	if err != nil {
  +		t.Fatalf("parse config: %v", err)
  +	}
  +	if cfg.TransientErrorCooldownSeconds != 0 {
  +		t.Fatalf("TransientErrorCooldownSeconds default = %d, want 0", cfg.TransientErrorCooldownSeconds)
  +	}
  +	if cfg.QuotaCooldownFloorSeconds != 1 {
  +		t.Fatalf("QuotaCooldownFloorSeconds default = %d, want 1", cfg.QuotaCooldownFloorSeconds)
  +	}
  +	if cfg.TransientCooldownByStatus != nil {
  +		t.Fatalf("TransientCooldownByStatus default = %v, want nil", cfg.TransientCooldownByStatus)
  +	}
  +}
  +
  +func TestCooldownConfigParse(t *testing.T) {
  +	data := []byte(`
  +host: "127.0.0.1"
  +port: 8080
  +transient-error-cooldown-seconds: 10
  +quota-cooldown-floor-seconds: 5
  +transient-cooldown-by-status:
  +  - status: 408
  +    cooldown-seconds: 2
  +  - status: 503
  +    cooldown-seconds: 15
  +`)
  +	cfg, err := ParseConfigBytes(data)
  +	if err != nil {
  +		t.Fatalf("parse config: %v", err)
  +	}
  +	if cfg.TransientErrorCooldownSeconds != 10 {
  +		t.Fatalf("TransientErrorCooldownSeconds = %d, want 10", cfg.TransientErrorCooldownSeconds)
  +	}
  +	if cfg.QuotaCooldownFloorSeconds != 5 {
  +		t.Fatalf("QuotaCooldownFloorSeconds = %d, want 5", cfg.QuotaCooldownFloorSeconds)
  +	}
  +	if len(cfg.TransientCooldownByStatus) != 2 {
  +		t.Fatalf("TransientCooldownByStatus len = %d, want 2", len(cfg.TransientCooldownByStatus))
  +	}
  +	found := map[int]int{}
  +	for _, r := range cfg.TransientCooldownByStatus {
  +		found[r.Status] = r.CooldownSeconds
  +	}
  +	if found[408] != 2 {
  +		t.Fatalf("status 408 cooldown = %d, want 2", found[408])
  +	}
  +	if found[503] != 15 {
  +		t.Fatalf("status 503 cooldown = %d, want 15", found[503])
  +	}
  +}
  +60 -0

internal/config/parse.go
  @@ -31,6 +31,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
  +	cfg.QuotaCooldownFloorSeconds = 1
   	cfg.DisableImageGeneration = DisableImageGenerationOff
   	cfg.WebsocketAuth = true
   	cfg.Pprof.Enable = false
  +1 -0

sdk/cliproxy/auth/conductor_cooldown.go
  @@ -23,6 +23,8 @@ import (
  +var quotaCooldownFloorSeconds atomic.Int64
  +var transientCooldownByStatus atomic.Value
   
   // SetQuotaCooldownDisabled toggles auth/model cooldown scheduling globally.
   func SetQuotaCooldownDisabled(disable bool) {
  @@ -35,6 +37,37 @@ func SetTransientErrorCooldownSeconds(seconds int) {
  +// SetQuotaCooldownFloorSeconds sets the minimum base for the quota cooldown ladder.
  +// Sub-second Retry-After hints are never allowed below this floor. Default 1 second.
  +func SetQuotaCooldownFloorSeconds(seconds int) {
  +	if seconds <= 0 {
  +		seconds = 1
  +	}
  +	quotaCooldownFloorSeconds.Store(int64(seconds))
  +}
  +
  +// SetTransientCooldownByStatus configures per-status transient cooldown overrides.
  +// Statuses missing from the map fall back to SetTransientErrorCooldownSeconds.
  +func SetTransientCooldownByStatus(rules []internalconfig.TransientCooldownByStatusRule) {
  +	m := make(map[int]int, len(rules))
  +	for _, r := range rules {
  +		m[r.Status] = r.CooldownSeconds
  +	}
  +	transientCooldownByStatus.Store(m)
  +}
  +
  +func transientCooldownSecondsForStatus(status int) int {
  +	v := transientCooldownByStatus.Load()
  +	if v == nil {
  +		return 0
  +	}
  +	m, ok := v.(map[int]int)
  +	if !ok {
  +		return 0
  +	}
  +	return m[status]
  +}
  +
   func quotaCooldownDisabledForAuth(auth *Auth) bool {
   	return quotaCooldownDisabledForAuthWithConfig(auth, nil)
   }
  @@ -85,8 +118,11 @@ func providerCoolingOverrideForAuth(auth *Auth, cfg *internalconfig.Config) (boo
  -func nextTransientErrorRetryAfter(now time.Time) time.Time {
  +func nextTransientErrorRetryAfter(now time.Time, status int) time.Time {
   	seconds := transientErrorCooldownSeconds.Load()
  +	if perStatus := transientCooldownSecondsForStatus(status); perStatus != 0 {
  +		seconds = int64(perStatus)
  +	}
   	if seconds < 0 {
   		return time.Time{}
   	}
  @@ -96,11 +132,11 @@ func nextTransientErrorRetryAfter(now time.Time) time.Time {
  -func recoverableFailureRetryAfter(now time.Time, disableCooling bool) time.Time {
  +func recoverableFailureRetryAfter(now time.Time, status int, disableCooling bool) time.Time {
   	if disableCooling {
   		return time.Time{}
   	}
  -	return nextTransientErrorRetryAfter(now)
  +	return nextTransientErrorRetryAfter(now, status)
   }
   
   // SetConfig updates the runtime config snapshot used by request-time helpers.
  @@ -876,10 +912,10 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
  -							state.NextRetryAfter = recoverableFailureRetryAfter(now, disableCooling)
  +							state.NextRetryAfter = recoverableFailureRetryAfter(now, statusCode, disableCooling)
   							state.Unavailable = !state.NextRetryAfter.IsZero()
   						default:
  -							state.NextRetryAfter = recoverableFailureRetryAfter(now, disableCooling)
  +							state.NextRetryAfter = recoverableFailureRetryAfter(now, statusCode, disableCooling)
   							state.Unavailable = !state.NextRetryAfter.IsZero()
   						}
   					}
  @@ -1953,13 +1989,13 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
  -		auth.NextRetryAfter = recoverableFailureRetryAfter(now, disableCooling)
  +		auth.NextRetryAfter = recoverableFailureRetryAfter(now, statusCode, disableCooling)
   		auth.Unavailable = !auth.NextRetryAfter.IsZero()
   	default:
   		if auth.StatusMessage == "" {
   			auth.StatusMessage = "request failed"
   		}
  -		auth.NextRetryAfter = recoverableFailureRetryAfter(now, disableCooling)
  +		auth.NextRetryAfter = recoverableFailureRetryAfter(now, statusCode, disableCooling)
   		auth.Unavailable = !auth.NextRetryAfter.IsZero()
   	}
   	if resultErr != nil && resultErr.Code == ErrorCodeForceCooldown && auth.NextRetryAfter.IsZero() {
  @@ -1993,9 +2029,13 @@ func nextQuotaCooldown(prevLevel int, disableCooling bool) (time.Duration, int)
  -	cooldown := quotaBackoffBase * time.Duration(1<<prevLevel)
  -	if cooldown < quotaBackoffBase {
  -		cooldown = quotaBackoffBase
  +	base := time.Duration(quotaCooldownFloorSeconds.Load()) * time.Second
  +	if base <= 0 {
  +		base = quotaBackoffBase
  +	}
  +	cooldown := base * time.Duration(1<<prevLevel)
  +	if cooldown < base {
  +		cooldown = base
   	}
   	if cooldown >= quotaBackoffMax {
   		return quotaBackoffMax, prevLevel
  +50 -10

sdk/cliproxy/auth/cooldown_backoff_test.go
  @@ -6,6 +6,7 @@ import (
  +	internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
   	"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
   	cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
   )
  @@ -308,3 +309,45 @@ func TestJitteredCooldownWaitBounds(t *testing.T) {
  +
  +func TestQuotaCooldownFloorSecondsConfiguresLadderBase(t *testing.T) {
  +	prev := quotaCooldownFloorSeconds.Load()
  +	quotaCooldownFloorSeconds.Store(5)
  +	t.Cleanup(func() { quotaCooldownFloorSeconds.Store(prev) })
  +
  +	cooldown, level := nextQuotaCooldown(0, false)
  +	if cooldown != 5*time.Second {
  +		t.Fatalf("level 0 cooldown with floor 5 = %v, want 5s", cooldown)
  +	}
  +	if level != 1 {
  +		t.Fatalf("level = %d, want 1", level)
  +	}
  +
  +	cooldown, level = nextQuotaCooldown(1, false)
  +	if cooldown != 10*time.Second {
  +		t.Fatalf("level 1 cooldown with floor 5 = %v, want 10s", cooldown)
  +	}
  +}
  +
  +func TestNextTransientErrorRetryAfterRespectsPerStatusOverride(t *testing.T) {
  +	prevGlobal := transientErrorCooldownSeconds.Load()
  +	transientErrorCooldownSeconds.Store(10)
  +	t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevGlobal) })
  +
  +	SetTransientCooldownByStatus([]internalconfig.TransientCooldownByStatusRule{
  +		{Status: 408, CooldownSeconds: 2},
  +		{Status: 503, CooldownSeconds: -1},
  +	})
  +	t.Cleanup(func() { SetTransientCooldownByStatus(nil) })
  +
  +	now := time.Now()
  +	if got := nextTransientErrorRetryAfter(now, 408); got.Sub(now) != 2*time.Second {
  +		t.Fatalf("status 408 cooldown = %v, want 2s", got.Sub(now))
  +	}
  +	if got := nextTransientErrorRetryAfter(now, 503); !got.IsZero() {
  +		t.Fatalf("status 503 should be disabled, got %v", got)
  +	}
  +	if got := nextTransientErrorRetryAfter(now, 504); got.Sub(now) != 10*time.Second {
  +		t.Fatalf("status 504 fallback cooldown = %v, want 10s", got.Sub(now))
  +	}
  +}
  +43 -0

sdk/cliproxy/service_auth.go
  @@ -361,6 +361,8 @@ func (s *Service) applyRetryConfig(cfg *config.Config) {
  +	coreauth.SetQuotaCooldownFloorSeconds(cfg.QuotaCooldownFloorSeconds)
  +	coreauth.SetTransientCooldownByStatus(cfg.TransientCooldownByStatus)
   }
   
   func (s *Service) configureCooldownStateStore(cfg *config.Config) {
  +2 -0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions