ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OkHttp拦截器链源码解析:RetryAndFollowUpInterceptor重试与重定向

OkHttp拦截器链源码解析:RetryAndFollowUpInterceptor重试与重定向

做 OkHttp 源码解析系列,上一篇梳理了整条拦截器链的运转流程,这一篇专门拆RetryAndFollowUpInterceptor。它是整条链上第一个拦截器,也是很多线上怪问题的源头:为什么日志里同一个请求出现了两次?为什么 302 的 POST 变成了 GET?为什么设置了retryOnConnectionFailure(false)之后偶发失败反而变多了?答案基本都藏在这一个类里。如果你正在排查 OkHttp 相关的连接失败、重定向异常、token 失效重放等问题,这篇文章就是给你准备的。

1. 拦截器链的第一棒:它的存在让"一次请求"从未真正只发生一次

1.1 责任链里它为什么排最前

OkHttp 的拦截器链是一个嵌套调用结构,外部拦截器先执行,然后调用proceed把请求往下传。RetryAndFollowUpInterceptor在整个链的开头位置,在它后面依次是BridgeInterceptor、CacheInterceptor、ConnectInterceptor、CallServerInterceptor等。这个位置不是随便排的,背后有明确分工:网络层问题必须在最外层兜住,因为一旦深入链路内部再想重试,连接状态、请求体消耗、缓存写入这些副作用都已经发生了,重试就成了不可能完成的事。

我们可以把它理解成一个"快递重派系统"。快递员拿着包裹出门派送,路上遇到收件地址临时变更(重定向),就回来重新规划路线;路上车坏了(连接失败),也回来换一辆车再跑;跑了好几次都不行,才告诉用户这单派不了。而这个"回来重新规划"的动作,必须发生在快递站的最外层,不能等包裹都塞进收件人门缝了才说重新派。

从源码角度看,这个拦截器的核心是一个while(true)循环:每次循环尝试发起一次调用,根据结果决定是返回响应、继续重试、还是构建 FollowUp 请求再次请求。整个循环体的骨架如下(源码以 OkHttp 4.x 为例,不同版本细节略有差异):

override fun intercept(chain: Interceptor.Chain): Response { val realChain = chain as RealInterceptorChain var request = chain.request val transmitter = realChain.transmitter var followUpCount = 0 var priorResponse: Response? = null while (true) { transmitter.prepareToConnect(request) if (transmitter.isCanceled) { throw IOException("Canceled") } var response: Response var success = false try { response = realChain.proceed(request, transmitter, null) success = true } catch (e: RouteException) { if (!recover(e.lastConnectException, transmitter, request, requestSendStarted = false)) { throw e.lastConnectException } } catch (e: IOException) { val requestSendStarted = e !is ConnectionShutdownException if (!recover(e, transmitter, request, requestSendStarted)) { throw e } } finally { if (!success) { transmitter.exchangeDoneDueToException() } } // 处理 priorResponse,为 304 等场景补充前序响应信息 // 构建 followUp 请求,如果没有则需要返回当前响应 // 检查 followUpCount 是否超过 MAX_FOLLOW_UPS(默认 20) } }

这个循环结构决定了它的两个核心职责:重试和重定向(包括认证重放)。两者共享同一套"失败后重新进入循环"的机制,这也是这个类名字里Retry和FollowUp并列的原因。

1.2 它和 Transmitter 的分工关系

在分析重试逻辑之前,必须先理解Transmitter这个角色。Transmitter是 OkHttp 4.x 引入的一个类,职责是管理一次用户请求的生命周期,包括路由选择、连接获取、Exchange 创建、连接释放等。RetryAndFollowUpInterceptor本身不直接持有连接池,所有连接相关的操作都通过Transmitter来完成。

这里有个容易混淆的点:很多人以为"连接失败重试"是 OkHttp 自动换一个 IP 重新连,实际流程是:第一次请求走某个路由(Route)失败后,Transmitter会从RouteSelector中取出下一个可用路由,再次进入proceed尝试建立连接。如果所有路由都试过了但请求仍然失败,RouteException会被抛出;此时recover()返回 false,异常直接向上抛给调用方。所以 OkHttp 的重试其实是"逐路由尝试",而不是对同一个路由反复死磕。

我在读源码时最喜欢的一句话注释就藏在这里:// The attempt to connect via a route failed. The request will not have been sent.这句话对应的是RouteException分支。重点在于"请求还没有发出",所以这个阶段的重试是安全的,连接层失败不会产生幂等性问题。相较之下,IOException分支那边就复杂得多,因为请求可能已经发出去了。

2. 重试机制拆解:从 RouteException 到 IOException,什么情况值得再试一次

2.1 两条异常路径的重试语义差异

intercept方法里try-proceed捕获了两类异常,分别对应两种不同的失败场景:

  • RouteException:发生在连接建立阶段,代表当前路由不可用。典型的场景是 TCP 连接超时、DNS 解析失败、路由被拒绝等。此时请求体绝对没有被写入 socket,所以重试完全没有副作用。
  • IOException:发生在请求写入或响应读取阶段。这类异常要复杂得多,因为某些情况下请求可能已经被服务器接收并处理,此时盲目重试可能导致重复写操作。ConnectionShutdownException是唯一的例外,它意味着连接在请求发送前就被关闭了,请求实际上没发出去,可以安全重试。

也许你会问:为什么同样是 IOException,还要区分requestSendStarted?因为 OkHttp 无法确定服务器到底有没有收到完整请求。比如写入 POST body 一半时连接断开,服务器可能已经收到了部分数据并执行了逻辑,也可能没有。这种不确定性让 OkHttp 必须保守处理。

2.2 recover() 的判定链条

recover()方法里的检查顺序很有讲究,它是所有重试决策的核心:

private fun recover( e: IOException, transmitter: Transmitter, userRequest: Request, requestSendStarted: Boolean ): Boolean { // 应用层明确禁止在连接失败时重试 if (!client.retryOnConnectionFailure) return false // 请求可能已发送,且请求体无法重新写入(one-shot) if (requestSendStarted && requestIsOneShot(e, userRequest)) return false // 协议错误不重试 if (e is ProtocolException) return false // 中断类异常不重试(超时属于这一类) if (e is InterruptedIOException) return false // 路由选择器已经没有可用路由 if (!transmitter.canRetry()) return false return true }

我逐条解释一下这些判定的实际意义:

第一条retryOnConnectionFailure是 OkHttpClient 的配置项,默认是 true。如果你在构造客户端时显式设为 false,等于告诉 OkHttp"连接失败就直接抛异常,不要给我换路由"。这个配置在弱网场景下其实要慎用,因为关闭它之后,一次 DNS 抖动就能让你的请求直接失败。

第二条requestIsOneShot关注请求体能否重放。所谓 one-shot body,指的是只能写入一次的请求体,比如通过RequestBody.create(object : RequestBody() { ... writeTo 里写流 ... })这种方式构建的上传流。普通 JSON 字符串 body 不属于 one-shot,因为写入两次不会消耗数据源;但文件流、视频流这类只能读一次的 body,服务器如果已经读取了一部分,再写一遍就是脏数据。

第三条和第四条排除了两类异常:ProtocolException代表协议层已经乱套(比如 HTTP/2 帧错误),重试没意义;InterruptedIOException包含了SocketTimeoutException,也就是说OkHttp 默认不会对读超时和写超时进行自动重试。这一点很多人会搞混,以为是配置问题,其实是这个拦截器的设计决策:超时往往是服务器端已经处理了请求但响应慢,重试容易放大压力。

补充一个容易忽略的判定:requestSendStarted && requestIsOneShot(e, userRequest)里的requestSendStarted在代码中是通过e !is ConnectionShutdownException判断的。也就是说,只有连接关闭异常被视为"请求未发送",其余 IOException 都被当作"可能已发送"处理。

2.3 连接池与路由耗尽后的最终失败

transmitter.canRetry()这个方法值得展开说。它主要检查当前 exchange 的状态和路由选择器是否还有存货。如果当前交换未完成且连接已关闭、路由选择器也没有备选路由,那就不重试,直接抛出原始异常。换句话说,OkHttp 的重试不会无限进行,每个请求的重试上限由可用路由数量决定。

举一个实际例子:一个域名解析出两个 IP(两个路由),第一次连接第一个 IP 超时,OkHttp 会换第二个 IP 再试一次;如果第二个也失败,路由耗尽,请求就失败了。从日志上看,同一个请求会有两次网络调用记录,但这并不是业务层面的重试,而是路由层面的自动切换。

我在项目里曾经遇到过一个问题:公司某个 API 域名同时解析出 IPv4 和 IPv6 两个地址,IPv6 不通导致每次请求都要先超时几秒才切到 IPv4。后来在 DNS 层面把 IPv6 解析去掉,问题才解决。这个案例说明,理解 RouteException 的重试机制对排查"请求变慢但最终成功"的问题很有帮助。

3. 重定向的状态码语义表:301/302/303/307/308 各自的分工逻辑

3.1 状态码对应的处理决策

Response.followUpRequest()方法是重定向逻辑的入口。它根据响应状态码决定是否要构造一个新的请求。这里最容易踩坑的是301/302 与 307/308 的处理差异,因为很多开发者只记得"重定向会跟随",却忽略了方法是否会被改变。

状态码含义OkHttp 的行为
300Multiple Choices构建重定向请求,可能转 GET
301Moved PermanentlyPOST 自动转为 GET;GET/HEAD 保持原方法
302Found同 301,POST 自动转为 GET
303See Other任何方法都转为 GET
307Temporary Redirect保留原方法和 body,仅限 GET/HEAD
308Permanent Redirect保留原方法和 body,仅限 GET/HEAD
304Not Modified特殊处理,适配缓存逻辑
401Unauthorized交给 Authenticator 处理
407Proxy Authentication Required交给 ProxyAuthenticator 处理
其他-返回 null,不跟随

这张表里最关键的是 307/308:它们要求严格保持请求方法和 body,但 OkHttp 只对 GET 和 HEAD 自动跟随。如果服务端返回 307 时原始请求是 POST,followUpRequest()会直接返回 null,也就是不自动跟随。这其实是保护性设计——因为 OkHttp 不知道新的目标地址是否愿意接收同样的 POST body,万一跟随了,就可能导致请求被意外重放。

3.2 Location 解析与请求重建的细节

buildRedirectRequest()负责构造跟随后续请求。我挑几个关键代码片段来分析:

private fun buildRedirectRequest(): Request? { val method = request.method val responseLocation = header("Location") ?: return null val url = request.url.resolve(responseLocation) ?: return null val sameScheme = url.scheme == request.url.scheme if (!sameScheme && !client.followSslRedirects) { return null } val requestBuilder = request.newBuilder() // ... 方法处理 if (!sameHost(url)) { requestBuilder.removeHeader("Authorization") } return requestBuilder.url(url).build() }

第一行就非常实用:Location头如果为空,直接放弃跟随。有些服务端实现不标准,只返回 302 但没有 Location,OkHttp 会当作没有重定向处理。

resolve()方法支持相对地址解析。比如当前 URL 是https://api.example.com/v1/login,Location: /v2/token会解析到https://api.example.com/v2/token;Location: https://cdn.example.com/new会跨域跳转。这个逻辑用的是HttpUrl.resolve,所以没有 Location 时不会抛异常,而是返回 null。

跨 scheme 的保护很关键:默认followSslRedirects为 true,允许 HTTPS -> HTTPS、甚至 HTTP -> HTTPS 的升级跳转;但如果你手动把它设为 false,那么任何跨 scheme 的跳转都会被拦截。我见过一个线上事故:某个 API 从 HTTP 升级到 HTTPS 后,老客户端拿到 301 跳转时因为followSslRedirects = false直接失败,排查了半天才发现是这个问题。

跨 host 时清空Authorization头是另一个重要细节。你的 token 只应该发给原始域名,如果 302 跳到了第三方 CDN 域名,带着Authorization头等于把凭据泄露给不相关的主机。除了 Authorization,Cookie 和 Proxy-Authorization 也会被移除。

3.3 方法转换的完整推导

方法转换规则是重定向里最容易出 bug 的部分。我用一个表格把"原始方法 + 状态码"的最终结果列出来,方便对照:

原始方法301302303307308
GETGETGETGETGETGET
HEADHEADHEADGETHEADHEAD
POSTGET(body 丢失)GET(body 丢失)GET(body 丢失)不跟随不跟随
PUTGET(body 丢失)GET(body 丢失)GET(body 丢失)不跟随不跟随
DELETEGET(body 丢失)GET(body 丢失)GET(body 丢失)不跟随不跟随

这是 OkHttp 遵守 HTTP RFC 的做法。很多开发者以为 301 会原样重放 POST,实际上 HTTP 规范里 301/302 对 POST 的默认行为就是转 GET。如果你的业务确实需要在 301/302 时保留 POST body,OkHttp 的默认行为不能满足,只能自己拦截响应、解析 Location、手动构建带 body 的新请求并发出去。

我在某支付对接项目里就遇到过这个场景:第三方支付平台在 302 跳转时要求继续 POST 表单数据,而不是转 GET。当时在拦截器里单独对 Location 响应做了处理,手动保留请求体和 header,才解决了回调数据丢失的问题。

3.4 最大跟随次数与循环检测

MAX_FOLLOW_UPS的默认值是 20。这个数字不是随便定的:防止 A -> B -> A -> B 无限循环。followUpCount在每次构建 FollowUp 后自增,超过 20 就抛ProtocolException("Too many follow-up requests: $followUpCount")。

别小看这个限制,我做过的某个老项目中,服务端配置了一个重定向循环(401 和 302 相互跳),如果没有这个保护,客户端会陷入死循环直到 OOM。20 次看起来不多,但对真实的重定向场景完全够用,常规业务顶多两三次跳转就结束了。

4. 401 和 407 的认证重放:FollowUp 不只是重定向的专利

4.1 Authenticator 的调用时机

RetryAndFollowUpInterceptor处理的另一类 FollowUp 是认证响应。当服务端返回401 Unauthorized时,OkHttp 会调用client.authenticator().authenticate(route, response);返回407 Proxy Authentication Required时,会调用client.proxyAuthenticator().authenticate(route, response)。

如果你没有配置自定义 Authenticator,OkHttp 默认使用Authenticator.NONE:

object NONE : Authenticator { override fun authenticate(route: Route?, response: Response): Request? = null }

返回 null 意味着"不提供任何认证信息",然后拦截器会把 401 响应直接返回给调用方。这也是为什么默认情况下 OkHttp 遇到 401 不会自动做任何事。

4.2 自定义 Authenticator 实现 token 刷新重放

实际开发中,Authenticator 最常见的用途是处理 token 过期后的自动刷新并重放原请求。我常用的实现模板如下:

class TokenAuthenticator(private val tokenProvider: TokenProvider) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { // 只处理 401,避免重复刷新 if (response.code != 401) return null // 防止死循环:如果上一个请求已经带了我们要刷的新 token,仍返回 401,就放弃 val previousRequest = response.request val previousToken = previousRequest.header("Authorization") val newToken = tokenProvider.refreshToken() if (newToken == null || newToken == previousToken) return null return previousRequest.newBuilder() .header("Authorization", "Bearer $newToken") .build() } }

关键点有三个:一是只对 401 响应做处理;二是拿到新 token 之后要检查是否和旧 token 相同,避免重复刷新;三是新请求是"同一个请求,替换 Authorization 头"。

使用这个机制时,要注意与重试机制的联动:如果新 token 依然无效,服务端再次返回 401,authenticate会再次被调用。如果不加"新 token 等于旧 token 就返回 null"的检查,就会出现无限重放,直到MAX_FOLLOW_UPS抛出异常。我一度在生产环境遇到过这个坑:认证服务偶发返回同一个 token,结果客户端在 20 次循环后才爆出ProtocolException,用户体验极差。

4.3 自定义拦截器与 Authenticator 的优先级

有个常见误解:以为在自定义 Interceptor 里统一添加 Authorization 头,Authenticator 里的 token 刷新就不需要了。实际上两者是配合关系:外层 Interceptor 添加的是"当前已知 token",Authenticator 处理的是"token 失效后的挽救"。如果 token 在请求发出前已过期,外层添加旧 token,服务端返回 401,Authenticator 刷新后重放,整个流程才完整。

我建议把 token 刷新逻辑放在 Authenticator 而不是拦截器里,原因很简单:拦截器无法知道服务端是否拒绝了上一个 token,而 Authenticator 天然被设计成"响应驱动的认证补救"。

5. 源码背后能推断出的实战铁律:幂等、超时与请求头

5.1 对写操作的重试要保持极度谨慎

从源码可以推出一个明确结论:OkHttp 对连接失败的重试是自动的,但对"请求已发送后的失败"是保守的。如果你在业务里做一个下单接口,使用的是普通 JSON body(非 one-shot),当写入过程中连接断开,OkHttp 会认为请求可能已发送并尝试重试。这意味着同一笔订单可能被提交两次。

这不是 OkHttp 的 bug,而是 HTTP 层无法感知业务幂等性的天然限制。解决办法有三个方向:一是服务端做幂等键校验,客户端在请求头里传Idempotency-Key;二是在 OkHttpClient 层面把retryOnConnectionFailure设为 false,然后自己控制重试;三是对这类关键写操作使用 one-shot body,主动告诉 OkHttp "这个请求不能重放"。

第三种方法可以这样实现:

val body = object : RequestBody() { override fun contentType() = "application/json".toMediaType() override fun writeTo(sink: BufferedSink) { sink.write(jsonString.toByteArray()) } override fun isOneShot() = true // 关键! }

isOneShot()返回 true 之后,只要请求已经发送过(requestSendStarted = true),任何 IOException 都不会触发重试,直接抛给上层。代价是如果连一次都没发出去(连接阶段失败),依然会重试,但这无关紧要。

5.2 超时异常不会自动重试,别指望它兜底

前面已经提到InterruptedIOException被排除在重试之外。这意味着SocketTimeoutException(读超时、写超时)发生时不触发重试。具体表现:请求发出后服务器长时间不响应,触发readTimeout,异常直接抛到你代码里,不会自动换路由重试。

这是我反复强调的一点:不要把业务级重试寄托在 RetryAndFollowUpInterceptor 上。如果你需要"超时后重试一次"这种能力,只能自己在调用层做封装,或者在自定义 Interceptor 里手动控制。我通常在 Kotlin 协程封装层做:

suspend fun <T> retryOnTimeout(block: suspend () -> T, retries: Int = 3): T { var lastException: Exception? = null repeat(retries) { try { return block() } catch (e: SocketTimeoutException) { lastException = e delay(200) } } throw lastException!! }

这个封装只捕获超时异常,不捕获业务异常,避免无意义重放。

5.3 重定向携带请求头时的安全边界

跨 host 重定向会移除Authorization、Cookie、Proxy-Authorization三个头。这是因为 HTTP 规范要求凭据不能泄露给非同源主机。但要注意,OkHttp 只移除了这几个标准头,你自己加的自定义头(比如X-Auth-Token、X-Tenant-Id)不会自动被清除,在跨域重定向时仍然会带到新域名。

如果你在意这个安全问题,建议在自定义拦截器里做一层检查:当响应是 30x 且Location指向不同 host 时,手动从新请求里移除X-*敏感头。我在做开放平台对接时遇到过:服务端 302 跳到用户提供的回调地址,自定义的 token 头被带了过去,对方服务器日志里全都看到了。后来加了一段处理:

if (response.code in 300..399) { val location = response.header("Location") ?: return@intercept val newUrl = response.request.url.resolve(location) if (newUrl?.host != response.request.url.host) { // 跨域跳转,移除自定义敏感头 request = request.newBuilder() .removeHeader("X-Auth-Token") .build() } }

5.4 与 CacheInterceptor 的前后照应

CacheInterceptor位于这个拦截器之后,处理 HTTP 缓存。重定向响应本身也会被缓存逻辑处理。有一个容易忽略的点:301 响应默认是可以被缓存的。如果客户端第一次请求拿到 301,OkHttp 的 CacheInterceptor 可能把 301 响应缓存下来,后续请求直接命中缓存而不经过网络层。这本来是好事,但如果服务端临时配置了 301 跳转,后来撤销了,客户端可能在一段时间内仍然缓存着 301,导致请求一直被强制跳转。

我遇到过的一个实际案例:某 API 迁移期间,服务端加了 301 跳转,几天后迁移完成、跳转撤销,但用户手机上的旧缓存还在,客户端仍然自动跳到新地址。排查下来发现是 301 的缓存头(Cache-Control: max-age=86400)导致。解决办法是服务端调整缓存头,或者客户端在重大配置变更时清掉 HTTP 缓存。

6. 真实项目踩坑实录:我在这上面交过的学费

6.1 上传文件接口被自动重试,源文件流不可重读

项目背景是一个图片上传功能,用户选择一张大图后 POST 到对象存储,RequestBody里把InputStream直接写入sink。当时偶现上传失败,错误堆栈里抛的是FileNotFoundException(因为流传过一次,第二次读取时流已经关闭)。排查后确认:连接阶段失败导致 OkHttp 自动换路由重试,而重试时同一个RequestBody被再次调用writeTo,里面的InputStream已经读到头。

这类问题的解法就是前面提到的isOneShot()。但要注意:isOneShot()只影响"请求可能已发送"情况下的重试,如果请求还没发出去,recover里不会做 one-shot 检查,依然会重试。要彻底杜绝,可以自己维护状态:

class OneShotBody(private val delegate: RequestBody) : RequestBody() { private var consumed = false override fun contentType(): MediaType? = delegate.contentType() override fun contentLength(): Long = delegate.contentLength() override fun isOneShot(): Boolean = true @Synchronized override fun writeTo(sink: BufferedSink) { if (consumed) throw IOException("Body already consumed") consumed = true delegate.writeTo(sink) } }

这样即使 OkHttp 在连接失败后尝试重试第二次writeTo,也会抛异常阻止重放,而不是传脏数据上去。

6.2 302 之后 POST 变 GET,表单数据全丢

某次对接一个老系统的登录接口,按文档写的是 POST JSON 到/login,服务端返回 302 跳到/dashboard。结果发现每次跳转后 dashboard 请求变成了 GET,登录态的 cookie 虽然在,但接口文档里明确说 dashboard 需要 POST 参数,最终页面数据为空。

查了一圈,问题根源正是 301/302 的 POST 转 GET 规则。服务端的 302 是"临时重定向",HTTP 语义允许客户端把 POST 转为 GET。我们的服务端开发希望的是"继续用 POST 跳到 dashboard",但没有在 302 的响应头里做任何特殊处理,OkHttp 自然按照标准走了转 GET 逻辑。

解决方案:后台去掉 302,改成直接返回 200 和页面数据;如果必须用 302,就在客户端自定义拦截器里拦截 Location 响应,手动构造带原 body 的 POST 请求重新发出。这次事故让我彻底记住了:默认客户端遵守 HTTP 标准,不要指望它理解你们公司的特殊约定。

6.3 401 重放循环把请求打满

还有一个印象深刻的线上事故:某个内部 API 用的自定义认证头,后端鉴权服务偶发返回 401 但同时在响应体里携带了新的 token(设计得极其反常)。我在 Authenticator 里无条件刷新 token 并重放,结果当新 token 也不被接受时,客户端会不断刷新 token、不断重放,直到 20 次上限才抛异常。那段时间网关的 QPS 直接被打满。

后来在 Authenticator 里加了两个限制:一是只允许 refreshToken 一次,刷新后如果新 token 还是 401,直接返回 null 放弃重放;二是对同一请求的 401 重放次数做计数,超过 2 次就终止。从那次以后我才明白,Authenticator 是重放控制的关键节点,必须内置防呆设计。

class SafeAuthenticator : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { if (response.code != 401) return null val attempt = response.priorResponse?.code == 401 // 看是不是第二次 401 if (attempt) return null // 第二次还失败就放弃 val newToken = tokenProvider.refreshToken() ?: return null return response.request.newBuilder() .header("Authorization", "Bearer $newToken") .build() } }

多提一句priorResponse:每次 FollowUp 之后,前一个响应会被包装成priorResponse放到新响应上。所以检查response.priorResponse?.code == 401就能判断这是不是第二次 401 了,非常方便。

6.4 自定义拦截器里的重试计数日志

最后分享一个我现在做线上排查时的常用习惯:在自定义 Application Interceptor 里给请求加一个重试序号,方便日志链路追踪。

class RetryMarkerInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val tryNumber = request.header("X-Try-Number")?.toIntOrNull() ?: 0 val newRequest = request.newBuilder() .header("X-Try-Number", (tryNumber + 1).toString()) .build() val response = chain.proceed(newRequest) return response.newBuilder() .header("X-Retry-Info", "try=$tryNumber") .build() } }

配上网关侧的X-Try-Number日志,就能一眼看出某个请求到底被 OkHttp 自动重试了几次,以及是在哪一步失败的。这个技巧帮我定位过不少"偶发超时"问题,强烈建议在正式项目里保留。

回看这一整套逻辑,RetryAndFollowUpInterceptor的设计思路其实很清晰:连接层失败可以大胆重试,应用层失败交给 Authenticator 和开发者自己控制,重定向严格遵循 HTTP 语义。理解它之后,很多关于 OkHttp 的"玄学问题"都会变成可预期的行为。下次再遇到日志里同一个请求出现两次,先不要怀疑是并发重复,翻一下是不是这个拦截器在底层帮你做了额外的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进