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:
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user