From 1d56c27e6d40208a46af19a1caf964b3de53db30 Mon Sep 17 00:00:00 2001 From: Michael Osipov Date: Thu, 5 Dec 2019 17:57:52 +0100 Subject: [PATCH] HTTPCLIENT-2034: Introduce HttpRequestRetryStrategy --- .../sync/TestClientRequestExecution.java | 44 ++- .../http/HttpRequestRetryStrategy.java | 90 ++++++ .../hc/client5/http/impl/ChainElements.java | 5 +- .../impl/DefaultHttpRequestRetryStrategy.java | 232 +++++++++++++++ .../impl/async/AsyncHttpRequestRetryExec.java | 187 ++++++++++++ .../http/impl/async/H2AsyncClientBuilder.java | 34 ++- .../impl/async/HttpAsyncClientBuilder.java | 34 ++- .../http/impl/classic/HttpClientBuilder.java | 35 ++- .../impl/classic/HttpRequestRetryExec.java | 160 +++++++++++ .../TestDefaultHttpRequestRetryStrategy.java | 158 ++++++++++ .../classic/TestHttpRequestRetryExec.java | 269 ++++++++++++++++++ 11 files changed, 1220 insertions(+), 28 deletions(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/HttpRequestRetryStrategy.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultHttpRequestRetryStrategy.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncHttpRequestRetryExec.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpRequestRetryExec.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/TestDefaultHttpRequestRetryStrategy.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestHttpRequestRetryExec.java diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientRequestExecution.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientRequestExecution.java index d56a6bece..d9be97e54 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientRequestExecution.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientRequestExecution.java @@ -30,7 +30,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; -import org.apache.hc.client5.http.HttpRequestRetryHandler; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.protocol.HttpClientContext; @@ -44,6 +44,7 @@ import org.apache.hc.core5.http.HttpException; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.impl.io.HttpRequestExecutor; import org.apache.hc.core5.http.io.HttpClientConnection; @@ -54,6 +55,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.net.URIBuilder; +import org.apache.hc.core5.util.TimeValue; import org.junit.Assert; import org.junit.Test; @@ -122,7 +124,7 @@ public class TestClientRequestExecution extends LocalServerTestBase { }; - final HttpRequestRetryHandler requestRetryHandler = new HttpRequestRetryHandler() { + final HttpRequestRetryStrategy requestRetryStrategy = new HttpRequestRetryStrategy() { @Override public boolean retryRequest( @@ -133,12 +135,28 @@ public class TestClientRequestExecution extends LocalServerTestBase { return true; } + @Override + public boolean retryRequest( + final HttpResponse response, + final int executionCount, + final HttpContext context) { + return false; + } + + @Override + public TimeValue getRetryInterval( + final HttpResponse response, + final int executionCount, + final HttpContext context) { + return TimeValue.ofSeconds(1L); + } + }; this.httpclient = this.clientBuilder .addRequestInterceptorFirst(interceptor) .setRequestExecutor(new FaultyHttpRequestExecutor("Oppsie")) - .setRetryHandler(requestRetryHandler) + .setRetryStrategy(requestRetryStrategy) .build(); final HttpHost target = start(); @@ -163,7 +181,7 @@ public class TestClientRequestExecution extends LocalServerTestBase { public void testNonRepeatableEntity() throws Exception { this.server.registerHandler("*", new SimpleService()); - final HttpRequestRetryHandler requestRetryHandler = new HttpRequestRetryHandler() { + final HttpRequestRetryStrategy requestRetryStrategy = new HttpRequestRetryStrategy() { @Override public boolean retryRequest( @@ -174,11 +192,27 @@ public class TestClientRequestExecution extends LocalServerTestBase { return true; } + @Override + public boolean retryRequest( + final HttpResponse response, + final int executionCount, + final HttpContext context) { + return false; + } + + @Override + public TimeValue getRetryInterval( + final HttpResponse response, + final int executionCount, + final HttpContext context) { + return TimeValue.ofSeconds(1L); + } + }; this.httpclient = this.clientBuilder .setRequestExecutor(new FaultyHttpRequestExecutor("a message showing that this failed")) - .setRetryHandler(requestRetryHandler) + .setRetryStrategy(requestRetryStrategy) .build(); final HttpHost target = start(); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/HttpRequestRetryStrategy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/HttpRequestRetryStrategy.java new file mode 100644 index 000000000..86140901e --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/HttpRequestRetryStrategy.java @@ -0,0 +1,90 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http; + +import java.io.IOException; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; + +/** + * Strategy interface that allows API users to plug in their own logic to + * control whether or not a retry should automatically be done, how many times + * it should be done and so on. + * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public interface HttpRequestRetryStrategy { + + /** + * Determines if a method should be retried after an I/O exception + * occured during execution. + * + * @param request the request failed due to an I/O exception + * @param exception the exception that occurred + * @param execCount the number of times this method has been + * unsuccessfully executed + * @param context the context for the request execution + * + * @return {@code true} if the request should be retried, {@code false} + * otherwise + */ + boolean retryRequest(HttpRequest request, IOException exception, int execCount, HttpContext context); + + /** + * Determines if a method should be retried given the response from + * the target server. + * + * @param response the response from the target server + * @param execCount the number of times this method has been + * unsuccessfully executed + * @param context the context for the request execution + * + * @return {@code true} if the request should be retried, {@code false} + * otherwise + */ + boolean retryRequest(HttpResponse response, int execCount, HttpContext context); + + /** + * Determines the retry interval between subsequent retries. + * + * @param response the response from the target server + * @param execCount the number of times this method has been + * unsuccessfully executed + * @param context the context for the request execution + * + * @return the retry interval between subsequent retries + */ + TimeValue getRetryInterval(HttpResponse response, int execCount, HttpContext context); + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElements.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElements.java index 9dd95cdad..9f034f594 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElements.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElements.java @@ -34,6 +34,7 @@ package org.apache.hc.client5.http.impl; */ public enum ChainElements { - REDIRECT, BACK_OFF, RETRY_SERVICE_UNAVAILABLE, RETRY_IO_ERROR, CACHING, PROTOCOL, CONNECT, MAIN_TRANSPORT + REDIRECT, BACK_OFF, RETRY, RETRY_SERVICE_UNAVAILABLE, RETRY_IO_ERROR, CACHING, PROTOCOL, + CONNECT, MAIN_TRANSPORT -} \ No newline at end of file +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultHttpRequestRetryStrategy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultHttpRequestRetryStrategy.java new file mode 100644 index 000000000..adef6bf32 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultHttpRequestRetryStrategy.java @@ -0,0 +1,232 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.impl; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; + +import javax.net.ssl.SSLException; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.concurrent.CancellableDependency; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Methods; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +/** + * Default implementation of the {@link HttpRequestRetryStrategy} interface. + * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public class DefaultHttpRequestRetryStrategy implements HttpRequestRetryStrategy { + + public static final DefaultHttpRequestRetryStrategy INSTANCE = new DefaultHttpRequestRetryStrategy(); + + /** + * Maximum number of allowed retries + */ + private final int maxRetries; + + /** + * Retry interval between subsequent retries + */ + private final TimeValue defaultRetryInterval; + + /** + * Derived {@code IOExceptions} which shall not be retried + */ + private final Set> nonRetriableIOExceptionClasses; + + /** + * HTTP status codes which shall be retried + */ + private final Set retriableCodes; + + protected DefaultHttpRequestRetryStrategy( + final int maxRetries, + final TimeValue defaultRetryInterval, + final Collection> clazzes, + final Collection codes) { + Args.notNegative(maxRetries, "maxRetries"); + Args.positive(defaultRetryInterval.getDuration(), "defaultRetryInterval"); + this.maxRetries = maxRetries; + this.defaultRetryInterval = defaultRetryInterval; + this.nonRetriableIOExceptionClasses = new HashSet<>(clazzes); + this.retriableCodes = new HashSet<>(codes); + } + + /** + * Create the HTTP request retry strategy using the following list of + * non-retriable I/O exception classes:
+ *
    + *
  • InterruptedIOException
  • + *
  • UnknownHostException
  • + *
  • ConnectException
  • + *
  • ConnectionClosedException
  • + *
  • SSLException
  • + *
+ * + * and retriable HTTP status codes:
+ *
    + *
  • SC_TOO_MANY_REQUESTS (429)
  • + *
  • SC_SERVICE_UNAVAILABLE (503)
  • + *
+ * + * @param maxRetries how many times to retry; 0 means no retries + * @param defaultRetryInterval the default retry interval between + * subsequent retries if the {@code Retry-After} header is not set + * or invalid. + */ + public DefaultHttpRequestRetryStrategy( + final int maxRetries, + final TimeValue defaultRetryInterval) { + this(maxRetries, defaultRetryInterval, + Arrays.asList( + InterruptedIOException.class, + UnknownHostException.class, + ConnectException.class, + ConnectionClosedException.class, + SSLException.class), + Arrays.asList( + HttpStatus.SC_TOO_MANY_REQUESTS, + HttpStatus.SC_SERVICE_UNAVAILABLE)); + } + + /** + * Create the HTTP request retry strategy with a max retry count of 1, + * default retry interval of 1 second, and using the following list of + * non-retriable I/O exception classes:
+ *
    + *
  • InterruptedIOException
  • + *
  • UnknownHostException
  • + *
  • ConnectException
  • + *
  • ConnectionClosedException
  • + *
  • SSLException
  • + *
+ * + * and retriable HTTP status codes:
+ *
    + *
  • SC_TOO_MANY_REQUESTS (429)
  • + *
  • SC_SERVICE_UNAVAILABLE (503)
  • + *
+ */ + public DefaultHttpRequestRetryStrategy() { + this(1, TimeValue.ofSeconds(1L)); + } + + @Override + public boolean retryRequest( + final HttpRequest request, + final IOException exception, + final int execCount, + final HttpContext context) { + Args.notNull(request, "request"); + Args.notNull(exception, "exception"); + + if (execCount > this.maxRetries) { + // Do not retry if over max retries + return false; + } + if (this.nonRetriableIOExceptionClasses.contains(exception.getClass())) { + return false; + } else { + for (final Class rejectException : this.nonRetriableIOExceptionClasses) { + if (rejectException.isInstance(exception)) { + return false; + } + } + } + if (request instanceof CancellableDependency && ((CancellableDependency) request).isCancelled()) { + return false; + } + + // Retry if the request is considered idempotent + return handleAsIdempotent(request); + } + + @Override + public boolean retryRequest( + final HttpResponse response, + final int execCount, + final HttpContext context) { + Args.notNull(response, "response"); + + return execCount <= this.maxRetries && retriableCodes.contains(response.getCode()); + } + + @Override + public TimeValue getRetryInterval( + final HttpResponse response, + final int execCount, + final HttpContext context) { + Args.notNull(response, "response"); + + final Header header = response.getFirstHeader(HttpHeaders.RETRY_AFTER); + TimeValue retryAfter = null; + if (header != null) { + final String value = header.getValue(); + try { + retryAfter = TimeValue.ofSeconds(Long.parseLong(value)); + } catch (final NumberFormatException ignore) { + final Date retryAfterDate = DateUtils.parseDate(value); + if (retryAfterDate != null) { + retryAfter = + TimeValue.ofMilliseconds(retryAfterDate.getTime() - System.currentTimeMillis()); + } + } + + if (TimeValue.isPositive(retryAfter)) { + return retryAfter; + } + } + return this.defaultRetryInterval; + } + + protected boolean handleAsIdempotent(final HttpRequest request) { + return Methods.isIdempotent(request.getMethod()); + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncHttpRequestRetryExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncHttpRequestRetryExec.java new file mode 100644 index 000000000..f3c18c6b6 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncHttpRequestRetryExec.java @@ -0,0 +1,187 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.async; + +import java.io.IOException; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.client5.http.impl.RequestCopier; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.nio.AsyncDataConsumer; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.nio.entity.NoopEntityConsumer; +import org.apache.hc.core5.util.Args; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Request executor in the asynchronous request execution chain that is + * responsible for making a decision whether a request that failed due to + * an I/O exception or received a specific response from the target server should + * be re-executed. Note that this exec chain handler will not respect + * {@link HttpRequestRetryStrategy#getRetryInterval(HttpResponse, int, org.apache.hc.core5.http.protocol.HttpContext)}. + *

+ * Further responsibilities such as communication with the opposite + * endpoint is delegated to the next executor in the request execution + * chain. + *

+ * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +@Internal +public final class AsyncHttpRequestRetryExec implements AsyncExecChainHandler { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private final HttpRequestRetryStrategy retryStrategy; + + public AsyncHttpRequestRetryExec(final HttpRequestRetryStrategy retryStrategy) { + Args.notNull(retryStrategy, "retryStrategy"); + this.retryStrategy = retryStrategy; + } + + private static class State { + + volatile int execCount; + volatile boolean retrying; + + } + + private void internalExecute( + final State state, + final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback asyncExecCallback) throws HttpException, IOException { + + final String exchangeId = scope.exchangeId; + + chain.proceed(RequestCopier.INSTANCE.copy(request), entityProducer, scope, new AsyncExecCallback() { + + @Override + public AsyncDataConsumer handleResponse( + final HttpResponse response, + final EntityDetails entityDetails) throws HttpException, IOException { + final HttpClientContext clientContext = scope.clientContext; + if (entityProducer != null && !entityProducer.isRepeatable()) { + if (log.isDebugEnabled()) { + log.debug("{}: cannot retry non-repeatable request", exchangeId); + } + return asyncExecCallback.handleResponse(response, entityDetails); + } + state.retrying = retryStrategy.retryRequest(response, state.execCount, clientContext); + if (state.retrying) { + return new NoopEntityConsumer(); + } else { + return asyncExecCallback.handleResponse(response, entityDetails); + } + } + + @Override + public void handleInformationResponse(final HttpResponse response) throws HttpException, IOException { + asyncExecCallback.handleInformationResponse(response); + } + + @Override + public void completed() { + if (state.retrying) { + state.execCount++; + try { + internalExecute(state, request, entityProducer, scope, chain, asyncExecCallback); + } catch (final IOException | HttpException ex) { + asyncExecCallback.failed(ex); + } + } else { + asyncExecCallback.completed(); + } + } + + @Override + public void failed(final Exception cause) { + if (cause instanceof IOException) { + final HttpRoute route = scope.route; + final HttpClientContext clientContext = scope.clientContext; + if (entityProducer != null && !entityProducer.isRepeatable()) { + if (log.isDebugEnabled()) { + log.debug("{}: cannot retry non-repeatable request", exchangeId); + } + } else if (retryStrategy.retryRequest(request, (IOException) cause, state.execCount, clientContext)) { + if (log.isDebugEnabled()) { + log.debug("{}: {}", exchangeId, cause.getMessage(), cause); + } + if (log.isInfoEnabled()) { + log.info("Recoverable I/O exception ({}) caught when processing request to {}", + cause.getClass().getName(), route); + } + scope.execRuntime.discardEndpoint(); + if (entityProducer != null) { + entityProducer.releaseResources(); + } + state.retrying = true; + state.execCount++; + try { + internalExecute(state, request, entityProducer, scope, chain, asyncExecCallback); + } catch (final IOException | HttpException ex) { + asyncExecCallback.failed(ex); + } + return; + } + } + asyncExecCallback.failed(cause); + } + + }); + + } + + @Override + public void execute( + final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback asyncExecCallback) throws HttpException, IOException { + final State state = new State(); + state.execCount = 1; + state.retrying = false; + internalExecute(state, request, entityProducer, scope, chain, asyncExecCallback); + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java index 02e6f109c..dab9a3b72 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncClientBuilder.java @@ -41,6 +41,7 @@ import java.util.concurrent.ThreadFactory; import org.apache.hc.client5.http.AuthenticationStrategy; import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRequestRetryHandler; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.SchemePortResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.AsyncExecChainHandler; @@ -55,7 +56,7 @@ import org.apache.hc.client5.http.cookie.CookieStore; import org.apache.hc.client5.http.impl.ChainElements; import org.apache.hc.client5.http.impl.CookieSpecSupport; import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; -import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryHandler; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; @@ -192,6 +193,7 @@ public class H2AsyncClientBuilder { private HttpRoutePlanner routePlanner; private RedirectStrategy redirectStrategy; private HttpRequestRetryHandler retryHandler; + private HttpRequestRetryStrategy retryStrategy; private Lookup authSchemeRegistry; private Lookup cookieSpecRegistry; @@ -389,6 +391,17 @@ public class H2AsyncClientBuilder { return this; } + /** + * Assigns {@link HttpRequestRetryStrategy} instance. + *

+ * Please note this value can be overridden by the {@link #disableAutomaticRetries()} + * method. + */ + public final H2AsyncClientBuilder setRetryStrategy(final HttpRequestRetryStrategy retryStrategy) { + this.retryStrategy = retryStrategy; + return this; + } + /** * Assigns {@link RedirectStrategy} instance. *

@@ -674,13 +687,20 @@ public class H2AsyncClientBuilder { // Add request retry executor, if not disabled if (!automaticRetriesDisabled) { - HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; - if (retryHandlerCopy == null) { - retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE; + final HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; + if (retryHandlerCopy != null) { + execChainDefinition.addFirst( + new AsyncRetryExec(retryHandlerCopy), + ChainElements.RETRY_IO_ERROR.name()); + } else { + HttpRequestRetryStrategy retryStrategyCopy = this.retryStrategy; + if (retryStrategyCopy == null) { + retryStrategyCopy = DefaultHttpRequestRetryStrategy.INSTANCE; + } + execChainDefinition.addFirst( + new AsyncHttpRequestRetryExec(retryStrategyCopy), + ChainElements.RETRY.name()); } - execChainDefinition.addFirst( - new AsyncRetryExec(retryHandlerCopy), - ChainElements.RETRY_IO_ERROR.name()); } HttpRoutePlanner routePlannerCopy = this.routePlanner; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java index 75eb512a4..917847687 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java @@ -41,6 +41,7 @@ import java.util.concurrent.ThreadFactory; import org.apache.hc.client5.http.AuthenticationStrategy; import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; import org.apache.hc.client5.http.HttpRequestRetryHandler; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.SchemePortResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.UserTokenHandler; @@ -57,7 +58,7 @@ import org.apache.hc.client5.http.impl.ChainElements; import org.apache.hc.client5.http.impl.CookieSpecSupport; import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; import org.apache.hc.client5.http.impl.DefaultConnectionKeepAliveStrategy; -import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryHandler; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; import org.apache.hc.client5.http.impl.DefaultUserTokenHandler; @@ -227,6 +228,7 @@ public class HttpAsyncClientBuilder { private HttpRoutePlanner routePlanner; private RedirectStrategy redirectStrategy; private HttpRequestRetryHandler retryHandler; + private HttpRequestRetryStrategy retryStrategy; private ConnectionReuseStrategy reuseStrategy; @@ -496,6 +498,17 @@ public class HttpAsyncClientBuilder { return this; } + /** + * Assigns {@link HttpRequestRetryStrategy} instance. + *

+ * Please note this value can be overridden by the {@link #disableAutomaticRetries()} + * method. + */ + public final HttpAsyncClientBuilder setRetryStrategy(final HttpRequestRetryStrategy retryStrategy) { + this.retryStrategy = retryStrategy; + return this; + } + /** * Assigns {@link RedirectStrategy} instance. *

@@ -824,13 +837,20 @@ public class HttpAsyncClientBuilder { // Add request retry executor, if not disabled if (!automaticRetriesDisabled) { - HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; - if (retryHandlerCopy == null) { - retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE; + final HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; + if (retryHandlerCopy != null) { + execChainDefinition.addFirst( + new AsyncRetryExec(retryHandlerCopy), + ChainElements.RETRY_IO_ERROR.name()); + } else { + HttpRequestRetryStrategy retryStrategyCopy = this.retryStrategy; + if (retryStrategyCopy == null) { + retryStrategyCopy = DefaultHttpRequestRetryStrategy.INSTANCE; + } + execChainDefinition.addFirst( + new AsyncHttpRequestRetryExec(retryStrategyCopy), + ChainElements.RETRY.name()); } - execChainDefinition.addFirst( - new AsyncRetryExec(retryHandlerCopy), - ChainElements.RETRY_IO_ERROR.name()); } HttpRoutePlanner routePlannerCopy = this.routePlanner; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java index cae1e0f89..0dfef46c8 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java @@ -40,6 +40,7 @@ import java.util.Map; import org.apache.hc.client5.http.AuthenticationStrategy; import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; import org.apache.hc.client5.http.HttpRequestRetryHandler; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.SchemePortResolver; import org.apache.hc.client5.http.ServiceUnavailableRetryStrategy; import org.apache.hc.client5.http.SystemDefaultDnsResolver; @@ -60,7 +61,7 @@ import org.apache.hc.client5.http.impl.ChainElements; import org.apache.hc.client5.http.impl.CookieSpecSupport; import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; import org.apache.hc.client5.http.impl.DefaultConnectionKeepAliveStrategy; -import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryHandler; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; import org.apache.hc.client5.http.impl.DefaultUserTokenHandler; @@ -204,6 +205,7 @@ public class HttpClientBuilder { private LinkedList execInterceptors; private HttpRequestRetryHandler retryHandler; + private HttpRequestRetryStrategy retryStrategy; private HttpRoutePlanner routePlanner; private RedirectStrategy redirectStrategy; private ConnectionBackoffStrategy connectionBackoffStrategy; @@ -505,6 +507,17 @@ public class HttpClientBuilder { return this; } + /** + * Assigns {@link HttpRequestRetryStrategy} instance. + *

+ * Please note this value can be overridden by the {@link #disableAutomaticRetries()} + * method. + */ + public final HttpClientBuilder setRetryStrategy(final HttpRequestRetryStrategy retryStrategy) { + this.retryStrategy = retryStrategy; + return this; + } + /** * Disables automatic request recovery and re-execution. */ @@ -859,13 +872,21 @@ public class HttpClientBuilder { // Add request retry executor, if not disabled if (!automaticRetriesDisabled) { - HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; - if (retryHandlerCopy == null) { - retryHandlerCopy = DefaultHttpRequestRetryHandler.INSTANCE; + // This needs to be cleaned up as soon as HttpRequestRetryHandler will be removed + final HttpRequestRetryHandler retryHandlerCopy = this.retryHandler; + if (retryHandlerCopy != null) { + execChainDefinition.addFirst( + new RetryExec(retryHandlerCopy), + ChainElements.RETRY_IO_ERROR.name()); + } else { + HttpRequestRetryStrategy retryStrategyCopy = this.retryStrategy; + if (retryStrategyCopy == null) { + retryStrategyCopy = DefaultHttpRequestRetryStrategy.INSTANCE; + } + execChainDefinition.addFirst( + new HttpRequestRetryExec(retryStrategyCopy), + ChainElements.RETRY.name()); } - execChainDefinition.addFirst( - new RetryExec(retryHandlerCopy), - ChainElements.RETRY_IO_ERROR.name()); } HttpRoutePlanner routePlannerCopy = this.routePlanner; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpRequestRetryExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpRequestRetryExec.java new file mode 100644 index 000000000..c674854d8 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpRequestRetryExec.java @@ -0,0 +1,160 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.classic; + +import java.io.IOException; +import java.io.InterruptedIOException; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChain.Scope; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.classic.ExecChainHandler; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Request executor in the request execution chain that is responsible for + * making a decision whether a request that failed due to an I/O exception + * or received a specific response from the target server should + * be re-executed. + *

+ * Further responsibilities such as communication with the opposite + * endpoint is delegated to the next executor in the request execution + * chain. + *

+ * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +@Internal +public class HttpRequestRetryExec implements ExecChainHandler { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private final HttpRequestRetryStrategy retryStrategy; + + public HttpRequestRetryExec( + final HttpRequestRetryStrategy retryStrategy) { + Args.notNull(retryStrategy, "retryStrategy"); + this.retryStrategy = retryStrategy; + } + + @Override + public ClassicHttpResponse execute( + final ClassicHttpRequest request, + final Scope scope, + final ExecChain chain) throws IOException, HttpException { + Args.notNull(request, "request"); + Args.notNull(scope, "scope"); + final String exchangeId = scope.exchangeId; + final HttpRoute route = scope.route; + final HttpClientContext context = scope.clientContext; + ClassicHttpRequest currentRequest = request; + + for (int execCount = 1;; execCount++) { + final ClassicHttpResponse response; + try { + response = chain.proceed(currentRequest, scope); + } catch (final IOException ex) { + if (scope.execRuntime.isExecutionAborted()) { + throw new RequestFailedException("Request aborted"); + } + final HttpEntity requestEntity = request.getEntity(); + if (requestEntity != null && !requestEntity.isRepeatable()) { + if (log.isDebugEnabled()) { + log.debug("{}: cannot retry non-repeatable request", exchangeId); + } + throw ex; + } + if (retryStrategy.retryRequest(request, ex, execCount, context)) { + if (log.isDebugEnabled()) { + log.debug("{}: {}", exchangeId, ex.getMessage(), ex); + } + if (log.isInfoEnabled()) { + log.info("Recoverable I/O exception ({}) caught when processing request to {}", + ex.getClass().getName(), route); + } + currentRequest = ClassicRequestCopier.INSTANCE.copy(scope.originalRequest); + continue; + } else { + if (ex instanceof NoHttpResponseException) { + final NoHttpResponseException updatedex = new NoHttpResponseException( + route.getTargetHost().toHostString() + " failed to respond"); + updatedex.setStackTrace(ex.getStackTrace()); + throw updatedex; + } + throw ex; + } + } + + try { + final HttpEntity entity = request.getEntity(); + if (entity != null && !entity.isRepeatable()) { + if (log.isDebugEnabled()) { + log.debug("{}: cannot retry non-repeatable request", exchangeId); + } + return response; + } + if (retryStrategy.retryRequest(response, execCount, context)) { + response.close(); + final TimeValue nextInterval = + retryStrategy.getRetryInterval(response, execCount, context); + if (TimeValue.isPositive(nextInterval)) { + try { + if (log.isDebugEnabled()) { + log.debug("{}: wait for {}", exchangeId, nextInterval); + } + nextInterval.sleep(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(); + } + } + currentRequest = ClassicRequestCopier.INSTANCE.copy(scope.originalRequest); + } else { + return response; + } + } catch (final RuntimeException ex) { + response.close(); + throw ex; + } + } + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TestDefaultHttpRequestRetryStrategy.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TestDefaultHttpRequestRetryStrategy.java new file mode 100644 index 000000000..2558359db --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/TestDefaultHttpRequestRetryStrategy.java @@ -0,0 +1,158 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.Date; + +import javax.net.ssl.SSLException; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.util.TimeValue; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class TestDefaultHttpRequestRetryStrategy { + + private DefaultHttpRequestRetryStrategy retryStrategy; + + @Before + public void setup() { + this.retryStrategy = new DefaultHttpRequestRetryStrategy(3, TimeValue.ofMilliseconds(1234L)); + } + + @Test + public void testBasics() throws Exception { + final HttpResponse response1 = new BasicHttpResponse(503, "Oppsie"); + Assert.assertTrue(this.retryStrategy.retryRequest(response1, 1, null)); + Assert.assertTrue(this.retryStrategy.retryRequest(response1, 2, null)); + Assert.assertTrue(this.retryStrategy.retryRequest(response1, 3, null)); + Assert.assertFalse(this.retryStrategy.retryRequest(response1, 4, null)); + final HttpResponse response2 = new BasicHttpResponse(500, "Big Time Oppsie"); + Assert.assertFalse(this.retryStrategy.retryRequest(response2, 1, null)); + final HttpResponse response3 = new BasicHttpResponse(429, "Oppsie"); + Assert.assertTrue(this.retryStrategy.retryRequest(response3, 1, null)); + Assert.assertTrue(this.retryStrategy.retryRequest(response3, 2, null)); + Assert.assertTrue(this.retryStrategy.retryRequest(response3, 3, null)); + Assert.assertFalse(this.retryStrategy.retryRequest(response3, 4, null)); + + Assert.assertEquals(TimeValue.ofMilliseconds(1234L), this.retryStrategy.getRetryInterval(response1, 1, null)); + } + + @Test + public void testRetryAfterHeaderAsLong() throws Exception { + final HttpResponse response = new BasicHttpResponse(503, "Oppsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, "321"); + + Assert.assertEquals(TimeValue.ofSeconds(321L), this.retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void testRetryAfterHeaderAsDate() throws Exception { + this.retryStrategy = new DefaultHttpRequestRetryStrategy(3, TimeValue.ofMilliseconds(1L)); + final HttpResponse response = new BasicHttpResponse(503, "Oppsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, DateUtils.formatDate(new Date(System.currentTimeMillis() + 100000L))); + + Assert.assertTrue(this.retryStrategy.getRetryInterval(response, 3, null).compareTo(TimeValue.ofMilliseconds(1L)) > 0); + } + + @Test + public void testRetryAfterHeaderAsPastDate() throws Exception { + final HttpResponse response = new BasicHttpResponse(503, "Oppsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, DateUtils.formatDate(new Date(System.currentTimeMillis() - 100000L))); + + Assert.assertEquals(TimeValue.ofMilliseconds(1234L), this.retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void testInvalidRetryAfterHeader() throws Exception { + final HttpResponse response = new BasicHttpResponse(503, "Oppsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, "Stuff"); + + Assert.assertEquals(TimeValue.ofMilliseconds(1234L), retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void noRetryOnConnectTimeout() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertFalse(retryStrategy.retryRequest(request, new SocketTimeoutException(), 1, null)); + } + + @Test + public void noRetryOnConnect() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertFalse(retryStrategy.retryRequest(request, new ConnectException(), 1, null)); + } + + @Test + public void noRetryOnConnectionClosed() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertFalse(retryStrategy.retryRequest(request, new ConnectionClosedException(), 1, null)); + } + + @Test + public void noRetryOnSSLFailure() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertFalse(retryStrategy.retryRequest(request, new SSLException("encryption failed"), 1, null)); + } + + @Test + public void noRetryOnUnknownHost() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertFalse(retryStrategy.retryRequest(request, new UnknownHostException(), 1, null)); + } + + @Test + public void noRetryOnAbortedRequests() throws Exception { + final HttpGet request = new HttpGet("/"); + request.cancel(); + + Assert.assertFalse(retryStrategy.retryRequest(request, new IOException(), 1, null)); + } + + @Test + public void retryOnNonAbortedRequests() throws Exception { + final HttpGet request = new HttpGet("/"); + + Assert.assertTrue(retryStrategy.retryRequest(request, new IOException(), 1, null)); + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestHttpRequestRetryExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestHttpRequestRetryExec.java new file mode 100644 index 000000000..c9ce01cdd --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestHttpRequestRetryExec.java @@ -0,0 +1,269 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.classic; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecRuntime; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.entity.EntityBuilder; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +@SuppressWarnings({"boxing","static-access"}) // test code +public class TestHttpRequestRetryExec { + + @Mock + private HttpRequestRetryStrategy retryStrategy; + @Mock + private ExecChain chain; + @Mock + private ExecRuntime endpoint; + + private HttpRequestRetryExec retryExec; + private HttpHost target; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + retryExec = new HttpRequestRetryExec(retryStrategy); + target = new HttpHost("localhost", 80); + } + + + @Test + public void testFundamentals1() throws Exception { + final HttpRoute route = new HttpRoute(target); + final HttpGet request = new HttpGet("/test"); + final HttpClientContext context = HttpClientContext.create(); + + final ClassicHttpResponse response = Mockito.mock(ClassicHttpResponse.class); + + Mockito.when(chain.proceed( + Mockito.same(request), + Mockito.any())).thenReturn(response); + Mockito.when(retryStrategy.retryRequest( + Mockito.any(), + Mockito.anyInt(), + Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE); + Mockito.when(retryStrategy.getRetryInterval( + Mockito.any(), + Mockito.anyInt(), + Mockito.any())).thenReturn(TimeValue.ZERO_MILLISECONDS); + + final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, endpoint, context); + retryExec.execute(request, scope, chain); + + Mockito.verify(chain, Mockito.times(2)).proceed( + Mockito.any(), + Mockito.same(scope)); + Mockito.verify(response, Mockito.times(1)).close(); + } + + @Test(expected = RuntimeException.class) + public void testStrategyRuntimeException() throws Exception { + final HttpRoute route = new HttpRoute(target); + final ClassicHttpRequest request = new HttpGet("/test"); + final HttpClientContext context = HttpClientContext.create(); + + final ClassicHttpResponse response = Mockito.mock(ClassicHttpResponse.class); + Mockito.when(chain.proceed( + Mockito.any(), + Mockito.any())).thenReturn(response); + Mockito.doThrow(new RuntimeException("Ooopsie")).when(retryStrategy).retryRequest( + Mockito.any(), + Mockito.anyInt(), + Mockito.any()); + final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, endpoint, context); + try { + retryExec.execute(request, scope, chain); + } catch (final Exception ex) { + Mockito.verify(response).close(); + throw ex; + } + } + + @Test + public void testNonRepeatableEntityResponseReturnedImmediately() throws Exception { + final HttpRoute route = new HttpRoute(target); + + final HttpPost request = new HttpPost("/test"); + request.setEntity(EntityBuilder.create() + .setStream(new ByteArrayInputStream(new byte[]{})) + .build()); + final HttpClientContext context = HttpClientContext.create(); + + final ClassicHttpResponse response = Mockito.mock(ClassicHttpResponse.class); + Mockito.when(chain.proceed( + Mockito.any(), + Mockito.any())).thenReturn(response); + Mockito.when(retryStrategy.retryRequest( + Mockito.any(), + Mockito.anyInt(), + Mockito.any())).thenReturn(Boolean.TRUE, Boolean.FALSE); + + final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, endpoint, context); + final ClassicHttpResponse finalResponse = retryExec.execute(request, scope, chain); + + Assert.assertSame(response, finalResponse); + Mockito.verify(response, Mockito.times(0)).close(); + } + + @Test(expected = IOException.class) + public void testFundamentals2() throws Exception { + final HttpRoute route = new HttpRoute(target); + final HttpGet originalRequest = new HttpGet("/test"); + originalRequest.addHeader("header", "this"); + originalRequest.addHeader("header", "that"); + final HttpClientContext context = HttpClientContext.create(); + + Mockito.when(chain.proceed( + Mockito.any(), + Mockito.any())).thenAnswer(new Answer() { + + @Override + public Object answer(final InvocationOnMock invocationOnMock) throws Throwable { + final Object[] args = invocationOnMock.getArguments(); + final ClassicHttpRequest wrapper = (ClassicHttpRequest) args[0]; + final Header[] headers = wrapper.getHeaders(); + Assert.assertEquals(2, headers.length); + Assert.assertEquals("this", headers[0].getValue()); + Assert.assertEquals("that", headers[1].getValue()); + wrapper.addHeader("Cookie", "monster"); + throw new IOException("Ka-boom"); + } + + }); + Mockito.when(retryStrategy.retryRequest( + Mockito.any(), + Mockito.any(), + Mockito.eq(1), + Mockito.any())).thenReturn(Boolean.TRUE); + final ExecChain.Scope scope = new ExecChain.Scope("test", route, originalRequest, endpoint, context); + final ClassicHttpRequest request = ClassicRequestCopier.INSTANCE.copy(originalRequest); + try { + retryExec.execute(request, scope, chain); + } catch (final IOException ex) { + Mockito.verify(chain, Mockito.times(2)).proceed( + Mockito.any(), + Mockito.same(scope)); + throw ex; + } + } + + + @Test(expected = IOException.class) + public void testAbortedRequest() throws Exception { + final HttpRoute route = new HttpRoute(target); + final HttpGet originalRequest = new HttpGet("/test"); + final HttpClientContext context = HttpClientContext.create(); + + Mockito.when(chain.proceed( + Mockito.any(), + Mockito.any())).thenThrow(new IOException("Ka-boom")); + Mockito.when(endpoint.isExecutionAborted()).thenReturn(true); + + final ExecChain.Scope scope = new ExecChain.Scope("test", route, originalRequest, endpoint, context); + final ClassicHttpRequest request = ClassicRequestCopier.INSTANCE.copy(originalRequest); + try { + retryExec.execute(request, scope, chain); + } catch (final IOException ex) { + Mockito.verify(chain, Mockito.times(1)).proceed( + Mockito.same(request), + Mockito.same(scope)); + Mockito.verify(retryStrategy, Mockito.never()).retryRequest( + Mockito.any(), + Mockito.any(), + Mockito.anyInt(), + Mockito.any()); + + throw ex; + } + } + + @Test(expected = IOException.class) + public void testNonRepeatableRequest() throws Exception { + final HttpRoute route = new HttpRoute(target); + final HttpPost originalRequest = new HttpPost("/test"); + originalRequest.setEntity(EntityBuilder.create() + .setStream(new ByteArrayInputStream(new byte[]{})) + .build()); + final HttpClientContext context = HttpClientContext.create(); + + Mockito.when(chain.proceed( + Mockito.any(), + Mockito.any())).thenAnswer(new Answer() { + + @Override + public Object answer(final InvocationOnMock invocationOnMock) throws Throwable { + final Object[] args = invocationOnMock.getArguments(); + final ClassicHttpRequest req = (ClassicHttpRequest) args[0]; + req.getEntity().writeTo(new ByteArrayOutputStream()); + throw new IOException("Ka-boom"); + } + + }); + Mockito.when(retryStrategy.retryRequest( + Mockito.any(), + Mockito.any(), + Mockito.eq(1), + Mockito.any())).thenReturn(Boolean.TRUE); + final ExecChain.Scope scope = new ExecChain.Scope("test", route, originalRequest, endpoint, context); + final ClassicHttpRequest request = ClassicRequestCopier.INSTANCE.copy(originalRequest); + try { + retryExec.execute(request, scope, chain); + } catch (final IOException ex) { + Mockito.verify(chain, Mockito.times(1)).proceed( + Mockito.same(request), + Mockito.same(scope)); + + throw ex; + } + } + +}