Skip to content

Commit 1f1da91

Browse files
SamBarkermanusa
authored andcommitted
fix: expands the HTTP interceptor API to include a call back for failed connection attempts (6144)
Add test which demonstrates what I expect to work --- Introduce afterConnectionFailure to complement `after` & `afterFailure`. Fixes #6143 --- Add a bit more explanatory Javadoc to Interceptor --- Extract private method to ensure changes to DefaultMockServer usage are consistent. --- Add test which covers future being completed exceptionally with a CompletionException. This happens in the Jetty implementation but gets wrapped in an IOException by the OkHttp impl but this should be enough for the coverage checker. --- Move afterConnectionFailure callback to `retryWithExponentialBackoff` so its invoked when an established websocket connection fails --- restart the mock server after a connection failure is detected to speed up tests. Rather than waiting ~20s to give up retrying. --- Add changelog entry --- Clarify javadoc --- @see -> @link --- Add unit test to ensure we unwrap CompletionExceptions. Really just making the coverage checker happy. --- Drop exception handling test from AbstractInterceptorTest as its now better covered elsewhere. (cherry picked from commit 8147542) Signed-off-by: Marc Nuri <[email protected]>
1 parent 710a8a2 commit 1f1da91

File tree

5 files changed

+136
-8
lines changed

5 files changed

+136
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Fix #6066: Added support for missing `v1.APIVersions` in KubernetesClient
77
* Fix #6110: VolumeSource (and other file mode fields) in Octal are correctly interpreted
88
* Fix #6137: `ConfigBuilder.withAutoConfigure` is not working
9+
* Fix #6143: Expands the HTTP interceptor API to include a call back for failed connection attempts
910
* Fix #6197: JettyHttp client error handling improvements.
1011
* Fix #6215: Suppressing rejected execution exception for port forwarder
1112
* Fix #6212: Improved reliability of file upload to Pod

kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/http/Interceptor.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
import java.util.List;
2020
import java.util.concurrent.CompletableFuture;
2121

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

2431
interface RequestTags {
@@ -63,7 +70,10 @@ default AsyncBody.Consumer<List<ByteBuffer>> consumer(AsyncBody.Consumer<List<By
6370
}
6471

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

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

100+
/**
101+
* Called after a connection attempt fails.
102+
* <p>
103+
* This method will be invoked on each failed connection attempt.
104+
*
105+
* @param request the HTTP request.
106+
* @param failure the Java exception that caused the failure.
107+
*/
108+
default void afterConnectionFailure(HttpRequest request, Throwable failure) {
109+
}
110+
87111
}

kubernetes-client-api/src/main/java/io/fabric8/kubernetes/client/http/StandardHttpClient.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,22 +177,31 @@ private <V> CompletableFuture<V> retryWithExponentialBackoff(
177177
}
178178
}
179179
} else {
180-
if (throwable instanceof CompletionException) {
181-
throwable = throwable.getCause();
182-
}
183-
if (throwable instanceof IOException) {
180+
final Throwable actualCause = unwrapCompletionException(throwable);
181+
builder.interceptors.forEach((s, interceptor) -> interceptor.afterConnectionFailure(request, actualCause));
182+
if (actualCause instanceof IOException) {
184183
// TODO: may not be specific enough - incorrect ssl settings for example will get caught here
185184
LOG.debug(
186185
String.format("HTTP operation on url: %s should be retried after %d millis because of IOException",
187186
uri, retryInterval),
188-
throwable);
187+
actualCause);
189188
return true;
190189
}
191190
}
192191
return false;
193192
});
194193
}
195194

195+
static Throwable unwrapCompletionException(Throwable throwable) {
196+
final Throwable actualCause;
197+
if (throwable instanceof CompletionException) {
198+
actualCause = throwable.getCause();
199+
} else {
200+
actualCause = throwable;
201+
}
202+
return actualCause;
203+
}
204+
196205
static long retryAfterMillis(HttpResponse<?> httpResponse) {
197206
String retryAfter = httpResponse.header(StandardHttpHeaders.RETRY_AFTER);
198207
if (retryAfter != null) {

kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/AbstractInterceptorTest.java

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,26 @@
2525
import java.net.URI;
2626
import java.nio.ByteBuffer;
2727
import java.nio.charset.StandardCharsets;
28+
import java.time.Duration;
29+
import java.time.temporal.ChronoUnit;
2830
import java.util.Collections;
2931
import java.util.List;
3032
import java.util.Set;
3133
import java.util.concurrent.CompletableFuture;
3234
import java.util.concurrent.ConcurrentHashMap;
35+
import java.util.concurrent.CountDownLatch;
3336
import java.util.concurrent.TimeUnit;
3437

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

3740
public abstract class AbstractInterceptorTest {
3841

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

4145
@BeforeEach
4246
void startServer() {
43-
server = new DefaultMockServer(false);
47+
server = newMockServer();
4448
server.start();
4549
}
4650

@@ -170,6 +174,69 @@ public CompletableFuture<Boolean> afterFailure(BasicBuilder builder, HttpRespons
170174
}
171175
}
172176

177+
@Test
178+
@DisplayName("afterConnectionFailure, invoked when remote server offline")
179+
public void afterConnectionFailureRemoteOffline() {
180+
// Given
181+
final int originalPort = server.getPort();
182+
server.shutdown();
183+
final CountDownLatch connectionFailureCallbackInvoked = new CountDownLatch(1);
184+
final HttpClient.Builder builder = getHttpClientFactory().newBuilder()
185+
.connectTimeout(1, TimeUnit.SECONDS)
186+
.addOrReplaceInterceptor("test", new Interceptor() {
187+
@Override
188+
public void afterConnectionFailure(HttpRequest request, Throwable failure) {
189+
connectionFailureCallbackInvoked.countDown();
190+
server = newMockServer();
191+
server.start(originalPort); // Need to restart on the original port as we can't alter the request during retry.
192+
}
193+
});
194+
// When
195+
try (HttpClient client = builder.build()) {
196+
final CompletableFuture<HttpResponse<String>> response = client.sendAsync(client.newHttpRequestBuilder()
197+
.timeout(1, TimeUnit.SECONDS)
198+
.uri(server.url("/not-found")).build(), String.class);
199+
200+
// Then
201+
assertThat(response).succeedsWithin(FUTURE_COMPLETION_TIME);
202+
assertThat(connectionFailureCallbackInvoked).extracting(CountDownLatch::getCount).isEqualTo(0L);
203+
}
204+
}
205+
206+
@Test
207+
@DisplayName("afterConnectionFailure, request is retried when remote server offline")
208+
public void afterConnectionFailureRetry() {
209+
// Given
210+
final int originalPort = server.getPort();
211+
server.shutdown();
212+
final CountDownLatch afterInvoked = new CountDownLatch(1);
213+
final HttpClient.Builder builder = getHttpClientFactory().newBuilder()
214+
.connectTimeout(1, TimeUnit.SECONDS)
215+
.addOrReplaceInterceptor("test", new Interceptor() {
216+
@Override
217+
public void afterConnectionFailure(HttpRequest request, Throwable failure) {
218+
server = newMockServer();
219+
server.start(originalPort); // Need to restart on the original port as we can't alter the request during retry.
220+
server.expect().withPath("/intercepted-url").andReturn(200, "This works").once();
221+
}
222+
223+
@Override
224+
public void after(HttpRequest request, HttpResponse<?> response, Consumer<List<ByteBuffer>> consumer) {
225+
afterInvoked.countDown();
226+
}
227+
});
228+
// When
229+
try (HttpClient client = builder.build()) {
230+
final CompletableFuture<HttpResponse<String>> response = client.sendAsync(client.newHttpRequestBuilder()
231+
.timeout(1, TimeUnit.SECONDS)
232+
.uri(server.url("/intercepted-url")).build(), String.class);
233+
234+
// Then
235+
assertThat(response).succeedsWithin(FUTURE_COMPLETION_TIME);
236+
assertThat(afterInvoked).extracting(CountDownLatch::getCount).isEqualTo(0L);
237+
}
238+
}
239+
173240
@Test
174241
@DisplayName("afterFailure (HTTP), replaces the HttpResponse produced by HttpClient.consumeBytes")
175242
public void afterHttpFailureReplacesResponseInConsumeBytes() throws Exception {
@@ -412,4 +479,7 @@ public void before(BasicBuilder builder, HttpRequest request, RequestTags tags)
412479
.containsEntry("test-header", Collections.singletonList("Test-Value-Override"));
413480
}
414481

482+
private static DefaultMockServer newMockServer() {
483+
return new DefaultMockServer(false);
484+
}
415485
}

kubernetes-client-api/src/test/java/io/fabric8/kubernetes/client/http/StandardHttpClientTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.Collections;
3535
import java.util.List;
3636
import java.util.concurrent.CompletableFuture;
37+
import java.util.concurrent.CompletionException;
3738
import java.util.concurrent.ExecutionException;
3839
import java.util.concurrent.TimeUnit;
3940
import java.util.concurrent.TimeoutException;
@@ -50,6 +51,7 @@
5051

5152
class StandardHttpClientTest {
5253

54+
public static final String IO_ERROR_MESSAGE = "IO woopsie";
5355
private TestStandardHttpClient client;
5456

5557
@BeforeEach
@@ -281,4 +283,26 @@ void testDerivedIsClosed() {
281283
assertTrue(client.isClosed());
282284
}
283285

286+
@Test
287+
void shouldUnwrapCompletionException() {
288+
// Given
289+
290+
// When
291+
final Throwable throwable = StandardHttpClient
292+
.unwrapCompletionException(new CompletionException(new IOException(IO_ERROR_MESSAGE)));
293+
294+
// Then
295+
assertThat(throwable).isInstanceOf(IOException.class).hasMessage(IO_ERROR_MESSAGE);
296+
}
297+
298+
@Test
299+
void shouldNotUnwrapOtherExceptions() {
300+
// Given
301+
302+
// When
303+
final Throwable throwable = StandardHttpClient.unwrapCompletionException(new IOException(IO_ERROR_MESSAGE));
304+
305+
// Then
306+
assertThat(throwable).isInstanceOf(IOException.class).hasMessage(IO_ERROR_MESSAGE);
307+
}
284308
}

0 commit comments

Comments
 (0)