feat(qq): make stability parameters configurable
Adds optional stability configuration fields to QQConfig: - reconnect_initial_ms / reconnect_max_ms: WebSocket reconnection intervals - max_retries / retry_initial_delay_ms / retry_max_delay_ms: API retry parameters - rate_limit_group_ms / rate_limit_direct_ms: Per-chat rate limit intervals All values are optional and fall back to sensible hardcoded defaults. Adds 7 new unit tests covering config-driven parameter methods.
This commit is contained in:
parent
0195ccfd38
commit
d25abb59a4
4 changed files with 197 additions and 22 deletions
|
|
@ -97,7 +97,15 @@
|
|||
"app_id": "YOUR_QQ_APP_ID",
|
||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
"reasoning_channel_id": "",
|
||||
"_comment_stability": "Stability parameters (all optional with sensible defaults)",
|
||||
"reconnect_initial_ms": 5000,
|
||||
"reconnect_max_ms": 300000,
|
||||
"max_retries": 3,
|
||||
"retry_initial_delay_ms": 500,
|
||||
"retry_max_delay_ms": 10000,
|
||||
"rate_limit_group_ms": 500,
|
||||
"rate_limit_direct_ms": 200
|
||||
},
|
||||
"maixcam": {
|
||||
"enabled": false,
|
||||
|
|
|
|||
|
|
@ -42,15 +42,19 @@ const (
|
|||
typingSeconds = 10
|
||||
bytesPerMiB = 1024 * 1024
|
||||
|
||||
// Reconnection constants
|
||||
reconnectInitial = 5 * time.Second
|
||||
reconnectMax = 5 * time.Minute
|
||||
// Reconnection constants (used as defaults when config values are 0)
|
||||
reconnectInitialDefault = 5 * time.Second
|
||||
reconnectMaxDefault = 5 * time.Minute
|
||||
reconnectMultiplier = 2.0
|
||||
|
||||
// Retry constants
|
||||
maxRetries = 3
|
||||
retryInitialDelay = 500 * time.Millisecond
|
||||
retryMaxDelay = 10 * time.Second
|
||||
// Retry constants (used as defaults when config values are 0)
|
||||
maxRetriesDefault = 3
|
||||
retryInitialDelayDefault = 500 * time.Millisecond
|
||||
retryMaxDelayDefault = 10 * time.Second
|
||||
|
||||
// Rate limit defaults
|
||||
rateLimitGroupDefault = 500 * time.Millisecond
|
||||
rateLimitDirectDefault = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
type qqAPI interface {
|
||||
|
|
@ -108,13 +112,23 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
|||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
// Use config values with defaults for rate limiters
|
||||
groupRateMs := cfg.RateLimitGroupMs
|
||||
if groupRateMs <= 0 {
|
||||
groupRateMs = int(rateLimitGroupDefault / time.Millisecond)
|
||||
}
|
||||
directRateMs := cfg.RateLimitDirectMs
|
||||
if directRateMs <= 0 {
|
||||
directRateMs = int(rateLimitDirectDefault / time.Millisecond)
|
||||
}
|
||||
|
||||
return &QQChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
dedup: make(map[string]time.Time),
|
||||
done: make(chan struct{}),
|
||||
groupRateLimiter: newRateLimiter(500 * time.Millisecond), // 20 msg/min with headroom
|
||||
directRateLimiter: newRateLimiter(200 * time.Millisecond), // 5 msg/sec with headroom
|
||||
groupRateLimiter: newRateLimiter(time.Duration(groupRateMs) * time.Millisecond),
|
||||
directRateLimiter: newRateLimiter(time.Duration(directRateMs) * time.Millisecond),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -250,10 +264,11 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
}
|
||||
|
||||
// Route to group or C2C with retry.
|
||||
maxRetries := c.maxRetries()
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := calculateBackoff(attempt - 1)
|
||||
backoff := c.calculateBackoff(attempt - 1)
|
||||
logger.InfoCF("qq", "Retrying send", map[string]any{
|
||||
"attempt": attempt,
|
||||
"chat_id": msg.ChatID,
|
||||
|
|
@ -1030,7 +1045,7 @@ func (c *QQChannel) reconnect() {
|
|||
c.reconnectMu.Unlock()
|
||||
}()
|
||||
|
||||
backoff := reconnectInitial
|
||||
backoff := c.reconnectInitial()
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
|
|
@ -1064,6 +1079,7 @@ func (c *QQChannel) reconnect() {
|
|||
case <-c.stopReconnect:
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
reconnectMax := c.reconnectMax()
|
||||
if backoff < reconnectMax {
|
||||
backoff = time.Duration(float64(backoff) * reconnectMultiplier)
|
||||
if backoff > reconnectMax {
|
||||
|
|
@ -1123,10 +1139,52 @@ func isRetryableError(err error) bool {
|
|||
}
|
||||
|
||||
// calculateBackoff returns the next backoff duration with exponential increase.
|
||||
func calculateBackoff(attempt int) time.Duration {
|
||||
backoff := retryInitialDelay * time.Duration(1<<uint(attempt))
|
||||
if backoff > retryMaxDelay {
|
||||
backoff = retryMaxDelay
|
||||
func (c *QQChannel) calculateBackoff(attempt int) time.Duration {
|
||||
initialDelay := c.retryInitialDelay()
|
||||
maxDelay := c.retryMaxDelay()
|
||||
backoff := initialDelay * time.Duration(1<<uint(attempt))
|
||||
if backoff > maxDelay {
|
||||
backoff = maxDelay
|
||||
}
|
||||
return backoff
|
||||
}
|
||||
|
||||
// reconnectInitial returns the configured or default initial reconnect interval.
|
||||
func (c *QQChannel) reconnectInitial() time.Duration {
|
||||
if c.config.ReconnectInitialMs > 0 {
|
||||
return time.Duration(c.config.ReconnectInitialMs) * time.Millisecond
|
||||
}
|
||||
return reconnectInitialDefault
|
||||
}
|
||||
|
||||
// reconnectMax returns the configured or default max reconnect interval.
|
||||
func (c *QQChannel) reconnectMax() time.Duration {
|
||||
if c.config.ReconnectMaxMs > 0 {
|
||||
return time.Duration(c.config.ReconnectMaxMs) * time.Millisecond
|
||||
}
|
||||
return reconnectMaxDefault
|
||||
}
|
||||
|
||||
// maxRetries returns the configured or default max retry count.
|
||||
func (c *QQChannel) maxRetries() int {
|
||||
if c.config.MaxRetries > 0 {
|
||||
return c.config.MaxRetries
|
||||
}
|
||||
return maxRetriesDefault
|
||||
}
|
||||
|
||||
// retryInitialDelay returns the configured or default initial retry delay.
|
||||
func (c *QQChannel) retryInitialDelay() time.Duration {
|
||||
if c.config.RetryInitialDelayMs > 0 {
|
||||
return time.Duration(c.config.RetryInitialDelayMs) * time.Millisecond
|
||||
}
|
||||
return retryInitialDelayDefault
|
||||
}
|
||||
|
||||
// retryMaxDelay returns the configured or default max retry delay.
|
||||
func (c *QQChannel) retryMaxDelay() time.Duration {
|
||||
if c.config.RetryMaxDelayMs > 0 {
|
||||
return time.Duration(c.config.RetryMaxDelayMs) * time.Millisecond
|
||||
}
|
||||
return retryMaxDelayDefault
|
||||
}
|
||||
|
|
|
|||
|
|
@ -374,7 +374,8 @@ func TestCalculateBackoff(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
result := calculateBackoff(tt.attempt)
|
||||
ch := newTestQQChannel(t, &mockQQAPI{})
|
||||
result := ch.calculateBackoff(tt.attempt)
|
||||
if result != tt.expected {
|
||||
t.Errorf("calculateBackoff(%d) = %v, want %v", tt.attempt, result, tt.expected)
|
||||
}
|
||||
|
|
@ -427,6 +428,105 @@ func TestQQChannel_ResetSessionState_ReasoningChannel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestQQChannel_ConfigDrivenParams tests that config values override defaults.
|
||||
func TestQQChannel_ConfigDrivenParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*config.QQConfig)
|
||||
check func(*testing.T, *QQChannel)
|
||||
}{
|
||||
{
|
||||
name: "reconnect initial uses config when set",
|
||||
setup: func(cfg *config.QQConfig) { cfg.ReconnectInitialMs = 10000 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.reconnectInitial(); got != 10*time.Second {
|
||||
t.Errorf("reconnectInitial() = %v, want 10s", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reconnect initial falls back to default when zero",
|
||||
setup: func(cfg *config.QQConfig) { cfg.ReconnectInitialMs = 0 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.reconnectInitial(); got != 5*time.Second {
|
||||
t.Errorf("reconnectInitial() = %v, want 5s", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reconnect max uses config when set",
|
||||
setup: func(cfg *config.QQConfig) { cfg.ReconnectMaxMs = 60000 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.reconnectMax(); got != 60*time.Second {
|
||||
t.Errorf("reconnectMax() = %v, want 60s", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max retries uses config when set",
|
||||
setup: func(cfg *config.QQConfig) { cfg.MaxRetries = 5 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.maxRetries(); got != 5 {
|
||||
t.Errorf("maxRetries() = %v, want 5", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max retries falls back to default when zero",
|
||||
setup: func(cfg *config.QQConfig) { cfg.MaxRetries = 0 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.maxRetries(); got != 3 {
|
||||
t.Errorf("maxRetries() = %v, want 3", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "retry max delay uses config when set",
|
||||
setup: func(cfg *config.QQConfig) { cfg.RetryMaxDelayMs = 30000 },
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
if got := ch.retryMaxDelay(); got != 30*time.Second {
|
||||
t.Errorf("retryMaxDelay() = %v, want 30s", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate limiters use config values",
|
||||
setup: func(cfg *config.QQConfig) {
|
||||
cfg.RateLimitGroupMs = 1000
|
||||
cfg.RateLimitDirectMs = 500
|
||||
},
|
||||
check: func(t *testing.T, ch *QQChannel) {
|
||||
// Verify rate limiters are created with config values
|
||||
start := time.Now()
|
||||
ch.groupRateLimiter.wait("test-chat")
|
||||
ch.groupRateLimiter.wait("test-chat")
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < 900*time.Millisecond {
|
||||
t.Errorf("group rate limiter did not delay ~1s, got %v", elapsed)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.QQConfig{AppID: "test", AppSecret: "test"}
|
||||
tt.setup(&cfg)
|
||||
// Rate limiter test needs NewQQChannel to initialize rate limiters
|
||||
if tt.name == "rate limiters use config values" {
|
||||
ch, err := NewQQChannel(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewQQChannel failed: %v", err)
|
||||
}
|
||||
tt.check(t, ch)
|
||||
return
|
||||
}
|
||||
ch := &QQChannel{config: cfg}
|
||||
tt.check(t, ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify mockQQAPI implements qqAPI interface at compile time
|
||||
var _ qqAPI = (*mockQQAPI)(nil)
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,15 @@ type QQConfig struct {
|
|||
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
|
||||
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||
|
||||
// Stability config (all optional with sensible defaults)
|
||||
ReconnectInitialMs int `json:"reconnect_initial_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RECONNECT_INITIAL_MS"`
|
||||
ReconnectMaxMs int `json:"reconnect_max_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RECONNECT_MAX_MS"`
|
||||
MaxRetries int `json:"max_retries,omitempty" env:"PICOCLAW_CHANNELS_QQ_MAX_RETRIES"`
|
||||
RetryInitialDelayMs int `json:"retry_initial_delay_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RETRY_INITIAL_DELAY_MS"`
|
||||
RetryMaxDelayMs int `json:"retry_max_delay_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RETRY_MAX_DELAY_MS"`
|
||||
RateLimitGroupMs int `json:"rate_limit_group_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RATE_LIMIT_GROUP_MS"`
|
||||
RateLimitDirectMs int `json:"rate_limit_direct_ms,omitempty" env:"PICOCLAW_CHANNELS_QQ_RATE_LIMIT_DIRECT_MS"`
|
||||
}
|
||||
|
||||
type DingTalkConfig struct {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue