Files
2026-07-13 13:00:08 +08:00

118 lines
3.5 KiB
Go

package bot
import (
"context"
"log/slog"
"time"
)
// This file holds the shared connection-lifecycle primitives for the platform
// adapters (qq / feishu / weixin). Each adapter previously hand-rolled the same
// "loop forever, attempt a connection, sleep a fixed 5s on failure" shape; two of
// them used time.Sleep, which ignores ctx and so left a Stop() blocked for the
// remaining delay. SleepCtx fixes that; RunWithRetry folds the persistent-
// connection reconnect loop (qq / feishu) into one cancellation-aware,
// exponentially-backing-off helper.
const (
defaultInitialDelay = 1 * time.Second
defaultMaxDelay = 30 * time.Second
defaultResetAfter = 60 * time.Second
)
// RetryConfig controls the reconnect/error backoff for RunWithRetry. The zero
// value uses sane defaults (1s → 30s exponential, reset after 60s up).
type RetryConfig struct {
// InitialDelay is the wait after the first failed/closed attempt.
InitialDelay time.Duration
// MaxDelay caps the exponential backoff.
MaxDelay time.Duration
// ResetAfter is the minimum time an attempt must stay connected for the
// backoff to reset to InitialDelay on its next failure — so a flaky reconnect
// escalates while a long-healthy connection that finally drops retries fast.
ResetAfter time.Duration
}
func (c RetryConfig) withDefaults() RetryConfig {
if c.InitialDelay <= 0 {
c.InitialDelay = defaultInitialDelay
}
if c.MaxDelay <= 0 {
c.MaxDelay = defaultMaxDelay
}
if c.MaxDelay < c.InitialDelay {
c.MaxDelay = c.InitialDelay
}
if c.ResetAfter <= 0 {
c.ResetAfter = defaultResetAfter
}
return c
}
// SleepCtx waits for d or until ctx is cancelled, whichever comes first. It
// returns true if the full duration elapsed and false if ctx was cancelled (or
// was already cancelled on entry). Adapter loops use it instead of time.Sleep so
// a Stop() takes effect promptly rather than blocking out the remaining delay.
func SleepCtx(ctx context.Context, d time.Duration) bool {
if ctx.Err() != nil {
return false
}
if d <= 0 {
return true
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// nextDelay doubles cur, capped at max. The <= 0 guard catches overflow on very
// large durations.
func nextDelay(cur, max time.Duration) time.Duration {
next := cur * 2
if next <= 0 || next > max {
next = max
}
return next
}
// RunWithRetry drives a persistent-connection adapter. It calls attempt(ctx) —
// one full connection lifetime that blocks until the connection drops or errors —
// then waits a cancellation-aware exponential backoff and reconnects, repeating
// until ctx is cancelled. attempt MUST honor ctx and clean up its own connection
// before returning; it returns nil for a clean close and an error otherwise (both
// trigger a reconnect — only ctx cancellation stops the loop).
func RunWithRetry(ctx context.Context, log *slog.Logger, name string, cfg RetryConfig, attempt func(context.Context) error) {
if log == nil {
log = slog.Default()
}
cfg = cfg.withDefaults()
delay := cfg.InitialDelay
for {
if ctx.Err() != nil {
return
}
start := time.Now()
err := attempt(ctx)
if ctx.Err() != nil {
return
}
if err != nil {
log.Error(name+" connection failed", "err", err)
} else {
log.Warn(name + " connection closed")
}
if time.Since(start) >= cfg.ResetAfter {
delay = cfg.InitialDelay
}
if !SleepCtx(ctx, delay) {
return
}
delay = nextDelay(delay, cfg.MaxDelay)
}
}