关于HttpClient重试策略的研究

1、背景

  因为工做上的业务本人常常与第三方系统交互,因此常常会使用HttpClient与第三方进行通讯。对于交易类的接口,订单状态是相当重要的。java

  这就牵扯到一系列问题:apache

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

  接下来,本文将从源码中探讨这些问题。源码下载地址:http://hc.apache.org/downloads.cgi,版本是4.5.5。服务器

2、通常使用方法

  通常而言,得到HttpClient实例的方法有两种:cookie

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

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

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

这两种方法得到都是CloseableHttpClient实例,且都是经过HttpClientBuilder的build()构建的。异步

3、有没有重试策略

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

    public CloseableHttpClient build() {
        //省略若干行
        return new InternalHttpClient(
                execChain,
                connManagerCopy,
                routePlannerCopy,
                cookieSpecRegistryCopy,
                authSchemeRegistryCopy,
                defaultCookieStore,
                defaultCredentialsProvider,
                defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,
                closeablesCopy);
    }

}

这里有不少配置化参数,这里咱们重点关注一下execChain这个执行链。ide

能够看到执行链有多种实现,好比

  1. RedirectExec执行器的默认策略是,在接收到重定向错误码301与307时会继续访问重定向的地址
  2. 以及咱们关注的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,这里看下他的执行发送数据的方法。

    @Override
    protected CloseableHttpResponse doExecute(
            final HttpHost target,
            final HttpRequest request,
            final HttpContext context) throws IOException, ClientProtocolException {
            //省略一些代码
return this.execChain.execute(route, wrapper, localcontext, execAware); } }

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

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

4、重试策略分析

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执行器的执行过程,作一个阶段小结:

  1.   RetryExec在执行http请求的时候使用的是底层的基础代码MainClientExec,并记录了发送次数
  2.   当发生IOException的时候,判断是否要重试
    1.     首先是根据重试策略DefaultHttpRequestRetryHandler判断,若是能够重试就继续
      1.      判断当前request是否还能够再次发起
    2.   若是重试策略判断不能够重试了,就抛相应异常并退出

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);
        }
    }

经过构造器能够看到,默认的重试策略是:

  1. 重试3次
  2. 若是请求被成功发送过,就再也不重试了
  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;
    }
    }

 

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

  1. 若是重试超过3次,则再也不重试
  2. 几种特殊异常及其子类,不进行重试
  3. 同一个请求在异步任务重已经被终止,则不进行重试
  4. 幂等的方法能够进行重试,好比Get
  5. 若是请求没有发送成功,能够进行重试。

 

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

    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通讯部分,步骤以下:

  1. 开始前将http.request_sent置为false
  2. 经过流flush数据到服务端
  3. 而后将http.request_sent置为true

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

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

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

5.1 咱们的业务重试了吗?

  对于咱们的场景应用中的get与post,能够总结为:

  1. 只有发生IOExecetion时才会发生重试
  2. InterruptedIOException、UnknownHostException、ConnectException、SSLException,发生这4中异常不重试
  3. get方法能够重试3次,post方法在socket对应的输出流没有被write并flush成功时能够重试3次。

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

  1. InterruptedIOException,线程中断异常
  2. UnknownHostException,找不到对应host
  3. ConnectException,找到了host可是创建链接失败。
  4. SSLException,https认证异常

  另外,咱们还常常会提到两种超时,链接超时与读超时:

  1. java.net.SocketTimeoutException: Read timed out
  2. 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也会进行重试,不过范围过广很差定位。

6、如何禁止重试?

回到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;
    }

7、本文总结

经过本文分析,能够得知HttpClient默认是有重试机制的,其重试策略是:

  1.只有发生IOExecetion时才会发生重试

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

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

  4.读/写超时不进行重试

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

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

相关文章
相关标签/搜索