package qq_obv11 // 本文件实现 **ws + client**(Cyrene 主动拨号 OneBot 正向 WS)的首连/重连退避策略。 // // 为什么需要它:NapCat 等 OneBot 实现可能比 Cyrene 后启动(或中途重启),若首连失败 // 就判定渠道起不来,「谁先起来」就成了部署顺序的硬约束。因此首连失败必须非致命: // 交给后台重试循环按指数退避继续拨号——退避带抖动(多实例同时重连时错开)且有上限 // (对面长期不在线时按固定上限间隔探测,不做无界增长)。连接状态始终以真实连接为准: // 没连上就是 connected=false,绝不假装已连(面板与 HealthCheck 都照此判断)。 // // 设计依据:DevDocs/design/08-渠道接入.md §4 与 AGENTS §9——client 模式不开监听端口, // 重试只发生在出站拨号上。 // // 既有实现对照(AGENTS §9.5):项目已有 `internal/channel/adapter/wsutil.Backoff` // (2s 起步、×2、60s 封顶的网关重连调度器,KOOK 用;design/08 §95 计划让各 WS 平台共用 // 这套重连基座)。本包**口径与之对齐**(同样 2s/60s),但不直接 import 它,理由有三: // ① 本需求要 ±抖动,wsutil.Backoff 没有这个能力;② import wsutil 会把 gorilla/websocket // 拖进本包依赖图,而本包刻意只用标准库携带 WS 帧编解码(见 websocket.go 抬头); // ③ 真正收敛的做法是给 wsutil.Backoff 加可选抖动再迁走,那要改 internal/**,属后续工作。 import ( "math/rand/v2" "sync" "time" ) const ( // reconnectBaseDelay 是重试等待时长的初值(第一次失败后按因子翻倍)。取值与 // wsutil.Backoff 的网关重连口径一致(2s 起步、60s 封顶)。 reconnectBaseDelay = 2 * time.Second // reconnectMaxDelay 是退避上限:到顶后按固定间隔继续探测,不再增长。 reconnectMaxDelay = 60 * time.Second // reconnectBackoffFactor 是每多一次连续失败,等待时长的放大倍数。 reconnectBackoffFactor = 2 // reconnectMaxDoublings 限制翻倍次数,避免移位/乘法溢出(结果随后仍被上限裁剪)。 reconnectMaxDoublings = 6 // reconnectJitterRatio 是抖动幅度(±25%),让多实例的重连打点错开。 reconnectJitterRatio = 0.25 ) // reconnectPolicy 是退避参数的可注入载体:生产用 defaultReconnectPolicy()(由上面的 // 具名常量构造),测试替换成毫秒级参数即可确定性验证重试,不必真等秒级退避。 type reconnectPolicy struct { baseDelay time.Duration maxDelay time.Duration factor int maxDoublings int jitterRatio float64 } // defaultReconnectPolicy 返回生产退避参数。 func defaultReconnectPolicy() reconnectPolicy { return reconnectPolicy{ baseDelay: reconnectBaseDelay, maxDelay: reconnectMaxDelay, factor: reconnectBackoffFactor, maxDoublings: reconnectMaxDoublings, jitterRatio: reconnectJitterRatio, } } // delayFor 返回「已连续失败 failures 次」之后应等待的时长: // baseDelay × factor^failures,叠加 ±jitterRatio 抖动后裁剪到 [0, maxDelay]。 // 指数项先按 maxDoublings 收敛再来抖动、最后裁上限,因此退避有界、绝不无限增长。 func (p reconnectPolicy) delayFor(failures int) time.Duration { steps := failures if steps < 0 { steps = 0 } if steps > p.maxDoublings { steps = p.maxDoublings } d := p.baseDelay for i := 0; i < steps; i++ { d *= time.Duration(p.factor) } if p.jitterRatio > 0 { d = time.Duration(float64(d) * (1 + jitterOffset(p.jitterRatio))) } if d > p.maxDelay { d = p.maxDelay } if d < 0 { d = 0 } return d } // jitterOffset 返回 [-ratio, +ratio] 上的均匀随机偏移。 func jitterOffset(ratio float64) float64 { return (rand.Float64()*2 - 1) * ratio } // reconnectState 是 client 模式的连续失败计数器。Connect 的首连失败与 readLoop 的 // 重连失败共享同一份状态,因此退避在一轮启动内连续;成功连上即清零。 type reconnectState struct { mu sync.Mutex failures int } // fail 记一次失败并返回下次重试前应等待的时长(该值就是要写进日志的 retryIn)。 func (s *reconnectState) fail(p reconnectPolicy) time.Duration { s.mu.Lock() defer s.mu.Unlock() s.failures++ return p.delayFor(s.failures) } // wait 返回当前应等待的时长(不推进计数):与上一次失败记录的 retryIn 一致, // 保证日志里承诺的间隔就是下一次真正拨号前的等待。 func (s *reconnectState) wait(p reconnectPolicy) time.Duration { s.mu.Lock() defer s.mu.Unlock() return p.delayFor(s.failures) } // reset 清零连续失败计数(成功连上或重新 Connect 时调用)。 func (s *reconnectState) reset() { s.mu.Lock() defer s.mu.Unlock() s.failures = 0 } // noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的 // WARN(含 err 与下次重试间隔)。access_token 绝不入日志。 func (a *Adapter) noteConnectFailure(err error) { delay := a.reconnect.fail(a.policy) a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay) }