Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@

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);
private final SSLSocketFactory sslSocketFactory;
private URL url;
private String method = "GET";
private RequestBody body;
private int connectTimeoutMillis;
private int readTimeoutMillis;

public RequestBuilder(final SSLSocketFactory sslSocketFactory) {

Expand All @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

}
8 changes: 7 additions & 1 deletion src/test/java/com/dajudge/kindcontainer/NodePortTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<DynamicTest> exposes_node_port() {
Expand Down Expand Up @@ -83,7 +85,11 @@ private Callable<Boolean> 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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
}
Loading