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

+37 -1
View File
@@ -4,6 +4,9 @@ package qq_obv11
// (Cyrene→OneBot),主动拨号 OneBot 实现的「正向 WS」端点(remote_url),建立后
// 收发都走这条长连接。access_token 同时以 Authorization: Bearer 头与
// ?access_token= 查询参数发送,兼容只认其中一种的 OneBot 实现。
//
// 首连失败**不致命**:对面(NapCat 等)可能比 Cyrene 后启动,失败只记 WARN,重试交给
// readLoop 的同一条重连路径(指数退避 + 抖动 + 上限,见 reconnect.go)。
import (
"context"
@@ -13,17 +16,50 @@ import (
"strings"
)
// errClientConfig 是 client 模式配置缺失(remote_url 为空)的错误哨兵:重试治不好
// 配置错,必须让 Connect 直接失败(渠道桥接器的凭据门同样要求 remoteURL 非空,见
// adapter.go 的 configRemoteURL)。
var errClientConfig = errors.New("qq-obv11: client mode requires remoteURL")
// connectFirstClient 做一次 ws+client 首连(Connect 同步调用)。
//
// 为什么失败不致命:OneBot 实现(NapCat 等)可能比 Cyrene 后启动,或者正在重启——
// 若把首连失败当作渠道起不来,部署顺序就成了硬约束。因此可重试的失败只记一条 WARN
// 就返回 nil,真正的重试由 readLoop 复用同一条重连路径(指数退避 + 抖动 + 上限)
// 在后台进行。只有配置缺失(errClientConfig)才致命:重试治不好配置错,渠道桥接器
// 的凭据门也要求 remote_url 非空。
func (a *Adapter) connectFirstClient(ctx context.Context) error {
// 新一轮启动:退避从初值起步,不继承上一轮会话残留的失败计数。
a.reconnect.reset()
err := a.connectClient(ctx)
switch {
case err == nil:
return nil
case errors.Is(err, errClientConfig):
return err
default:
a.noteConnectFailure(err)
return nil
}
}
// connectClient dials the configured OneBot WebSocket endpoint and installs the
// resulting connection as the active session.
func (a *Adapter) connectClient(ctx context.Context) error {
if a.cfg.RemoteURL == "" {
return errors.New("qq-obv11: client mode requires remoteURL")
return errClientConfig
}
target := appendQueryToken(a.cfg.RemoteURL, a.cfg.AccessToken)
conn, err := dialWS(ctx, a.cfg.DialContext, target, a.cfg.AccessToken)
if err != nil {
return fmt.Errorf("qq-obv11: dial onebot ws %s: %w", a.cfg.RemoteURL, err)
}
// 拨号期间可能已被 Disconnect / ctx 取消:此时绝不把连接挂成「已连接」,
// 否则状态撒谎,而且这条连接没有读循环会去消费它。
if cerr := ctx.Err(); cerr != nil {
_ = conn.Close()
return fmt.Errorf("qq-obv11: dial onebot ws %s: %w", a.cfg.RemoteURL, cerr)
}
a.setConn(conn)
a.log.Info("connected to onebot ws (client mode)", "url", a.cfg.RemoteURL)
return nil