Skip to content

chat: token 过期后聊天直接失败——1d74f4f 引入 401 重试时漏了 chat 模块 #100

Description

@HarlonWang

背景

1d74f4f(2026-08-13,「业务请求 401 → 刷新 → 重试一次」)给 AuthManager 加了 authorized {} 包装器:取 token → 执行 → 撞 401 就走 AuthClient.refresh() 的单飞路径刷新、用新 token 重试一次。

那次改动覆盖了 8 个文件(GithubTokenProviderProfileViewModelApp.ktAccountLinkHostFavoriteRepository 等),androidLibrary/chat/ 一个文件都没碰

问题

ChatApi 的两条请求路径至今仍是手工拼 header,绕过 authorized {}

  • androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:223researchJson,research 提交/轮询)
  • androidLibrary/chat/src/main/kotlin/whl/trending/chat/engine/ChatApi.kt:260executeStreaming,聊天与解读的流式路径)
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:

  1. chat 端点匿名可用。没登录也要照发(走匿名档配额,见 ChatApi.kt:259 的注释),而 authorized {} 在无会话时直接返回 null根本不执行 block。直接包进去会让未登录用户的聊天全部失效。
  2. chat 抛的是 ChatException 不是 ApiExceptionauthorized {} 靠沿 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.ktresearchJsonexecuteStreaming 各抽出一个接受 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions