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
Summary
Adds two missing cooldown knobs from
reports/recommended-config.md:quota-cooldown-floor-seconds(default1): makes the quota cooldown ladder base configurable and floors sub-secondRetry-Afterhints.transient-cooldown-by-status: per-HTTP-status overrides for408/500/502/503/504cooldowns, falling back totransient-error-cooldown-secondsfor any status not listed.This patch also incorporates the #5130 (quota ladder floor) refactor because
quota-cooldown-floor-secondslives 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.goconfig.example.yamlsdk/cliproxy/auth/cooldown_backoff_test.goandinternal/config/cooldown_config_test.goPatch
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