Files
Cyrene_v2/plugins/channels/qq_obv11/reconnect.go
T
AskaEth 8c437866e7
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq-obv11): client 模式首连失败不再致命(复用既有重连循环 + 指数退避)
痛点: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/**,本切片禁碰)。
2026-10-05 10:12:23 +08:00

130 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}