feat(qq-obv11): client 模式首连失败不再致命(复用既有重连循环 + 指数退避)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

痛点: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/**,本切片禁碰)。
This commit is contained in:
AskaEth committed 2026-10-05 10:12:23 +08:00
1 parent d804c4e675
commit 8c437866e7
6 files changed
+698 -10

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
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)
}