feat(chat): W41 未连接时先探一次健康端点,通就回落 REST 发出去,不通才进队列

主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点
验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。

## 为什么先探测再决定,而不是直接排队
网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发
Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成
「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。

## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt)
1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事
   仍然是 sendChat,前面不加任何判断、不挂任何起点。
2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律
   按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走
   REST);不通 → 进队列。
3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误
   (证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。
4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。

Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的),
而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会
发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。

## 为什么重发是安全的
回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。
服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate /
duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经
收下」这种最难判的情况不需要本端猜。

## 回复怎么回来
那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp →
loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以
回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。

## 其他
- 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测
  都不发。
- 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上
  所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论
  之后。
- 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒);
  回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。
- 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、
  探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。
- devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
This commit is contained in:
AskaEth committed 2026-10-05 14:33:34 +08:00
1 parent 7b981dd88a
commit 9f77fb469c
11 files changed
+954 -58

No files matched your search

@@ -0,0 +1,294 @@
package top.yeij.cyrene.ui.chat
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.SendChannel
/**
* 「未连接时先探测、能发就发,不通才排队」这条三级阶梯的判据
* ([sendRouteFor] / [reachabilityOf] / [restFailureLanding])。
*
* ## 背景:主人 2026-10-05 的第二次定案
* W40 定的是「未连接就进队列」([SendQueueTest] 那次),它解决的是**内容不被退回给用户**。
* 这一轮的追问是「**未连接状态你就不能拿 httpAPI 尝试吗?**」,并明确为「可以走 health 端点验证
* 连通性」。理由是网关只有一条:`/ws` 与 `POST /api/v1/chat` 是同一个 `:8080` 上的两条车道,
* 握手被挡(代理不转发 Upgrade、中间设备掐长连接)时 REST 往往是通的——直接排队等于把
* 「WS 这条车道暂时不通」当成「后端不可用」,让用户白等一次重连。
*
* ## 本测试钉住五件事
* 1. **优先级**([sendRouteFor]):帧交出去了 ⇒ 走 WS(连接那一格不再参与判断);未就绪 + 探测通
* ⇒ 走 REST 回落;未就绪 + 探测不通 / 超时 / 抛异常 / 没探过 ⇒ 进队列。
* 2. **fail-closed**([reachabilityOf]):只有「探到 `true`」才算通,其余一切(含超时与异常)
* 都归「不通」——探测这一层永远不因为拿不准而放行一次真发送。
* 3. **探测必须短**:[PROBE_TIMEOUT_MILLIS] 落在 2~3 秒里(它直接加在用户的发送动作上)。
* 4. **Offline 不探测**:那一档是「会话已知是死的」,健康端点不鉴权必然回 200,探了只会把
* W40 的「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。
* 5. **回落失败后的落点**([restFailureLanding]):网络类失败(含超时)⇒ 进队列;服务端给过
* 裁决的(拒绝 / 401)与配置类错误 ⇒ 今天那条回滚 + 文案。
*/
class SendFallbackTest {
// —— 一、优先级:这一刻该走哪条路 ——
@Test
fun `a frame handed to the socket walks the ws path whatever else says`() {
// `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格与探测结论都不再参与判断
// (`sendChat` 判的就是它自己的状态)。这一档是**成功路径**,W41 一个字都不许动它——
// 所以这里连探测结论都不给它:`null` 也得是 Ws。
WsState.entries.forEach { connection ->
listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability ->
assertEquals(
"$connection 上帧已经交出去了,不该改走别的路",
SendRoute.Ws,
sendRouteFor(wsDispatched = true, connection = connection, reachability = reachability),
)
}
}
}
@Test
fun `not connected but reachable falls back to rest`() {
// WS 没把帧交出去,而探测说后端这一刻在 ⇒ 用既有的 POST /api/v1/chat 把这条发出去。
// 三档「未就绪」一视同仁:Idle / Connecting / Reconnecting 都只是「还没连上」,
// 与「后端不可达」根本不是一件事。
listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection ->
assertEquals(
"$connection 上探测通了,该回落 REST",
SendRoute.Rest,
sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable),
)
}
}
@Test
fun `not connected and unreachable queues`() {
listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection ->
assertEquals(
"$connection 上探测不通,该进队列",
SendRoute.Queue,
sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Unreachable),
)
}
}
@Test
fun `no probe result at all means queue, never a hopeful rest send`() {
// `null` = 还没探过(或判据被改坏没喂结论)。没有证据说能发,就不发:
// 这一档必须与「探测不通」同归一处,否则「拿不准」会变成一次注定失败的真发送。
listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting, WsState.Offline).forEach { connection ->
assertEquals(
"$connection 上还没有探测结论,只许进队列",
SendRoute.Queue,
sendRouteFor(wsDispatched = false, connection = connection, reachability = null),
)
}
}
// —— 二、探测的结论怎么算:fail-closed ——
@Test
fun `only a confirmed answer counts as reachable`() {
assertEquals(Reachability.Reachable, reachabilityOf(Result.success(true)))
// 后端回了话但非 2xx / 回执解析不出来:仓库层折成 `false` 或 `failure`,都不是通。
assertEquals(Reachability.Unreachable, reachabilityOf(Result.success(false)))
}
@Test
fun `a timing out probe is not reachable`() {
// 硬要求:探测超时一律按「不通」处理(fail-closed 到队列)——**绝不许**把用户的发送
// 卡在一个等不到头的探测上,也绝不许因为「说不定通」就先发一次 REST。
assertEquals(
Reachability.Unreachable,
reachabilityOf(Result.failure(SocketTimeoutException("call timeout"))),
)
}
@Test
fun `a probe that throws is not reachable, whatever it threw`() {
// 连不上 / DNS / 别的任何异常:同一档。探测这一层**永远不抛异常给调用方**——
// 它要回答的是一个 yes/no,抛出去只会让「这条消息去哪」这件事没有答案。
val thrown = listOf(
ConnectException("connection refused"),
UnknownHostException("cyrene.invalid"),
IllegalStateException("boom"),
)
thrown.forEach { e ->
assertEquals("$e 不该被当成通", Reachability.Unreachable, reachabilityOf(Result.failure(e)))
}
}
@Test
fun `an unset result is not reachable either`() {
// `Result` 里没有值(既没成功也没给异常)也是一种「没拿到答案」⇒ 不通。
assertEquals(Reachability.Unreachable, reachabilityOf(Result.failure(RuntimeException())))
}
@Test
fun `the probe timeout is short enough to sit inside a send`() {
// 硬要求:探测必须有 2~3 秒量级的短超时。它直接加在用户的发送动作上(气泡已经上屏、
// 用户等着这条消息的结论),所以既不能长到像卡死,也不能短到正常网络下误判成不通。
assertTrue(
"探测超时 $PROBE_TIMEOUT_MILLIS ms 落在 2~3 秒之外",
PROBE_TIMEOUT_MILLIS in 2_000L..3_000L,
)
// 回落 REST 那一次投递另有一道上限,且必须比探测宽(它是真请求,不是探针):
// 两道合起来才是「没网时用户最多等多久」的上界。
assertTrue(
"回落 REST 的上限该比探测宽裕",
REST_FALLBACK_TIMEOUT_MILLIS > PROBE_TIMEOUT_MILLIS,
)
}
// —— 三、Offline 那一档:不探测,直接排队 ——
@Test
fun `offline queues without probing, because the session is already known dead`() {
// Offline 的语义是「握手 401 且续期也失败」(终态、不自动重连)⇒ 会话已知是死的。
// 健康端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 挂在公开路由里),
// 探它必然回 200 ⇒ 判成 Reachable ⇒ 发一次注定 401 的 REST ⇒ 把 W40 那句
// 「已排队不会丢」换成一次回滚 + 一句会误导人的鉴权错误。所以这一档**根本不探**。
listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability ->
assertEquals(
"Offline 上不该因为探测通就去发 REST",
SendRoute.Queue,
sendRouteFor(wsDispatched = false, connection = WsState.Offline, reachability = reachability),
)
}
}
@Test
fun `offline is the only state that skips the probe`() {
// 三档「还没连上」都该走到探测那一步(判据只认 Connected 一个值,也排除 Offline 一个值):
// 将来连接状态多一档时,这一条会逼着有人来做决定,而不是静默继承。
WsState.entries
.filter { it != WsState.Connected && it != WsState.Offline }
.forEach { connection ->
assertEquals(
"$connection 上该允许回落 REST",
SendRoute.Rest,
sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable),
)
}
}
// —— 四、回落 REST 失败之后去哪 ——
@Test
fun `network failures on the fallback queue instead of rolling back`() {
// 这一族都是「请求根本没被服务端读到」:等一下多半会好,而内容一点都不该退给用户。
// 重发用的是同一个 clientMsgId,服务端按 (session_id, client_msg_id) 仲裁:真重复到达
// 只回 chat_duplicate / {duplicate:true},不会再落库、不会再起回合(契约 v0.56 §3.13)。
val network = listOf(
CyreneError.LocalKind.NoNetwork,
CyreneError.LocalKind.Dns,
CyreneError.LocalKind.Timeout,
CyreneError.LocalKind.ConnectFailed,
)
network.forEach { kind ->
assertEquals(
"$kind 属于网络类失败,该进队列",
RestFailureLanding.Queue,
restFailureLanding(CyreneError.Local(kind)),
)
}
}
@Test
fun `a fallback that got no verdict at all queues`() {
// `null` = 本端自己设的那道上限到点了(withTimeoutOrNull 回 null)或抛了个映射不出来的
// 异常:服务端没给过裁决 ⇒ 这条「还没上路」,与网络类失败同一档。
assertEquals(RestFailureLanding.Queue, restFailureLanding(null))
}
@Test
fun `a verdict from the server is never re-sent behind the user's back`() {
// 服务端**回了话**就是它的裁决:拒绝(4xx/5xx 的明确码)与未授权都不该被悄悄重发——
// 重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话。
assertEquals(
RestFailureLanding.Rollback,
restFailureLanding(CyreneError.Server("bad_request", "invalid text", 400)),
)
assertEquals(
RestFailureLanding.Rollback,
restFailureLanding(CyreneError.Server("internal", "boom", 500)),
)
assertEquals(
RestFailureLanding.Rollback,
restFailureLanding(CyreneError.Unauthorized("invalid_token", "")),
)
}
@Test
fun `configuration failures roll back because waiting will not fix them`() {
// 配置与协议这一族要的是用户改设置 / 换地址,等下去不会变好;回滚时那句 userMessage
// 正是他需要看到的东西(把「证书不对」说成「还没连上」会让他干等)。
val config = listOf(
CyreneError.LocalKind.Tls,
CyreneError.LocalKind.CleartextBlocked,
CyreneError.LocalKind.InvalidUrl,
CyreneError.LocalKind.Parse,
CyreneError.LocalKind.Unknown,
)
config.forEach { kind ->
assertEquals(
"$kind 等下去不会变好,该回滚并说清",
RestFailureLanding.Rollback,
restFailureLanding(CyreneError.Local(kind)),
)
}
}
@Test
fun `every local kind is decided, none is inherited by accident`() {
// 穷举 `LocalKind`:判据要么进队列、要么回滚,**没有第三种默认**。这条断言的价值在
// 错误模型将来多一档时——那时 `restFailureLanding` 会编译不过(when 不穷举),
// 而不是静默把新档位当成「网络类」重发出去。
CyreneError.LocalKind.entries.forEach { kind ->
val landing = restFailureLanding(CyreneError.Local(kind))
assertTrue(
"$kind 必须被明确分到某一档",
landing == RestFailureLanding.Queue || landing == RestFailureLanding.Rollback,
)
}
// 队列那一档恰好是「请求没被服务端读到」的网络族;其余一律回滚。
assertEquals(
RestFailureLanding.Queue,
restFailureLanding(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
)
assertNotEquals(
RestFailureLanding.Queue,
restFailureLanding(CyreneError.Local(CyreneError.LocalKind.Tls)),
)
}
// —— 五、这条回落到不了「REST 专属」的消息 ——
@Test
fun `image and document messages never enter this ladder at all`() {
// 回落 REST 只服务「本该走 WS 的短文本」:有图 / 有附件的消息在**进入阶梯之前**就被
// [ChatSendRouting.channelFor] 判成 Rest,走的是另一条分支(今天的 `sendViaRest` /
// `deliverImage`,行为一个字没变)。所以它们既不会因为「WS 未就绪」而多花一次探测,
// 也不会因为回落失败而进队列——**没有本地 URI 过一阵子还能不能读、图片要不要重编码
// 这一类新问题**(队列不持久化,真跨了进程 URI 授权就失效)。
// 路由规则本身由 `ChatSendPolicyTest` 逐个钉住,这里只钉「阶梯的入口因此变窄」这件事。
val documents = listOf(DraftDocument("content://doc", "a.txt"))
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("你好", hasImage = true))
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor("你好", hasImage = false, hasDocuments = documents.isNotEmpty()),
)
// 反过来:能进阶梯的只有「无图无附件 + 文本没超 WS 帧上限」那一种。
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("你好", hasImage = false))
}
}