痛点: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/**,本切片禁碰)。
80 lines
3.1 KiB
Go
80 lines
3.1 KiB
Go
package qq_obv11
|
||
|
||
// 本文件实现四个端里的 **ws + client**(裁决 1.4):连接由 Cyrene 发起
|
||
// (Cyrene→OneBot),主动拨号 OneBot 实现的「正向 WS」端点(remote_url),建立后
|
||
// 收发都走这条长连接。access_token 同时以 Authorization: Bearer 头与
|
||
// ?access_token= 查询参数发送,兼容只认其中一种的 OneBot 实现。
|
||
//
|
||
// 首连失败**不致命**:对面(NapCat 等)可能比 Cyrene 后启动,失败只记 WARN,重试交给
|
||
// readLoop 的同一条重连路径(指数退避 + 抖动 + 上限,见 reconnect.go)。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"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 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
|
||
}
|
||
|
||
// appendQueryToken adds an access_token query parameter to a URL, preserving any
|
||
// existing query string.
|
||
func appendQueryToken(rawURL, token string) string {
|
||
if token == "" {
|
||
return rawURL
|
||
}
|
||
sep := "?"
|
||
if strings.Contains(rawURL, "?") {
|
||
sep = "&"
|
||
}
|
||
return rawURL + sep + "access_token=" + url.QueryEscape(token)
|
||
}
|