diff --git a/src/main/java/com/dajudge/kindcontainer/client/http/RequestBuilder.java b/src/main/java/com/dajudge/kindcontainer/client/http/RequestBuilder.java index c74c602b..57f334af 100644 --- a/src/main/java/com/dajudge/kindcontainer/client/http/RequestBuilder.java +++ b/src/main/java/com/dajudge/kindcontainer/client/http/RequestBuilder.java @@ -5,12 +5,13 @@ import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; +import java.time.Duration; +import java.util.Objects; public class RequestBuilder { private static final Logger LOG = LoggerFactory.getLogger(RequestBuilder.class); @@ -18,6 +19,8 @@ public class RequestBuilder { private URL url; private String method = "GET"; private RequestBody body; + private int connectTimeoutMillis; + private int readTimeoutMillis; public RequestBuilder(final SSLSocketFactory sslSocketFactory) { @@ -39,6 +42,22 @@ public RequestBuilder method(final String method, final RequestBody body) { return this; } + /** + * Limits how long establishing the connection may block. A zero duration keeps the JDK default of no timeout. + */ + public RequestBuilder connectTimeout(final Duration timeout) { + this.connectTimeoutMillis = timeoutMillis(timeout, "connect timeout"); + return this; + } + + /** + * Limits how long reading the response may block. A zero duration keeps the JDK default of no timeout. + */ + public RequestBuilder readTimeout(final Duration timeout) { + this.readTimeoutMillis = timeoutMillis(timeout, "read timeout"); + return this; + } + public Response execute() throws IOException { if (url == null) { throw new IllegalStateException("No URL specified"); @@ -47,6 +66,8 @@ public Response execute() throws IOException { throw new IllegalStateException("No METHOD specified"); } final HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(connectTimeoutMillis); + conn.setReadTimeout(readTimeoutMillis); if (conn instanceof HttpsURLConnection) { if (sslSocketFactory != null) { ((HttpsURLConnection) conn).setSSLSocketFactory(sslSocketFactory); @@ -74,4 +95,19 @@ private Response.ResponseWrapper executeRequest(final HttpURLConnection conn) th } } + private static int timeoutMillis(final Duration timeout, final String description) { + Objects.requireNonNull(timeout, description); + if (timeout.isNegative()) { + throw new IllegalArgumentException(description + " must not be negative"); + } + + final long millis = timeout.toMillis(); + if (millis > Integer.MAX_VALUE) { + throw new IllegalArgumentException(description + " must not exceed " + Integer.MAX_VALUE + "ms"); + } + + // HttpURLConnection treats zero as infinite, so round positive sub-millisecond durations up. + return millis == 0 && !timeout.isZero() ? 1 : (int) millis; + } + } diff --git a/src/test/java/com/dajudge/kindcontainer/NodePortTest.java b/src/test/java/com/dajudge/kindcontainer/NodePortTest.java index 925e8619..b7a8f79a 100644 --- a/src/test/java/com/dajudge/kindcontainer/NodePortTest.java +++ b/src/test/java/com/dajudge/kindcontainer/NodePortTest.java @@ -12,6 +12,7 @@ import org.testcontainers.containers.Container.ExecResult; import java.io.IOException; +import java.time.Duration; import java.util.HashMap; import java.util.Objects; import java.util.concurrent.Callable; @@ -29,6 +30,7 @@ public class NodePortTest { private static final Logger LOG = LoggerFactory.getLogger(NodePortTest.class); + private static final Duration HOST_PROBE_TIMEOUT = Duration.ofSeconds(2); @TestFactory public Stream exposes_node_port() { @@ -83,7 +85,11 @@ private Callable httpWithDiagnostics( return () -> { try { final TinyHttpClient client = TinyHttpClient.newHttpClient().build(); - try (final Response response = client.request().url(url).execute()) { + try (final Response response = client.request() + .url(url) + .connectTimeout(HOST_PROBE_TIMEOUT) + .readTimeout(HOST_PROBE_TIMEOUT) + .execute()) { lastResult.set("HTTP " + response.code()); return response.code() == 200; } diff --git a/src/test/java/com/dajudge/kindcontainer/client/http/RequestBuilderTest.java b/src/test/java/com/dajudge/kindcontainer/client/http/RequestBuilderTest.java new file mode 100644 index 00000000..0a280cdf --- /dev/null +++ b/src/test/java/com/dajudge/kindcontainer/client/http/RequestBuilderTest.java @@ -0,0 +1,102 @@ +package com.dajudge.kindcontainer.client.http; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RequestBuilderTest { + + @Test + void timed_out_request_can_be_retried() throws Exception { + final AtomicInteger connections = new AtomicInteger(); + final AtomicLong firstFailureMillis = new AtomicLong(); + final AtomicReference lastResult = new AtomicReference<>("not attempted"); + final CountDownLatch firstRequestFailed = new CountDownLatch(1); + final ExecutorService serverExecutor = Executors.newSingleThreadExecutor(); + + try (final ServerSocket server = new ServerSocket(0)) { + final Future serverTask = serverExecutor.submit(() -> { + serveStalledThenSuccessfulRequest(server, connections, firstRequestFailed); + return null; + }); + + final TinyHttpClient client = TinyHttpClient.newHttpClient().build(); + await("HTTP request is retried after the first response stalls") + .atMost(4, TimeUnit.SECONDS) + .pollInterval(25, TimeUnit.MILLISECONDS) + .until(() -> { + final long started = System.nanoTime(); + try (final Response response = client.request() + .url("http://localhost:" + server.getLocalPort()) + .connectTimeout(Duration.ofMillis(200)) + .readTimeout(Duration.ofMillis(200)) + .execute()) { + lastResult.set("HTTP " + response.code()); + return response.code() == 200; + } catch (final IOException e) { + firstFailureMillis.compareAndSet( + 0, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + ); + lastResult.set(e.getClass().getSimpleName() + ": " + e.getMessage()); + firstRequestFailed.countDown(); + return false; + } + }); + + assertEquals("HTTP 200", lastResult.get()); + assertEquals(2, connections.get()); + assertTrue(firstFailureMillis.get() < 1_000, + "the stalled request should fail quickly enough to permit a retry"); + serverTask.get(1, TimeUnit.SECONDS); + } finally { + firstRequestFailed.countDown(); + serverExecutor.shutdownNow(); + } + } + + private static void serveStalledThenSuccessfulRequest( + final ServerSocket server, + final AtomicInteger connections, + final CountDownLatch firstRequestFailed + ) { + try (final Socket ignored = server.accept()) { + connections.incrementAndGet(); + firstRequestFailed.await(2, TimeUnit.SECONDS); + } catch (final IOException e) { + throw new RuntimeException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + try (final Socket socket = server.accept()) { + connections.incrementAndGet(); + socket.getOutputStream().write(( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n" + ).getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } +}