diff --git a/src/main/java/de/stklcode/pubtrans/ura/UraClient.java b/src/main/java/de/stklcode/pubtrans/ura/UraClient.java index ff8fc1d..e2a97e7 100644 --- a/src/main/java/de/stklcode/pubtrans/ura/UraClient.java +++ b/src/main/java/de/stklcode/pubtrans/ura/UraClient.java @@ -475,10 +475,17 @@ public class UraClient implements Serializable { */ private InputStream request(String url) throws IOException { try { - return HttpClient.newHttpClient().send( - HttpRequest.newBuilder(URI.create(url)).GET().build(), - HttpResponse.BodyHandlers.ofInputStream() - ).body(); + var clientBuilder = HttpClient.newBuilder(); + if (config.getConnectTimeout() != null) { + clientBuilder.connectTimeout(config.getConnectTimeout()); + } + + var reqBuilder = HttpRequest.newBuilder(URI.create(url)).GET(); + if (config.getTimeout() != null) { + reqBuilder.timeout(config.getTimeout()); + } + + return clientBuilder.build().send(reqBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()).body(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("API request interrupted", e); diff --git a/src/main/java/de/stklcode/pubtrans/ura/UraClientConfiguration.java b/src/main/java/de/stklcode/pubtrans/ura/UraClientConfiguration.java index 5ba3bc6..126afde 100644 --- a/src/main/java/de/stklcode/pubtrans/ura/UraClientConfiguration.java +++ b/src/main/java/de/stklcode/pubtrans/ura/UraClientConfiguration.java @@ -1,6 +1,7 @@ package de.stklcode.pubtrans.ura; import java.io.Serializable; +import java.time.Duration; /** * Configurstion Object for the {@link UraClient}. @@ -17,6 +18,8 @@ public class UraClientConfiguration implements Serializable { private final String baseURL; private final String instantPath; private final String streamPath; + private final Duration connectTimeout; + private final Duration timeout; /** * Get new configuration {@link Builder} for given base URL. @@ -38,6 +41,8 @@ public class UraClientConfiguration implements Serializable { this.baseURL = builder.baseURL; this.instantPath = builder.instantPath; this.streamPath = builder.streamPath; + this.connectTimeout = builder.connectTimeout; + this.timeout = builder.timeout; } /** @@ -67,6 +72,24 @@ public class UraClientConfiguration implements Serializable { return this.streamPath; } + /** + * Get the connection timeout, if any. + * + * @return Timeout duration or {@code null} if none specified. + */ + public Duration getConnectTimeout() { + return this.connectTimeout; + } + + /** + * Get the response timeout, if any. + * + * @return Timeout duration or {@code null} if none specified. + */ + public Duration getTimeout() { + return this.timeout; + } + /** * Builder for {@link UraClientConfiguration} objects. */ @@ -74,6 +97,8 @@ public class UraClientConfiguration implements Serializable { private final String baseURL; private String instantPath; private String streamPath; + private Duration connectTimeout; + private Duration timeout; /** * Initialize the builder with mandatory base URL. @@ -85,6 +110,8 @@ public class UraClientConfiguration implements Serializable { this.baseURL = baseURL; this.instantPath = DEFAULT_INSTANT_PATH; this.streamPath = DEFAULT_STREAM_PATH; + this.connectTimeout = null; + this.timeout = null; } /** @@ -109,6 +136,28 @@ public class UraClientConfiguration implements Serializable { return this; } + /** + * Specify a custom connection timeout duration. + * + * @param connectTimeout Timeout duration. + * @return The builder. + */ + public Builder withConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + /** + * Specify a custom timeout duration. + * + * @param timeout Timeout duration. + * @return The builder. + */ + public Builder withTimeout(Duration timeout) { + this.timeout = timeout; + return this; + } + /** * Finally build the configuration object. * diff --git a/src/test/java/de/stklcode/pubtrans/ura/UraClientTest.java b/src/test/java/de/stklcode/pubtrans/ura/UraClientTest.java index 05d65bb..bdf603d 100644 --- a/src/test/java/de/stklcode/pubtrans/ura/UraClientTest.java +++ b/src/test/java/de/stklcode/pubtrans/ura/UraClientTest.java @@ -29,6 +29,9 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; import java.util.List; import java.util.Optional; @@ -389,6 +392,57 @@ public class UraClientTest { assertThat(messages.get(0).getText(), is("Sehr geehrte Fahrgäste, wegen Strassenbauarbeiten kann diese Haltestelle nicht von den Bussen der Linien 17, 44 und N2 angefahren werden.")); } + @Test + public void timeoutTest() throws IOException { + // Try to read trips from TEST-NET-1 IP that is not routed (hopefully) and will not connect within 100ms. + UraClientException exception = assertThrows( + UraClientException.class, + () -> new UraClient( + UraClientConfiguration.forBaseURL("http://192.0.2.1") + .withConnectTimeout(Duration.ofMillis(100)) + .build() + ).forDestinationNames("Piccadilly Circus").getTrips(), + "Connection to TEST-NET-1 address should fail" + ); + assertTrue(exception.getCause() instanceof HttpConnectTimeoutException, "Exception cause is not HttpConnectionTimeoutException"); + + // Mock the HTTP call with delay of 200ms, but immediate connection. + WireMock.stubFor( + get(urlPathEqualTo("/interfaces/ura/instant_V1")).willReturn( + aResponse().withFixedDelay(200).withBodyFile("instant_V1_trips_destination.txt") + ) + ); + assertDoesNotThrow( + () -> new UraClient( + UraClientConfiguration.forBaseURL(httpMock.baseUrl()) + .withConnectTimeout(Duration.ofMillis(100)) + .build() + ).forDestinationNames("Piccadilly Circus").getTrips(), + "Connection timeout should not affect response time." + ); + + // Now specify response timeout. + exception = assertThrows( + UraClientException.class, + () -> new UraClient( + UraClientConfiguration.forBaseURL(httpMock.baseUrl()) + .withTimeout(Duration.ofMillis(100)) + .build() + ).forDestinationNames("Piccadilly Circus").getTrips(), + "Response timeout did not raise an exception" + ); + assertTrue(exception.getCause() instanceof HttpTimeoutException, "Exception cause is not HttpTimeoutException"); + + assertDoesNotThrow( + () -> new UraClient( + UraClientConfiguration.forBaseURL(httpMock.baseUrl()) + .withTimeout(Duration.ofMillis(300)) + .build() + ).forDestinationNames("Piccadilly Circus").getTrips(), + "Response timeout of 300ms with 100ms delay must not fail" + ); + } + private static void mockHttpToFile(int version, String resourceFile) { WireMock.stubFor(