痛点:mode=client 时首次拨号失败会让 Connect 直接返回 error ⇒ 渠道起不来; 而「连上后再断」本来就有重连。⇒ 把 readLoop 的入口条件放宽为「conn==nil 也算」, **不新造第二套循环**。 新增 reconnect.go:退避 2s → ×2 → 上限 60s、抖动 ±25%(具名常量),失败计数跨 Connect 与 readLoop 共享。 ws_client:新增 connectFirstClient —— 可重试失败只记 WARN 并返回 nil; 配置缺失(remote_url 空)仍致命(闸门口径不变)。 connected 语义不许撒谎:始终由真实连接决定;HealthCheck 未连上返回 ErrNotConnected; 仅 http 传输保留「已装配即可服务」旧口径(逐字未改)。 ctx 感知等待(sleepCtx)+ 等待后复查 ctx ⇒ Disconnect 能立即中止,不泄漏 goroutine。 server 模式与 http 三端行为逐字不变(既有用例零改动全绿 + TestServerModeUnaffectedByClientRetry)。 测试:client_retry_test.go(428) + reconnect_test.go(83),用可注入 dialer + gate 通道造确定性时序。 变异 5 条(首连 fail-fast / 退避无上限 / 取消失效 / 未连上就报已连 / 抖动未接线)。 诚实边界:60s 上限对聊天渠道可能偏慢(改 30s 是一行常量);仓库内仍存第二份指数退避 (wsutil.Backoff 无抖动能力,收敛需改 internal/**,本切片禁碰)。
130 lines
5.1 KiB
Go
130 lines
5.1 KiB
Go
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)
|
||
}
|