关于HttpClient重试策略的研究

崔世宁

16年毕业加入 Qunar 金融事业部,两年过去了,仍然是一只萌新。

一、背景

由于工作上的业务本人经常与第三方系统交互,所以经常会使用 HttpClient 与第三方进行通信。对于交易类的接口,订单状态是至关重要的。

这就牵扯到一系列问题:

HttpClient 是否有默认的重试策略?重试策略原理?如何禁止重试?

接下来,本文将从源码中探讨这些问题。

源码下载地址:

,版本是4.5.5。

二、一般使用方法

一般而言,获得 HttpClient 实例的方法有两种:

1.HttpClients.custom().setXXX().build()

2.HttpClients.build()

第一种方法用来定制一些 HttpClient 的属性,比如 https 证书,代理服务器, http 过滤器,连接池管理器等自定义的用法。

第二种方法用来获得一个默认的 HttpClient 实例。

这两种方法获得都是 CloseableHttpClient 实例,且都是通过 HttpClientBuilder 的 build() 构建的。

可以看到,上面的两种用法最终都得到了一个 InternalHttpClient ,是抽象类 CloseableHttpClient 的一种实现。

public CloseableHttpClient build() {

       //省略若干行

       return new InternalHttpClient(

               execChain,

               connManagerCopy,

               routePlannerCopy,

               cookieSpecRegistryCopy,

               authSchemeRegistryCopy,

               defaultCookieStore,

               defaultCredentialsProvider,

               defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,

               closeablesCopy);

   }

}

这里有很多配置化参数,这里我们重点关注一下 execChain 这个执行链。

可以看到执行链有多种实现,比如:

RedirectExec 执行器的默认策略是,在接收到重定向错误码301与307时会继续访问重定向的地址;

以及我们关注的 RetryExec 可以重试的执行器。这么多执行器,是怎么用到了重试执行器呢?

public CloseableHttpClient build() {

   //省略一些代码  

       // Add request retry executor, if not disabled

       if (!automaticRetriesDisabled) {

           HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;

           if (retryHandlerCopy == null) {

               retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;

           }

           execChain = new RetryExec(execChain, retryHandlerCopy);

       }  

}

可以看到在 build() httpclient 实例的时候,判断了是否关闭了自动重试,这个 AutomaticRetriesDisabled 类型是 boolean ,默认值是 false ,所以 if 这里是满足的。

即如果没有指定执行链,就是用 RetryExec 执行器,默认的重试策略是 DefaultHttpRequestRetryHandler 。

前面已经看到我们使用的 HttiClient 本质上是 InternalHttpClient ,这里看下他的执行发送数据的方法。

   protected CloseableHttpResponse doExecute(

           final HttpHost target,

           final HttpRequest request,

           final HttpContext context) throws IOException, ClientProtocolException {

           //省略一些代码

           return this.execChain.execute(route, wrapper, localcontext, execAware);

       }

   }

最后一行可以看到,最终的执行 execute 方式使用的是 execChain 的执行方法,而 execChain 是通过 InternalHttpClient 构造器传进来的,就是上面看到的 RetryExec 。

所以, HttpClient 有默认的执行器 RetryExec ,其默认的重试策略是 DefaultHttpRequestRetryHandler 。

四、重试策略分析

4.1 是否需要重试的判断在哪里?

http 请求是执行器执行的,所以先看 RetryExec 发送请求的部分。

public CloseableHttpResponse execute(

           final HttpRoute route,

           final HttpRequestWrapper request,

           final HttpClientContext context,

           final HttpExecutionAware execAware) throws IOException, HttpException {

       //参数校验

       Args.notNull(route, "HTTP route");

       Args.notNull(request, "HTTP request");

       Args.notNull(context, "HTTP context");

       final Header[] origheaders = request.getAllHeaders();

      //这个for循环记录了当前http请求的执行次数

       for (int execCount = 1;; execCount++) {

           try {

          //调用基础executor执行http请求

               return this.requestExecutor.execute(route, request, context, execAware);

           } catch (final IOException ex) {

          //发生IO异常的时候,判断上下文是否已经中断,如果中断则抛异常退出

               if (execAware != null && execAware.isAborted()) {

                   this.log.debug("Request has been aborted");

                   throw ex;

               }

               //根据重试策略,判断当前执行状况是否要重试,如果是则进入下面逻辑

               if (retryHandler.retryRequest(ex, execCount, context)) {

            //日志

                   if (this.log.isInfoEnabled()) {

                       this.log.info("I/O exception ("+ ex.getClass().getName() +

                               ") caught when processing request to "

                               + route +

                               ": "

                               + ex.getMessage());

                   }

            //日志

                   if (this.log.isDebugEnabled()) {

                       this.log.debug(ex.getMessage(), ex);

                   }

            //判断当前请求是否可以被重复发起

                   if (!RequestEntityProxy.isRepeatable(request)) {

                       this.log.debug("Cannot retry non-repeatable request");

                       throw new NonRepeatableRequestException("Cannot retry request " +

                               "with a non-repeatable request entity", ex);

                   }

                   request.setHeaders(origheaders);

                   if (this.log.isInfoEnabled()) {

                       this.log.info("Retrying request to " + route);

                   }

               } else {

            //如果重试策略判断不能重试了,则根据异常状态抛异常,退出当前流程

                   if (ex instanceof NoHttpResponseException) {

                       final NoHttpResponseException updatedex = new NoHttpResponseException(

                               route.getTargetHost().toHostString() + " failed to respond");

                       updatedex.setStackTrace(ex.getStackTrace());

                       throw updatedex;

                   } else {

                       throw ex;

                   }

               }

           }

       }

   }

关于 RetryExec 执行器的执行过程,做一个阶段小结:

RetryExec 在执行 http 请求的时候使用的是底层的基础代码 MainClientExec ,并记录了发送次数;

当发生 IOException 的时候,判断是否要重试;

首先是根据重试策略 DefaultHttpRequestRetryHandler 判断,如果可以重试就继续;

判断当前 request 是否还可以再次发起;

如果重试策略判断不可以重试了,就抛相应异常并退出。

4.2 DefaultHttpRequestRetryHandler 的重试策略

在上文我们看到了默认的重试策略是 DefaultHttpRequestRetryHandler.INSTANCE 。

//单例模式

   public static final DefaultHttpRequestRetryHandler INSTANCE = new DefaultHttpRequestRetryHandler();

   //重试次数

   private final int retryCount;

   //如果一个请求发送成功过,是否还会被再次发送

   private final boolean requestSentRetryEnabled;

   private final Set<Class<? extends IOException>> nonRetriableClasses;

   public DefaultHttpRequestRetryHandler() {

       this(3, false);

   }

   public DefaultHttpRequestRetryHandler(final int retryCount, final boolean requestSentRetryEnabled) {

       this(retryCount, requestSentRetryEnabled, Arrays.asList(

               InterruptedIOException.class,

               UnknownHostException.class,

               ConnectException.class,

               SSLException.class));

   }

   protected DefaultHttpRequestRetryHandler(

           final int retryCount,

           final boolean requestSentRetryEnabled,

           final Collection<Class<? extends IOException>> clazzes) {

       super();

       this.retryCount = retryCount;

       this.requestSentRetryEnabled = requestSentRetryEnabled;

       this.nonRetriableClasses = new HashSet<Class<? extends IOException>>();

       for (final Class<? extends IOException> clazz: clazzes) {

           this.nonRetriableClasses.add(clazz);

       }

   }

通过构造器可以看到,默认的重试策略是:

重试3次;

如果请求被成功发送过,就不再重试了;

InterruptedIOException、UnknownHostException、ConnectException、SSLException ,发生这4中异常不重试。

说句题外话,这是一个单例模式,属于饿汉模式。 饿汉模式的缺点是,这个类在被加载的时候就会初始化这个对象,对内存有占用。不过这个对象维护的 filed 比较小,所以对内存的影响不大。 另外由于这个类所有的 field 都是 final 的,所以是一个不可变的对象,是线程安全的。

public boolean retryRequest(

           final IOException exception,

           final int executionCount,

           final HttpContext context) {

       //参数校验

       Args.notNull(exception, "Exception parameter");

       Args.notNull(context, "HTTP context");

     //如果已经执行的次数大于设置的次数,则不继续重试

       if (executionCount > this.retryCount) {

           return false;

       }

     //如果是上面规定的几种异常,则不重试

       if (this.nonRetriableClasses.contains(exception.getClass())) {

           return false;

       } else {

       //如果是上面规定的集中异常的子类,则不重试

           for (final Class<? extends IOException> rejectException : this.nonRetriableClasses) {

               if (rejectException.isInstance(exception)) {

                   return false;

               }

           }

       }

       final HttpClientContext clientContext = HttpClientContext.adapt(context);

       final HttpRequest request = clientContext.getRequest();

     //判断当前请求是否已经被终止了,这个是避免当前请求被放入异步的异步的HttpRequestFutureTask中

     //跟进去可以看到,当这个异步任务被cancel的时候,会通过AtomicBoolean的compareAndSet的方法,保证状态被更改

     //这部分不做详细讨论了

       if(requestIsAborted(request)){

           return false;

       }

     //判断请求是否是幂等请求,跟进去可以看到,所有包含http body的请求都认为是非幂等的,比如post/put等

     //幂等的请求可以直接重试,比如get

       if (handleAsIdempotent(request)) {

           return true;

       }

     //根据上下文判断请求是否发送成功了,或者根据状态为是否永远可以重复发送(默认的是否)

     //这个下面会分析

       if (!clientContext.isRequestSent() || this.requestSentRetryEnabled) {

           return true;

       }

       //否则不需要重试

       return false;

   }

   }

关于默认的重试策略,做一个阶段小结:

如果重试超过3次,则不再重试;

几种特殊异常及其子类,不进行重试;

同一个请求在异步任务重已经被终止,则不进行重试;

幂等的方法可以进行重试,比如 Get ;

如果请求没有发送成功,可以进行重试。

那么关键问题来了,如何判断请求是否已经发送成功了呢?

public static final String HTTP_REQ_SENT    = "http.request_sent";

   public boolean isRequestSent() {

       final Boolean b = getAttribute(HTTP_REQ_SENT, Boolean.class);

       return b != null && b.booleanValue();

   }

可看到如果当前的 httpContext 中的 http.request_sent 属性为 true ,则认为已经发送成功,否则认为还没有发送成功。

那么就剩下一个问题了,一次正常的 http 请求中 http.request_sent 属性是如果设置的?

上面有提到过, RetryExec 在底层通信使用了 MainClientExec ,而 MainCLientExec 底层调用了 HttpRequestExecutor.doSendRequest() 

protected HttpResponse doSendRequest(

           final HttpRequest request,

           final HttpClientConnection conn,

           final HttpContext context) throws IOException, HttpException {

           Args.notNull(request, "HTTP request");

       Args.notNull(conn, "Client connection");

       Args.notNull(context, "HTTP context");

       HttpResponse response = null;

       context.setAttribute(HttpCoreContext.HTTP_CONNECTION, conn);

     //首先在请求发送之前,将http.request_sent放入上下文context的属性中,值为false

       context.setAttribute(HttpCoreContext.HTTP_REQ_SENT, Boolean.FALSE);

      //将request的Header放入连接中

       conn.sendRequestHeader(request);

       //如果是post/put这种有body的请求,需要先判断100-cotinue扩展协议是否支持

     //即发送包含body请求前,先判断服务端是否支持同样的协议如果不支持,则不发送了。除非特殊约定,默认双端是都不设置的。

       if (request instanceof HttpEntityEnclosingRequest) {

           boolean sendentity = true;

           final ProtocolVersion ver =

               request.getRequestLine().getProtocolVersion();

           if (((HttpEntityEnclosingRequest) request).expectContinue() &&

               !ver.lessEquals(HttpVersion.HTTP_1_0)) {

               conn.flush();

               if (conn.isResponseAvailable(this.waitForContinue)) {

                   response = conn.receiveResponseHeader();

                   if (canResponseHaveBody(request, response)) {

                       conn.receiveResponseEntity(response);

                   }

                   final int status = response.getStatusLine().getStatusCode();

                   if (status < 200) {

                       if (status != HttpStatus.SC_CONTINUE) {

                           throw new ProtocolException(

                                   "Unexpected response: " + response.getStatusLine());

                       }

                       // discard 100-continue

                       response = null;

                   } else {

                       sendentity = false;

                   }

               }

           }

       //如果可以发送,则将body序列化后,写入当前流中

           if (sendentity) {

               conn.sendRequestEntity((HttpEntityEnclosingRequest) request);

           }

       }

     //刷新当前连接,发送数据

       conn.flush();

     //将http.request_sent置为true

       context.setAttribute(HttpCoreContext.HTTP_REQ_SENT, Boolean.TRUE);

       return response;

   }

上面是一个完成的 http 通信部分,步骤如下:

开始前将 http.request_sent 置为 false ;

通过流 flush 数据到服务端;

然后将 http.request_sent 置为 true 。

显然,对于 conn.flush() 这一步是会发生异常的,这种情况下就认为没有发送成功。

说句题外话,上面对 coon 的操作都是基于连接池的,每次都是从池中拿到一个可用连接。

五、重试策略对业务的影响

5.1 我们的业务重试了吗?

对于我们的场景应用中的 get 与 post ,可以总结为:

只有发生IOExecetion时才会发生重试;

InterruptedIOException、UnknownHostException、ConnectException、SSLException ,发生这4中异常不重试;

get 方法可以重试3次, post 方法在 socket 对应的输出流没有被 write 并 flush 成功时可以重试3次。

首先分析下不重试的异常:

InterruptedIOException ,线程中断异常;

UnknownHostException ,找不到对应 host ;

ConnectException ,找到了 host 但是建立连接失败;

SSLException , https 认证异常。

另外,我们还经常会提到两种超时,连接超时与读超时:

java.net.SocketTimeoutException: Read timed out;

java.net.SocketTimeoutException: connect timed out 这两种超时都是 SocketTimeoutException ,继承自 InterruptedIOException ,属于上面的第1种线程中断异常,不会进行重试。

5.2 哪些场景会进行重试?

对于大多数系统而言,很多交互都是通过 post 的方式与第三方交互的。

所以,我们需要知道有哪些情况 HttpClient 给我们进行了默认重试。

我们关心的场景转化为, post 请求在输出流进行 write 与 flush 的时候,会发生哪些除了 InterruptedIOException、UnknownHostException、ConnectException、SSLException 以外的 IOExecetion 。

可能出问题的一步在于 HttpClientConnection.flush() 的一步,跟进去可以得知其操作的对象是一个 SocketOutputStream ,而这个类的 flush 是空实现,所以只需要看 wirte 方法即可。

private void socketWrite(byte b[], int off, int len) throws IOException {

       if (len <= 0 || off < 0 || len > b.length - off) {

           if (len == 0) {

               return;

           }

           throw new ArrayIndexOutOfBoundsException("len == " + len

                   + " off == " + off + " buffer length == " + b.length);

       }

       FileDescriptor fd = impl.acquireFD();

       try {

           socketWrite0(fd, b, off, len);

       } catch (SocketException se) {

           if (se instanceof sun.net.ConnectionResetException) {

               impl.setConnectionResetPending();

               se = new SocketException("Connection reset");

           }

           if (impl.isClosedOrPending()) {

               throw new SocketException("Socket closed");

           } else {

               throw se;

           }

       } finally {

           impl.releaseFD();

       }

   }

可以看到,这个方法会抛出 IOExecption ,代码中对 SocketException 异常进行了加工。从之前的分析中可以得知, SocketException 是不在可以忽略的范围内的。

所以从上面代码上就可以分析得出对于传输过程中 socket 被重置或者关闭的时候, httpclient 会对 post 请求进行重试。

以及一些其他的 IOExecption 也会进行重试,不过范围过广不好定位。

六、如何禁止重试?

回到 HttpClientBuilder 中,其 build() 方法中之所以选择了 RetryExec 执行器是有前置条件的,即没有手动禁止。

// Add request retry executor, if not disabled

       if (!automaticRetriesDisabled) {

           HttpRequestRetryHandler retryHandlerCopy = this.retryHandler;

           if (retryHandlerCopy == null) {

               retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE;

           }

           execChain = new RetryExec(execChain, retryHandlerCopy);

       }

所以我们在构建 HttpClient 实例的时候手动禁止掉即可。

   /**

    * Disables automatic request recovery and re-execution.

    */

   public final HttpClientBuilder disableAutomaticRetries() {

       automaticRetriesDisabled = true;

       return this;

   

七、本文总结

通过本文分析,可以得知 HttpClient 默认是有重试机制的,其重试策略是:

只有发生 IOExecetion 时才会发生重试;

InterruptedIOException、UnknownHostException、ConnectException、SSLException ,发生这4中异常不重试;

get 方法可以重试3次, post 方法在 socket 对应的输出流没有被 write 并 flush 成功时可以重试3次;

读/写超时不进行重试;

socket 传输中被重置或关闭会进行重试;

以及一些其他的 IOException ,暂时分析不出来。