背景
1d74f4f(2026-08-13,「业务请求 401 → 刷新 → 重试一次」)给 AuthManager 加了 authorized {} 包装器:取 token → 执行 → 撞 401 就走 AuthClient.refresh() 的单飞路径刷新、用新 token 重试一次。
那次改动覆盖了 8 个文件(GithubTokenProvider、ProfileViewModel、App.kt、AccountLinkHost、FavoriteRepository 等),androidLibrary/chat/ 一个文件都没碰。
问题
ChatApi 的两条请求路径至今仍是手工拼 header,绕过 authorized {}:
androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:223(researchJson,research 提交/轮询)
androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:260(executeStreaming,聊天与解读的流式路径)
globalAuthManager.getAccessToken()?.let { header("Authorization", "Bearer $it") }
没有任何 401 刷新重试。chat 模块里 grep 不到对 401 的处理,所以它落进 toChatException 的兜底分支,被分类成 BAD_REQUEST(retryable = false)——「客户端请求非法,重试无用」,用户看到的是一个不可重试的失败。
而 loginbase 的 access token TTL 是 3600 秒。
触发条件
登录态用户,距上次活动超过 1 小时(access token 已过期、尚未被任何其他请求触发刷新),打开 App 直接发聊天/解读/research。首条必失败,且提示为不可重试。
如果用户先逛了别的页面(触发了任意一条走 authorized {} 的请求),token 已被刷新,就撞不上——所以是概率性复现,不是每次。
为什么当时没被覆盖
不完全是疏忽,authorized {} 的形状确实套不上 chat:
- chat 端点匿名可用。没登录也要照发(走匿名档配额,见
ChatApi.kt:259 的注释),而 authorized {} 在无会话时直接返回 null、根本不执行 block。直接包进去会让未登录用户的聊天全部失效。
- chat 抛的是
ChatException 不是 ApiException。authorized {} 靠沿 cause 链匹配 ApiException(401) 判定,链上没有这个类型,即便包了也识别不出 401。
所以覆盖 chat 需要一个新形状,那次改动没做这件事。
修法
倾向方案(与 authorized {} 当初的加法保持一致):
1. shared/.../auth/AuthManager.kt 加一个带默认实现的接口方法:
/**
* 401 后刷新一次并返回新 token;无会话或刷新失败返回 null。
* 给「匿名也可用」的端点用——它们不能套 [authorized](无会话时会短路、根本不发请求)。
*/
suspend fun refreshForRetry(): String? = null
LoginbaseAuthManager 覆盖,复用 authorized 那套失败语义(保持提示行为只有一份):
override suspend fun refreshForRetry(): String? = when (val o = client.refresh()) {
is RefreshOutcome.Success -> o.tokens.accessToken
is RefreshOutcome.SessionEnded -> { SignInFailureBus.emit(SignInFailureReason.SESSION_EXPIRED); null }
else -> null // Failed / NoSession:会话可能好好的,不提示、不打扰
}
NoopAuthManager 继承默认值,iOS 不受影响。
2. ChatApi.kt 的 researchJson 与 executeStreaming 各抽出一个接受 token: String? 的内部 attempt,401 && token != null 时刷新并重试一次。
两个安全点已核对:
executeStreaming 的状态码检查在 execute {} 的第一行(ChatApi.kt:265),此时任何 delta 都还没 emit,重试不会造成重复渲染
- 只重试一次,与
authorized {} 现有纪律一致(刷新后仍 401 说明不是过期问题,再试无益)
备选:不动共享接口,在 chat 内 (globalAuthManager as? LoginbaseAuthManager)?.client?.refresh()。改动面更小,但把 loginbase 类型漏进 chat 模块,且 SessionEnded → 提示 的逻辑会出现第二份。不推荐。
测试
androidLibrary/chat/src/test/kotlin/whl/trending/chat/engine/ 下已有 ChatErrorsTest / ChatWireTest / ChatSseTest,加 401 → 刷新 → 重试的用例有现成位置。需要覆盖的至少三条:
- 带 token 撞 401 → 刷新 → 用新 token 重试一次成功
- 未登录(无 token)撞 401 不刷新、不重试,保持匿名路径行为不变(这条是防回归的重点)
- 刷新后仍 401 → 只重试一次,按原错误上报
按会话惯例先破坏修复确认测试变红,再恢复。
关联
- 缺口引入:
1d74f4f(覆盖不全),涉事代码本身写于 Logto 时代(2d47550 2026-07-05、0a57917 2026-07-22),当时全 App 都没有 401 重试,chat 不特殊
- 另一个已知但有意保留的缺口:返回
Boolean 的写接口(putFavorite / deleteFavorite / batchPutFavorites)把 401 变成 false 不抛异常,authorized {} 感知不到——有待推队列兜底,只是延后不丢数据,见 LoginbaseAuthManager.authorized 的 KDoc
背景
1d74f4f(2026-08-13,「业务请求 401 → 刷新 → 重试一次」)给AuthManager加了authorized {}包装器:取 token → 执行 → 撞 401 就走AuthClient.refresh()的单飞路径刷新、用新 token 重试一次。那次改动覆盖了 8 个文件(
GithubTokenProvider、ProfileViewModel、App.kt、AccountLinkHost、FavoriteRepository等),androidLibrary/chat/一个文件都没碰。问题
ChatApi的两条请求路径至今仍是手工拼 header,绕过authorized {}:androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:223(researchJson,research 提交/轮询)androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:260(executeStreaming,聊天与解读的流式路径)没有任何 401 刷新重试。chat 模块里 grep 不到对 401 的处理,所以它落进
toChatException的兜底分支,被分类成BAD_REQUEST(retryable = false)——「客户端请求非法,重试无用」,用户看到的是一个不可重试的失败。而 loginbase 的 access token TTL 是 3600 秒。
触发条件
登录态用户,距上次活动超过 1 小时(access token 已过期、尚未被任何其他请求触发刷新),打开 App 直接发聊天/解读/research。首条必失败,且提示为不可重试。
如果用户先逛了别的页面(触发了任意一条走
authorized {}的请求),token 已被刷新,就撞不上——所以是概率性复现,不是每次。为什么当时没被覆盖
不完全是疏忽,
authorized {}的形状确实套不上 chat:ChatApi.kt:259的注释),而authorized {}在无会话时直接返回null、根本不执行 block。直接包进去会让未登录用户的聊天全部失效。ChatException不是ApiException。authorized {}靠沿cause链匹配ApiException(401)判定,链上没有这个类型,即便包了也识别不出 401。所以覆盖 chat 需要一个新形状,那次改动没做这件事。
修法
倾向方案(与
authorized {}当初的加法保持一致):1.
shared/.../auth/AuthManager.kt加一个带默认实现的接口方法:LoginbaseAuthManager覆盖,复用authorized那套失败语义(保持提示行为只有一份):NoopAuthManager继承默认值,iOS 不受影响。2.
ChatApi.kt的researchJson与executeStreaming各抽出一个接受token: String?的内部 attempt,401 && token != null时刷新并重试一次。两个安全点已核对:
executeStreaming的状态码检查在execute {}的第一行(ChatApi.kt:265),此时任何 delta 都还没 emit,重试不会造成重复渲染authorized {}现有纪律一致(刷新后仍 401 说明不是过期问题,再试无益)备选:不动共享接口,在 chat 内
(globalAuthManager as? LoginbaseAuthManager)?.client?.refresh()。改动面更小,但把 loginbase 类型漏进 chat 模块,且SessionEnded → 提示的逻辑会出现第二份。不推荐。测试
androidLibrary/chat/src/test/kotlin/whl/trending/chat/engine/下已有ChatErrorsTest/ChatWireTest/ChatSseTest,加 401 → 刷新 → 重试的用例有现成位置。需要覆盖的至少三条:按会话惯例先破坏修复确认测试变红,再恢复。
关联
1d74f4f(覆盖不全),涉事代码本身写于 Logto 时代(2d475502026-07-05、0a579172026-07-22),当时全 App 都没有 401 重试,chat 不特殊Boolean的写接口(putFavorite/deleteFavorite/batchPutFavorites)把 401 变成false不抛异常,authorized {}感知不到——有待推队列兜底,只是延后不丢数据,见LoginbaseAuthManager.authorized的 KDoc