Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expands the HTTP interceptor API to include a call back for failed connection attempts #6144

Merged
merged 12 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Fix #6137: `ConfigBuilder.withAutoConfigure` is not working
* Fix #6215: Suppressing rejected execution exception for port forwarder
* Fix #6197: JettyHttp client error handling improvements.
* Fix #6143: Expands the HTTP interceptor API to include a call back for failed connection attempts

#### Improvements
* Fix #6008: removing the optional dependency on bouncy castle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
import java.util.List;
import java.util.concurrent.CompletableFuture;

/**
* A collection of callback methods invoked through the various stages of the HTTP request lifecycle.
* Each invocation of {@link Interceptor#before(BasicBuilder, HttpRequest, RequestTags)} will be matched with a call to one of
* {@link Interceptor#afterConnectionFailure(HttpRequest, Throwable)} or
* {@link Interceptor#after(HttpRequest, HttpResponse, AsyncBody.Consumer)}.
* Callbacks that lead to a request being sent allow for that request to be customised.
*/
public interface Interceptor {

interface RequestTags {
Expand Down Expand Up @@ -63,7 +70,10 @@ default AsyncBody.Consumer<List<ByteBuffer>> consumer(AsyncBody.Consumer<List<By
}

/**
* Called after a websocket failure or by default from a normal request
* Called after a websocket failure or by default from a normal request.
* <p>
* Failure is determined by HTTP status code and will be invoked in addition to {@link Interceptor#after(HttpRequest,
* HttpResponse, AsyncBody.Consumer)}
*
* @param builder used to modify the request
* @param response the failed response
Expand All @@ -75,7 +85,10 @@ default CompletableFuture<Boolean> afterFailure(BasicBuilder builder, HttpRespon

/**
* Called after a non-websocket failure
*
* <p>
* Failure is determined by HTTP status code and will be invoked in addition to {@link Interceptor#after(HttpRequest,
* HttpResponse, AsyncBody.Consumer)}
*
* @param builder used to modify the request
* @param response the failed response
* @return true if the builder should be used to execute a new request
Expand All @@ -84,4 +97,15 @@ default CompletableFuture<Boolean> afterFailure(HttpRequest.Builder builder, Htt
return afterFailure((BasicBuilder) builder, response, tags);
}

/**
* Called after a connection attempt fails.
* <p>
* This method will be invoked on each failed connection attempt.
*
* @param request the HTTP request.
* @param failure the Java exception that caused the failure.
*/
default void afterConnectionFailure(HttpRequest request, Throwable failure) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -177,22 +177,31 @@ private <V> CompletableFuture<V> retryWithExponentialBackoff(
}
}
} else {
if (throwable instanceof CompletionException) {
throwable = throwable.getCause();
}
if (throwable instanceof IOException) {
final Throwable actualCause = unwrapCompletionException(throwable);
builder.interceptors.forEach((s, interceptor) -> interceptor.afterConnectionFailure(request, actualCause));
if (actualCause instanceof IOException) {
// TODO: may not be specific enough - incorrect ssl settings for example will get caught here
LOG.debug(
String.format("HTTP operation on url: %s should be retried after %d millis because of IOException",
uri, retryInterval),
throwable);
actualCause);
return true;
}
}
return false;
});
}

static Throwable unwrapCompletionException(Throwable throwable) {
final Throwable actualCause;
if (throwable instanceof CompletionException) {
actualCause = throwable.getCause();
} else {
actualCause = throwable;
}
return actualCause;
}

static long retryAfterMillis(HttpResponse<?> httpResponse) {
String retryAfter = httpResponse.header(StandardHttpHeaders.RETRY_AFTER);
if (retryAfter != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,26 @@
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

public abstract class AbstractInterceptorTest {

private static final Duration FUTURE_COMPLETION_TIME = Duration.of(10, ChronoUnit.SECONDS);
private static DefaultMockServer server;

@BeforeEach
void startServer() {
server = new DefaultMockServer(false);
server = newMockServer();
server.start();
}

Expand Down Expand Up @@ -170,6 +174,69 @@ public CompletableFuture<Boolean> afterFailure(BasicBuilder builder, HttpRespons
}
}

@Test
@DisplayName("afterConnectionFailure, invoked when remote server offline")
public void afterConnectionFailureRemoteOffline() {
// Given
final int originalPort = server.getPort();
server.shutdown();
final CountDownLatch connectionFailureCallbackInvoked = new CountDownLatch(1);
final HttpClient.Builder builder = getHttpClientFactory().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.addOrReplaceInterceptor("test", new Interceptor() {
@Override
public void afterConnectionFailure(HttpRequest request, Throwable failure) {
connectionFailureCallbackInvoked.countDown();
server = newMockServer();
server.start(originalPort); // Need to restart on the original port as we can't alter the request during retry.
}
});
// When
try (HttpClient client = builder.build()) {
final CompletableFuture<HttpResponse<String>> response = client.sendAsync(client.newHttpRequestBuilder()
.timeout(1, TimeUnit.SECONDS)
.uri(server.url("/not-found")).build(), String.class);

// Then
assertThat(response).succeedsWithin(FUTURE_COMPLETION_TIME);
assertThat(connectionFailureCallbackInvoked).extracting(CountDownLatch::getCount).isEqualTo(0L);
}
}

@Test
@DisplayName("afterConnectionFailure, request is retried when remote server offline")
public void afterConnectionFailureRetry() {
// Given
final int originalPort = server.getPort();
server.shutdown();
final CountDownLatch afterInvoked = new CountDownLatch(1);
final HttpClient.Builder builder = getHttpClientFactory().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.addOrReplaceInterceptor("test", new Interceptor() {
@Override
public void afterConnectionFailure(HttpRequest request, Throwable failure) {
server = newMockServer();
server.start(originalPort); // Need to restart on the original port as we can't alter the request during retry.
server.expect().withPath("/intercepted-url").andReturn(200, "This works").once();
}

@Override
public void after(HttpRequest request, HttpResponse<?> response, Consumer<List<ByteBuffer>> consumer) {
afterInvoked.countDown();
}
});
// When
try (HttpClient client = builder.build()) {
final CompletableFuture<HttpResponse<String>> response = client.sendAsync(client.newHttpRequestBuilder()
.timeout(1, TimeUnit.SECONDS)
.uri(server.url("/intercepted-url")).build(), String.class);

// Then
assertThat(response).succeedsWithin(FUTURE_COMPLETION_TIME);
assertThat(afterInvoked).extracting(CountDownLatch::getCount).isEqualTo(0L);
}
}

@Test
@DisplayName("afterFailure (HTTP), replaces the HttpResponse produced by HttpClient.consumeBytes")
public void afterHttpFailureReplacesResponseInConsumeBytes() throws Exception {
Expand Down Expand Up @@ -412,4 +479,7 @@ public void before(BasicBuilder builder, HttpRequest request, RequestTags tags)
.containsEntry("test-header", Collections.singletonList("Test-Value-Override"));
}

private static DefaultMockServer newMockServer() {
return new DefaultMockServer(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
Expand All @@ -50,6 +51,7 @@

class StandardHttpClientTest {

public static final String IO_ERROR_MESSAGE = "IO woopsie";
private TestStandardHttpClient client;

@BeforeEach
Expand Down Expand Up @@ -281,4 +283,26 @@ void testDerivedIsClosed() {
assertTrue(client.isClosed());
}

@Test
void shouldUnwrapCompletionException() {
// Given

// When
final Throwable throwable = StandardHttpClient
.unwrapCompletionException(new CompletionException(new IOException(IO_ERROR_MESSAGE)));

// Then
assertThat(throwable).isInstanceOf(IOException.class).hasMessage(IO_ERROR_MESSAGE);
}

@Test
void shouldNotUnwrapOtherExceptions() {
// Given

// When
final Throwable throwable = StandardHttpClient.unwrapCompletionException(new IOException(IO_ERROR_MESSAGE));

// Then
assertThat(throwable).isInstanceOf(IOException.class).hasMessage(IO_ERROR_MESSAGE);
}
}
Loading