ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

第九篇:Ktor Plugin 实战:Logging、HttpTimeout 与 HttpRequestRetry

2026/8/22 23:31:02 拓冰建站 浏览量
第九篇:Ktor Plugin 实战:Logging、HttpTimeout 与 HttpRequestRetry 前面几篇我已经把 Ktor 网络层逐渐搭到了这里ApiService ↓ NetworkClient ↓ NetworkConnectivityProvider ↓ Pre-check ↓ HttpClient ↓ Engine ↓ HTTP同时也建立了异常体系Network Timeout HTTP Parse Business Unknown ↓ ExceptionMapper ↓ AppError现在一个请求已经能够发送 Request ↓ 接收 Response ↓ 解析 JSON ↓ 检查业务 code ↓ 统一异常但一个真正可以投入项目使用的网络层还需要三个非常重要的能力Logging ↓ 这次 HTTP 请求到底发生了什么 HttpTimeout ↓ 这次请求最多允许等待多久 HttpRequestRetry ↓ 请求失败以后什么时候值得再试一次这三个能力都可以通过 Ktor Client Plugin 来完成。截至当前 Ktor 官方文档 3.5.2这三个能力分别对应Logging、HttpTimeout和HttpRequestRetry。这一篇不打算把每个 Plugin 的所有 API 都列一遍。真正要搞清楚的是Logging、Timeout、Retry 在一次 Request 生命周期中分别承担什么职责以及它们之间如何配合。一、先重新理解 Plugin前面已经讲过HttpClient Plugin Engine是 Ktor Client 很重要的设计。例如val client HttpClient { install(Logging) install(HttpRequestRetry) install(HttpTimeout) }可以理解成HttpClient │ ├── Logging │ ↓ │ 增加 HTTP 日志能力 │ ├── HttpRequestRetry │ ↓ │ 增加失败重试能力 │ └── HttpTimeout ↓ 增加超时控制能力Plugin 不是UserApi 调一次 OrderApi 再调一次 RobotApi 再调一次而是安装到某一个 HttpClient 上为这个 Client 增加统一网络能力。所以它首先是Client Scope的配置。二、三个 Plugin 不要混在一起先把三个问题彻底分开。Logging回答发生了什么例如请求 URL 是什么 Method 是什么 Header 有没有 服务器返回什么 Status Response 是什么HttpTimeout回答最多允许等多久HttpRequestRetry回答失败以后要不要重新发一次所以Logging ≠ Timeout ≠ Retry可以简单记Logging 负责看见Timeout 负责限制等待Retry 负责决定是否再试。三、第一个 PluginLogging安装最简单install(Logging)Ktor 官方的LoggingPlugin 用于记录 HTTP Call并且可以配置logger level filter sanitizeHeader bodyFilter其中LoggingConfig当前明确提供logger、level、filter()、sanitizeHeader()和bodyFilter。四、最简单的系统 Logging例如install(Logging) { logger Logger.DEFAULT level LogLevel.ALL }这里Logging Plugin ↓ 采集 HTTP Request / Response 信息 ↓ Logger.DEFAULT ↓ 输出日志在 JVM 平台Logger.DEFAULT使用 SLF4JAndroid 官方文档推荐配合slf4j-android。Ktor Multiplatform 也支持自己提供 Logger。五、一个真实 Logging 例子假设POST /login真实 RequestPOST /login Content-Type: application/json Authorization: Bearer abc123 { username: tom, password: 123456 }配置install(Logging) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { headerName - headerName HttpHeaders.Authorization } }那么日志中Authorization: ***真实 Token 不再直接出现。而真正发给服务器的 Request 仍然是Authorization: Bearer abc123也就是说sanitizeHeader ↓ 只影响日志展示 不会修改真实 HttpRequestKtor 官方当前的sanitizeHeader()就是用来把指定敏感 Header 的日志值替换掉默认占位符为***。六、这里马上出现一个问题password 怎么办刚才 Request Body{ username: tom, password: 123456 }虽然Authorization ↓ ***但是password仍然在 Body 中。因为sanitizeHeader只处理Header不能处理JSON BodyKtor 3.5.x 的LoggingConfig还提供了bodyFilter而LogBodyFilter当前同时有filterRequest()和filterResponse()可以决定 Request/Response Body 如何进入日志例如脱敏、截断或者跳过。这一篇不继续展开 Body 脱敏否则 Logging 会占掉整篇文章。TipssanitizeHeader、bodyFilter、JSON Body 脱敏、Binary/Multipart 跳过、大 Body 截断、Logger.DEFAULT与 Custom Logger 的完整设计放到补充篇9.1《Ktor Logging 深入Header、Body 脱敏与自定义 Logger》单独讲。七、Logger.DEFAULT 和 Custom Logger 有什么区别这个主篇只建立基本认识。方案 Ainstall(Logging) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { it HttpHeaders.Authorization } }流程Ktor Logging ↓ LoggingConfig ↓ 脱敏 / 过滤 ↓ Logger.DEFAULT ↓ 平台日志系统方案 Binstall(Logging) { logger object : Logger { override fun log( message: String, ) { AppLogger.d( tag HTTP, message message, ) } } level LogLevel.ALL sanitizeHeader { it HttpHeaders.Authorization } }流程Ktor Logging ↓ LoggingConfig ↓ 脱敏 / 过滤 ↓ Custom Logger ↓ AppLogger / Kermit所以两种方式都可以使用 Ktor 自带的脱敏能力。区别主要在Logger.DEFAULT ↓ 使用默认日志出口 Custom Logger ↓ 把 Ktor 日志接入自己的日志系统Ktor 官方同样支持通过实现Logger.log(message)提供 Custom Logger。八、Logging 在架构中应该负责什么最终可以理解Logging Plugin ↓ 采集 HTTP 信息 LoggingConfig ↓ 决定记录什么 ↓ level filter sanitizeHeader bodyFilter Logger ↓ 决定日志输出到哪里所以ApiService不应该println(POST /login)到处自己打印网络日志。统一 HTTP 日志应该属于HttpClient ↓ Logging Plugin九、第二个 PluginHttpTimeout接下来是HttpTimeout它解决的问题非常简单网络请求不能无限等下去。例如install(HttpTimeout) { requestTimeoutMillis 15_000 connectTimeoutMillis 10_000 socketTimeoutMillis 15_000 }Ktor 官方当前把 Timeout 分为Request Timeout Connect Timeout Socket Timeout三类。十、Connect Timeout首先Connect Timeout负责建立服务器连接最多允许等待多久。例如GET /orders ↓ 尝试连接 api.example.com ↓ 一直连接不上 ↓ 超过 10 秒 ↓ ConnectTimeoutException如果connectTimeoutMillis 10_000意思就是建立连接 ↓ 最多等 10 秒可以记成Connect Timeout 门一直敲不开我最多等多久。十一、Socket Timeout假设连接已经成功但是服务器迟迟没有继续发送数据这时候关注Socket TimeoutKtor 官方把它定义成数据交换过程中两个数据包之间允许的最大无数据活动时间。例如连接成功 ↓ 服务器开始返回数据 ↓ 突然 20 秒没有任何数据 ↓ Socket Timeout可以记Socket Timeout 已经接通了但你多久不说话我就不等了。十二、Request Timeout最后Request Timeout范围最大。它关注发送 Request ↓ 一直到接收 Response整笔 HTTP Call 所允许的时间。可以画成Request Timeout ┌──────────────────────────┐ ↓ ↓ Request → Connect → 数据交换 → Response ↑ ↑ │ │ Connect Socket Timeout Timeout可以记Request Timeout 整笔请求最多允许花多久。十三、一个 Timeout 实际例子配置install(HttpTimeout) { requestTimeoutMillis 15_000 connectTimeoutMillis 10_000 socketTimeoutMillis 15_000 }请求GET /orders情况一2 秒建立连接 ↓ 3 秒返回数据 ↓ 总计 5 秒 ↓ 成功情况二开始连接 ↓ 超过 10 秒仍然建立不了连接 ↓ Connect Timeout情况三2 秒连接成功 ↓ 服务器开始返回 ↓ 中途超过 15 秒没有数据 ↓ Socket Timeout情况四连接 数据传输 ↓ 整笔 Call 超过 Request Timeout ↓ Request Timeout这样三个 Timeout 就比较容易区分了。十四、Timeout 也存在“Client 默认值 Request 特殊值”例如普通接口GET /orders GET /users GET /products统一install(HttpTimeout) { requestTimeoutMillis 15_000 }但是上传POST /upload可能明显需要更长时间。于是client.post(upload) { timeout { requestTimeoutMillis 120_000 } }这一次 Request120 秒普通 Request15 秒Ktor 官方当前明确支持 Request 级 timeout并且 Request 配置会覆盖 Client Plugin 的全局对应 timeout。这正好又对应我们前面讲过的Client 默认配置 ↓ 大多数 Request Request 特殊配置 ↓ 只影响当前请求十五、Timeout 还存在 Engine 差异KMP 这里要特别注意。commonMain 可以统一配置Request Connect Socket但不同 Engine 的底层能力不一定完全一致。例如当前 Ktor 官方文档中Darwin Request ✅ Connect ❌ Socket ✅ JavaScript Request ✅ Connect ❌ Socket ❌所以KMP 可以统一 Timeout 配置思想但不能理解成所有平台 Engine 的底层 Timeout 能力完全一致。这和 ConnectivityProvider 的道理一样统一的是抽象 ≠ 所有平台实现完全相同十六、Timeout 最终怎么进入异常体系Ktor 当前可能抛HttpRequestTimeoutException ConnectTimeoutException SocketTimeoutException如果业务没有必要区分这么细三种 Ktor Exception ↓ ExceptionMapper ↓ AppError.Timeout即可。ViewModel 不需要catch ( e: SocketTimeoutException )十七、第三个 PluginHttpRequestRetry接下来进入最容易写出问题的HttpRequestRetry它解决这次 Request 失败以后要不要重新发送一次首先有一个非常重要的前提请求失败不等于应该 Retry。Ktor Client 默认不会因为网络或服务器错误自动替你反复请求安装HttpRequestRetry后才可以配置 Retry 次数、条件、Delay 以及 Retry 前如何修改 Request。十八、先看最简单的 Retry例如install(HttpRequestRetry) { retryOnServerErrors( maxRetries 2, ) exponentialDelay() }这里5xx ↓ 允许 Retry maxRetries 2 ↓ 最多重试 2 次 exponentialDelay() ↓ Retry 之间不要立即连续请求Ktor 官方retryOnServerErrors()当前就是用于 5xx Response 重试而exponentialDelay()用于指数退避。十九、一个 503 Retry 的真实例子例如GET /products第一次Request #1 ↓ 503 Service Unavailable服务器可能正在瞬时负载过高 滚动发布 网关临时异常于是等待 ↓ Retry第二次Request #2 ↓ 200 OK整个流程GET /products ↓ 503 ↓ HttpRequestRetry ↓ 等待 ↓ GET /products ↓ 200 ↓ 继续正常业务流程这时候ViewModel甚至根本不需要知道第一次出现过 503这就是 Retry 的一个重要价值在最终错误暴露给业务层之前消化某些瞬时故障。二十、为什么不能所有 5xx 都无脑 Retry因为 Retry 不只是Response 出错了吗还要问这笔 Request 再发一次安全吗这就涉及幂等性二十一、最重要的 Retry 反例创建订单例如POST /orders ↓ 创建订单Client 发送POST /orders服务器已经创建 Order #1001然后 Response 返回途中网络断开Client 最终看到Network Error或者TimeoutClient 很容易误认为“请求失败了”然后自动 RetryPOST /orders服务器可能再创建 Order #1002最后用户操作一次 ↓ 产生两张订单所以非常重要的一句话是客户端没有收到成功 Response不代表服务端没有执行成功。二十二、这就是为什么 Retry 必须考虑幂等性简单理解同一个请求执行一次和执行多次最终业务结果应该一致。例如规范的GET /users多执行几次只是查询通常风险比较低。而POST /orders多执行一次可能产生第二张订单所以失败 ↓ 不能直接推导出 ↓ Retry而应该失败 ↓ 错误是否值得重试 ↓ Request 能安全重放吗 ↓ 都满足 ↓ Retry二十三、POST 怎么才能安全 Retry一种常见设计Idempotency-Key例如Idempotency-Key: order-100001第一次POST /orders Key ABC ↓ 服务器创建 Order #1001 ↓ 记录 ABC 已经处理RetryPOST /orders Key 仍然 ABC服务器发现 ABC 已经处理 ↓ 不创建第二张订单 ↓ 返回原结果这时候客户端 Retry 服务端幂等才真正形成一套可靠机制。所以HttpRequestRetry 只能负责“再发送”真正能不能安全再发送还取决于业务协议设计。二十四、机器人控制指令更不能无脑 Retry例如向前移动 1 米ClientCommand #1001 ↓ Server / Robot ↓ 已经执行但Ack 丢失Client没收到 ↓ Retry如果 Retry 被当成新命令机器人可能再移动 1 米所以机器人领域通常还需要CommandId Sequence Ack Result 幂等处理HttpRequestRetry 本身无法替你解决这些业务协议问题。二十五、Retry 判断最好拆成三个问题以后看到任何 Retry都可以问第一问这个错误值得 Retry 吗例如Network Error Timeout 503可能值得。而404 Parse Error Business Error通常没有意义自动 Retry。第二问当前还有网络条件吗也就是NetworkConnectivityProvider ↓ Available ?第三问Request 能安全重放吗例如GET 幂等 PUT Idempotency-Key Command Sequence最终Retryable Error AND Connectivity Available AND Request Safe ↓ Retry这是比maxRetries 3重要得多的 Retry 模型。二十六、Retry 也可以根据 Exception 判断Ktor 当前支持retryOnExceptionIf { request, cause, - ... }官方示例就是根据 Request 和 Throwable 自定义是否 Retry。例如retryOnExceptionIf( maxRetries 2, ) { request, cause - val safeRequest request.method HttpMethod.Get val networkAvailable connectivityProvider .isNetworkAvailable val retryableError isRetryableNetworkException( cause ) safeRequest networkAvailable retryableError }这个例子非常重要。因为 Retry 已经不只是Exception ↓ Retry而是Exception Request Provider 当前状态 ↓ 共同决定二十七、这里正好连接 8.1 的动态 Provider补充篇 8.1Ktor/KMP 断网处理为什么请求前要先判断网络状态第一次 RequestProvider Available ↓ NetworkClient Pre-check ↓ 通过 ↓ HttpClient请求失败Timeout但是准备 Retry 时Wi-Fi 已经断开 ↓ Provider Unavailable这时候retryOnExceptionIf ↓ 再次读取 Provider ↓ false ↓ 停止 Retry流程Request #1 ↓ Provider Available ↓ 开始请求 ↓ Timeout ↓ 准备 Retry ↓ 读取同一个 Provider ↓ Provider 已经变成 Unavailable ↓ 不 Retry这就真正体现了HttpClient 持有的是动态 Provider 对象不是创建 Client 时的一次 Boolean 快照。二十八、为什么 NetworkClient 的 Pre-check 不够因为现在结构NetworkClient ↓ Pre-check ↓ HttpClient ↓ HttpRequestRetryPre-check只在第一次进入 HttpClient 之前执行但 HttpRequestRetry是在 HttpClient 内部重新发送所以Request #1 ↓ 经过 NetworkClient Pre-check Retry #1 ↓ 不会天然重新回到 NetworkClient Pre-check因此如果你希望每次 Retry ↓ 都考虑最新 ConnectivityRetry Condition 自己重新读取NetworkConnectivityProvider是比较自然的方案。二十九、Retry Delay 为什么不能省假设服务器503你马上Retry Retry Retry可能会服务器已经压力很大 ↓ 客户端继续猛打 ↓ 情况更差所以 Retry 通常要Backoff例如第一次等待 1 秒 第二次等待更久 第三次继续增加Ktor 当前官方基础示例提供exponentialDelay()用于指数退避。核心思想瞬时错误值得再试但不要立即连续轰炸服务器。三十、HttpRequestRetry 和 HttpTimeout 的安装顺序这里有一个 Ktor 当前明确要求的细节。如果HttpRequestRetry HttpTimeout一起使用并且希望 Retry 能够配置处理 TimeoutHttpClient { install(HttpRequestRetry) { ... } install(HttpTimeout) { ... } }也就是HttpRequestRetry ↓ 先 install HttpTimeout ↓ 后 installKtor 3.5.2 官方文档明确说明了这个顺序。这里不要按照“哪个先发生就哪个先 install”去推理。Plugin 安装顺序和 Ktor Pipeline 的组合有关。这个场景直接按官方要求即可。三十一、Timeout Retry 的实际例子假设GET /products配置Request Timeout 15 秒 maxRetries 2第一次Request #1 ↓ Timeout准备 RetryProvider Available ↓ GET ↓ Retry Policy 允许 ↓ 等待第二次Request #2 ↓ 200那么最终业务层 ↓ 成功但这里要注意单次 Request Timeout和整个业务操作总耗时不是一个概念。因为可能Attempt #1 ↓ Timeout Backoff Attempt #2 ↓ Timeout Backoff Attempt #3所以整个操作的总时间可能明显超过一次 requestTimeoutMillis如果某个业务有严格的整个操作最多 20 秒还可能需要业务层额外的总 Deadline而不能只依赖单次 HttpTimeout。三十二、一个比较保守的 Retry 例子项目早期可以先从GET 5xx开始。例如install(HttpRequestRetry) { maxRetries 2 retryIf { request, response - val safeRequest request.method HttpMethod.Get val retryableStatus response.status.value in 500..599 val networkAvailable connectivityProvider .isNetworkAvailable safeRequest retryableStatus networkAvailable } exponentialDelay() }这个配置真正表达的是GET AND 5xx AND 当前仍然有网络 ↓ Retry而不是5xx ↓ 统统 Retry三十三、Logging、Timeout、Retry 现在可以放回一条链路Request ↓ Connectivity Pre-check ↓ HttpClient │ ┌──────────────┼──────────────┐ ↓ ↓ ↓ Logging HttpTimeout HttpRequestRetry ↓ ↓ ↓ 看见过程 时间边界 Retry Policy │ │ │ │ │ ┌──────┼──────┐ │ │ ↓ ↓ ↓ │ │ Error Network Request │ │ State Safety │ │ ↓ └──────────────┴────────────────┘ ↓ Engine ↓ HTTP一句话Logging ↓ 让我知道发生了什么 Timeout ↓ 让我不要无限等待 Retry ↓ 让我在值得的时候再试一次三十四、它们和 ExceptionMapper 是什么关系假设Request ↓ Timeout如果Retry Policy ↓ 允许 Retry先继续 Retry。直到成功或者Retry 用完 ↓ 仍然失败才Throwable ↓ ExceptionMapper ↓ AppError.Timeout所以Retry负责的是在错误最终暴露给上层前尝试恢复瞬时故障。而ExceptionMapper负责的是最终仍然失败时把底层异常翻译成稳定的 AppError。三十五、一个阶段性的 HttpClient 配置把三个 Plugin 放在一起可以先写成fun createHttpClient( connectivityProvider: NetworkConnectivityProvider, ): HttpClient { return HttpClient { install(HttpRequestRetry) { maxRetries 2 retryIf { request, response, - val safeRequest request.method HttpMethod.Get val retryableStatus response.status.value in 500..599 safeRequest retryableStatus connectivityProvider .isNetworkAvailable } retryOnExceptionIf { request, cause, - val safeRequest request.method HttpMethod.Get val retryableError isRetryableNetworkException( cause ) safeRequest retryableError connectivityProvider .isNetworkAvailable } exponentialDelay() } install(HttpTimeout) { requestTimeoutMillis 15_000 connectTimeoutMillis 10_000 socketTimeoutMillis 15_000 } install(Logging) { logger Logger.DEFAULT level LogLevel.ALL sanitizeHeader { headerName, - headerName HttpHeaders.Authorization } } } }注意这只是一个阶段性示例不是所有项目的最终标准答案例如正式项目的 Logging 可能接Kermit AppLogger 文件日志 统一脱敏Retry 也可能根据不同 Client 使用不同策略。三十六、三个 Plugin 也有 Client 作用域例如apiClient可能Logging Debug Timeout 15s GET Retry 2而uploadClient可能Logging ↓ 不打印二进制 Body Timeout ↓ 120s Retry ↓ 使用上传自己的策略机器人 ClientrobotClient可能Timeout ↓ 更短 Retry ↓ 不能只看 HTTP ↓ 还要考虑 CommandId / Ack所以仍然是配置不是越集中越好而是应该放到正确的 Client 作用域。三十七、本篇最容易踩的几个坑坑一Logging 全部LogLevel.ALL直接上正式环境可能泄露Authorization Cookie Password Token 用户隐私至少需要sanitizeHeader bodyFilter 日志环境控制更完整方案见 9.1。补充篇 8.1Ktor/KMP 断网处理为什么请求前要先判断网络状态坑二认为 Custom Logger 才能脱敏不对。Logger.DEFAULT和Custom Logger都可以使用sanitizeHeader bodyFilter因为它们属于LoggingConfig而不是 Logger 本身。坑三把断网当 Timeout不对。请求前明确断网 ↓ Network 等待超过限制 ↓ Timeout坑四所有 Timeout 都 Retry不对。还需要考虑当前网络状态 Request 是否安全重放 业务是否允许坑五POST Timeout 自动 Retry非常危险。因为Client 没收到 Response不代表Server 没执行坑六Retry 越多越稳定不对。过多 Retry 可能增加等待 增加服务器压力 制造重复业务Retry 是解决瞬时故障不是把所有失败硬磨成成功。坑七只在 NetworkClient 做 Connectivity Pre-check第一次请求够用。但 HttpRequestRetry在 HttpClient 内部 Retry不会天然重新回到NetworkClient Pre-check所以 Retry Condition 如果需要 Connectivity重新读取 Provider 当前状态更加合理。坑八忽略 Retry 和 Timeout 的安装顺序需要 Retry Timeout 时HttpRequestRetry ↓ 先安装 HttpTimeout ↓ 后安装这是 Ktor 当前官方明确要求。三十八、本篇总结这一篇最重要的是把三个 Plugin 的职责彻底分开。Logging回答发生了什么结构Logging Plugin ↓ LoggingConfig │ ├ level ├ filter ├ sanitizeHeader └ bodyFilter ↓ LoggerLogger.DEFAULT和 Custom Logger 都可以使用 Ktor 自带的过滤/脱敏配置Custom Logger 只是把处理后的日志接入自己的日志系统。Tips完整日志方案见补充篇 9.1《Ktor Logging 深入Header、Body 脱敏与自定义 Logger》。HttpTimeout回答允许等多久三种Connect Timeout ↓ 建立连接最多多久 Socket Timeout ↓ 数据交换过程中 最大无活动时间 Request Timeout ↓ 整笔 HTTP Call 最多多久并且Client 默认 Timeout ↓ 可以被 Request 特殊 Timeout 覆盖HttpRequestRetry回答失败以后 是否值得再发送一次真正可靠的 Retry 不是失败 ↓ Retry而是错误值得 Retry ↓ 当前网络 Available ↓ Request 可以安全重放 ↓ 三者都满足 ↓ Retry再配合有限次数 Backoff处理瞬时故障。把目前的整个网络可靠性链路串起来Request ↓ Connectivity Pre-check ↓ Logging ↓ HttpTimeout ↓ HttpRequestRetry ↓ Engine ↓ HTTP ↓ Response / Throwable ↓ 必要时 Retry ↓ 最终仍失败 ↓ ExceptionMapper ↓ AppError这一篇真正需要记住四句话Connectivity 解决“现在是否值得请求”。Logging 解决“这次请求到底发生了什么”。Timeout 解决“我最多愿意等多久”。Retry 解决“失败以后这笔请求是否值得而且能够安全地再试一次”。当这几个职责分开以后网络层就不再是client.get() ↓ 成功 / 失败这么简单而开始具备真正的工程可靠性。补充篇 9.1《Ktor Logging 深入Header、Body 脱敏与自定义 Logger》专门展开LoggingConfig 和 Logger 的关系 Logger.DEFAULT Custom Logger sanitizeHeader bodyFilter Request / Response Body 脱敏 JSON 敏感字段递归处理 Binary / Multipart Skip 大 Body 截断 Kermit / AppLogger 对接 项目级二次脱敏 最终完整生产级 Logging 示例下一篇《Ktor AuthBearer Token、Refresh Token 与 401 自动刷新到底怎么工作》下一篇开始解决Access Token ↓ 怎么自动加到 Request 401 ↓ 发生以后谁处理 Refresh Token ↓ 为什么经常需要独立 Client Refresh 成功 ↓ 原来的 Request 怎么重新发送 10 个请求同时 401 ↓ 为什么不能刷新 10 次 Token TokenProvider ↓ 为什么又是一个动态 Provider Auth Plugin ↓ 和 DefaultRequest 手动添加 Authorization 到底有什么区别并把之前的apiClient refreshClient TokenProvider 401 Provider 动态状态 Retry真正连接起来。