浅析 OkHttp 拦截器之 RetryAndFollowUpInterceptor - Go语言中文社区

浅析 OkHttp 拦截器之 RetryAndFollowUpInterceptor


本文目录

1 概述

除了用户自定义的拦截器(比如打打日志),该拦截器处于链条的头部

1.1 核心功能

  • 连接失败重试(Retry)

    在发生 RouteException 或者 IOException 后,会捕获建联或者读取的一些异常,根据一定的策略判断是否是可恢复的,如果可恢复会重新创建 StreamAllocation 开始新的一轮请求

  • 继续发起请求(Follow up)

    主要有这几种类型

    • 3xx 重定向
    • 401,407 未授权,调用 Authenticator 进行授权后继续发起新的请求
    • 408 客户端请求超时,如果 Request 的请求体没有被 UnrepeatableRequestBody 标记,会继续发起新的请求

其中 Follow up 的次数受到MAX_FOLLOW_UP 约束,在 OkHttp 中为 20 次,这样可以防止重定向死循环

   /**
     * How many redirects and auth challenges should we attempt? Chrome follows 21 redirects; Firefox,
     * curl, and wget follow 20; Safari follows 16; and HTTP/1.0 recommends 5.
     */
    private static final int MAX_FOLLOW_UPS = 20;

1.2 活动图

流程

2 失败重试

后面的环节会进行路由选择、建立连接、发起请求并得到响应等等。而如果建立连接失败,比如未知主机,三次握手超时,读取超时等等都会导致请求失败。有一些失败是可以恢复的,而有些失败是不可以恢复的

失败重试核心代码:

public Response intercept(Chain chain) throws IOException {
    ...
    while (true) {
      ...
      try {
        response = ((RealInterceptorChain) chain).proceed(request, streamAllocation, null, null);
        releaseConnection = false;
      } catch (RouteException e) {
        // The attempt to connect via a route failed. The request will not have been sent.
        if (!recover(e.getLastConnectException(), true, request)) throw e.getLastConnectException();
        releaseConnection = false;
        continue;
      } catch (IOException e) {
        // An attempt to communicate with a server failed. The request may have been sent.
        if (!recover(e, false, request)) throw e;
        releaseConnection = false;
        continue;
      } finally {
        // We're throwing an unchecked exception. Release any resources.
        if (releaseConnection) {
          streamAllocation.streamFailed(null);
          streamAllocation.release();
        }
      }
      ...
}

2.1 异常捕获

在后面的拦截器的请求失败,会抛出两种异常

  • RouteException

    这个异常发生在 Request 请求还没有发出去前,就是打开 Socket 连接失败。这个异常是 OkHttp 自定义的异常,是一个包裹类,包裹住了建联失败中发生的各种 Exception

    主要发生 ConnectInterceptor 建立连接环节

    比如连接超时抛出的 SocketTimeoutException,包裹在 RouteException 中

  • IOException

    这个异常发生在 Request 请求发出并且读取 Response 响应的过程中,TCP 已经连接,或者 TLS 已经成功握手后,连接资源准备完毕

    主要发生在 CallServerInterceptor 中,通过建立好的通道,发送请求并且读取响应的环节

    比如读取超时抛出的 SocketTimeoutException

2.2 重试判断

在捕获到这两种异常后,OkHttp 会使用 recover 方法来判断是否是不可以重试的。然后有两种处理方式:

  • 不可重试的

    会把继续把异常抛出,调用 StreamAllocation 的 streamFailedrelease 方法释放资源,结束请求。OkHttp 有个黑名单机制,用来记录发起失败的 Route,从而在连接发起前将之前失败的 Route 延迟到最后再使用,streamFailed 方法可以将这个出问题的 route 记录下来,放到黑名单(RouteDatabase)。所以下一次发起新请求的时候,上次失败的 Route 会延迟到最后再使用,提高了响应成功率

  • 可以重试的

    则继续使用 StreamAllocation 开始新的 proceed 。是不是可以无限重试下去?并不是,每一次重试,都会调用 RouteSelector 的 next 方法获取新的 Route,当没有可用的 Route 后就不会再重试了

什么情况下的失败是不可以恢复的呢?

/**
 * Report and attempt to recover from a failure to communicate with a server. Returns true if
 * {@code e} is recoverable, or false if the failure is permanent. Requests with a body can only
 * be recovered if the body is buffered.
 */
private boolean recover(IOException e, boolean routeException, Request userRequest) {
  streamAllocation.streamFailed(e);

  // The application layer has forbidden retries.
  if (!client.retryOnConnectionFailure()) return false;

  // We can't send the request body again.
  if (!routeException && userRequest.body() instanceof UnrepeatableRequestBody) return false;

  // This exception is fatal.
  if (!isRecoverable(e, routeException)) return false;

  // No more routes to attempt.
  if (!streamAllocation.hasMoreRoutes()) return false;

  // For failure recovery, use the same route selector with a new connection.
  return true;
}

简单分析 recover 的代码,首先会调用 StreamAllocation 的 streamFailed 方法释放资源。然后通过以下策略来判断这些类型是否可以重试:

1. 没有配置允许连接失败的重试

需要进行配置

    OkHttpClient okHttpClient = new OkHttpClient.Builder()
           ...
           .retryOnConnectionFailure(true)
           ...
           .build();

2. 不是被 RouteException 包裹的异常,并且请求的内容被 UnrepeatableRequestBody 标记

也就是不是建立连接阶段发生的异常,比如发起请求和获取响应的时候发生的 IOException,同时请求的内容不可重复发起,就不能重试

3. 使用 isRecoverable 方法过滤掉不可恢复异常

通过的话会继续进入第 4 步。不可重试的异常有:

  • ProtocolException

    协议异常,主要发生在 RealConnection 中创建 HTTPS 通过 HTTP 代理进行连接重试超过 21 次。不可以重试

    private void buildTunneledConnection(int connectTimeout, int readTimeout, int writeTimeout,
    ConnectionSpecSelector connectionSpecSelector) throws IOException {
         ...
         int attemptedConnections = 0;
         int maxAttempts = 21;
         while (true) {
           if (++attemptedConnections > maxAttempts) {
             throw new ProtocolException("Too many tunnel connections attempted: " + maxAttempts);
           }
          ...
          }
      ...
    }
  • InterruptedIOException

    如果是建立连接时 SocketTimeoutException,即创建 TCP 连接超时,会走到第4步

    如果是连接已经建立,在读取响应的超时的 SocketTimeoutException,不可恢复

  • CertificateException 引起的 SSLHandshakeException

    证书错误导致的异常,比如证书制作错误

  • SSLPeerUnverifiedException

    访问网站的证书不在你可以信任的证书列表中

4. 已经没有其他的路由可以使用

前面三步条件都通过的,还需要最后一步检验,就是获取可用的 Route。

   public boolean hasMoreRoutes() {
       return route != null || routeSelector.hasNext();
   }  

所以满足下面两个条件就可以结束请求,释放 StreamAllocation 的资源了

  • StreamAllocation 的 route 为空
  • RouteSelector 没有可选择的路由了
    • 没有下一个 IP
    • 没有下一个代理
    • 没有下一个延迟使用的 Route(之前有失败过的路由,会在这个列表中延迟使用)

RouteSelector 封装了选择可用路由进行连接的策略。重试一个重要作用,就是这个请求存在多个代理,多个 IP 情况下,OkHttp 帮我们在连接失败后换了个代理和 IP,而不是同一个代理和 IP 反复重试。所以,理所当然的,如果没有其他 IP 了,那么就会停止。比如 DNS 对域名解析后会返回多个 IP。比如有三个 IP,IP1,IP2 和 IP3,第一个连接超时了,会换成第二个;第二个又超时了,换成第三个;第三个还是不给力,那么请求就结束了

但是 OkHttp 在执行以上策略前,也就是 RouteSelector 内部的策略前,还有一个判断,就是该 StreamAllocation 的当前 route 是否为空,如果不为空话会继续使用该 route 而没有走入到 RouteSelector 的策略中。

这样子,是否会有这样一个场景,如果每次失败过来,前面3个条件都通过了,而且又满足 route 不为空,然后死循环,然后失败的连接无法释放,应用内存泄漏,最终 OOM?所以 StreamAllocation 的 route 为空,是一个非常重要退出重试条件,如果 route 一直不会空,且前面的几个条件又都满足,真的会发生严重的内存泄漏。那什么时候 route 为空?在 recover 方法的第一行,调用 streamAllocation.streamFailed 方法

  public void streamFailed(IOException e) {
    boolean noNewStreams = false;

    synchronized (connectionPool) {
      if (e instanceof StreamResetException) {
        StreamResetException streamResetException = (StreamResetException) e;
        if (streamResetException.errorCode == ErrorCode.REFUSED_STREAM) {
          refusedStreamCount++;
        }
        // On HTTP/2 stream errors, retry REFUSED_STREAM errors once on the same connection. All
        // other errors must be retried on a new connection.
        if (streamResetException.errorCode != ErrorCode.REFUSED_STREAM || refusedStreamCount > 1) {
          noNewStreams = true;
          route = null;
        }
      } else if (connection != null && !connection.isMultiplexed()) {
        noNewStreams = true;

        // If this route hasn't completed a call, avoid it for new connections.
        if (connection.successCount == 0) {
          if (route != null && e != null) {
            routeSelector.connectFailed(route, e);
          }
          route = null;
        }
      }
    }

    deallocate(noNewStreams, false, true);
  }

所以 route 置为空有两种情况

  • Http/2 的 StreamResetException,如果是第一次出现 ErrorCode.REFUSED_STREAM 不会置空,其它都会置空
  • 非 SPDY 协议且 connection 的 successCount 为 0,即该 Route 对应的 Connection 一次都没成功过

除了这两种情况下,route 在 streamFailed 都不会置空,所以下一次连接前,还会继续使用该 route。目前还未确定,是否真有满足重试的条件且 route 始终不为 null,不停地重试的现象

2.3 死循环问题

怀疑某些路径会引起不断重试。最终内存泄漏

由于出现异常,进行重试是在一个死循环中,而且处理后不是 continue 就是 throw 出异常,所以必定不受继续请求的最大次数 MAX_FOLLOW_UPS 限制。于是,如果出现某个路径,导致 recover 的判断每次都可以通过,那么就死循环了,该连接就无法释放,积累起来最后触发 OOM

目前仅仅是猜测,尚未发现有这样的路径

3 继续请求

如果连接成功,也获得了 Response 响应,但是不一定是 200 OK,还有一些其他情况。比如 3xx 重定向,401 未授权等,这些响应码是允许我们再次发起请求的。比如重定向,获取目标地址后,再次发起请求。又比如 401 未授权,可以在 Request 中新增头部 “Authorization” 授权信息再次发起请求等。OkHttp 帮我们实现了这些功能

  Request followUp = followUpRequest(response);

在方法 followUpRequest 对 response 的响应码进行判断,来确定是否支持再次发起请求,具体有这几种类型支持再次请求

3.1 未授权

会使用 Authenticator 来添加授权信息后重新发起请求

  • 407 代理未授权

    在请求中添加 “Proxy-Authorization”

  • 401 未授权

    在请求中添加 “Authorization”

3.2 重定向

并不是所有 3xx 都支持,有做区分

  • 307 和 308

    如果不是 GET 或者 HEAD 请求不进行重定向

  • 300,301,302,303

    均允许重定向

具体的流程如下:

  1. 从响应中获取 “Location”
  2. 跳转到不支持协议不能重定向
  3. HTTPS 和 HTTP 之间的重定向,需要根据配置 followSslRedirects 来判断
  4. 去掉请求体,并响应地去掉 “Transfer-Encoding”,“Content-Length”,“Content-Type” 等头部信息
  5. 如果不是同一个连接(比如 host 不同),去掉授权头部 “Authorization”

3.3 超时

只处理一种情况

  • 408 客户端超时

    部分服务器会因为客户端请求时间太长而返回 408,此时如果请求体没有实现标记接口 UnrepeatableRequestBody, OkHttp 会再把之前的请求没有修改重新发出

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/firefile/article/details/75346937
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-03-01 19:55:44
  • 阅读 ( 850 )
  • 分类:

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢