diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2353accc..bdf46ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,16 @@
CHANGELOG
=========
+5.0.3 (unreleased)
+------------------
+
+* Added `WebServiceClient.Builder.maxRetries(int)` to bound transport-failure
+ retries (default 1; set 0 to disable). See the README for retry semantics.
+ **Behavior change:** previously, transient transport failures (connection
+ reset, broken pipe, etc.) surfaced to callers immediately. They are now
+ retried once by default; pass `.maxRetries(0)` to restore the prior
+ behavior.
+
5.0.2 (2025-12-08)
------------------
diff --git a/README.md b/README.md
index 5d46a084..2ed73c3b 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,50 @@ are not created for each request.
See the [API documentation](https://maxmind.github.io/GeoIP2-java/) for
more details.
+### Connection pooling and transport retries ###
+
+`WebServiceClient` is thread-safe and reuses a pooled `HttpClient` across
+requests. Idle connections in the pool can be silently closed by load
+balancers or other intermediaries. When the next request reuses one of these
+half-closed connections, the JDK reports the failure as a `Connection reset`,
+`Broken pipe`, or related transport `IOException`.
+
+To smooth over these intermittent transport failures, the SDK retries once
+by default. Any transport-level `IOException` raised by the underlying HTTP
+send is retried, with the following exclusions:
+
+* `HttpTimeoutException` — a request-phase timeout. Connect-phase timeouts
+ (`HttpConnectTimeoutException`) are also excluded because they extend
+ `HttpTimeoutException`. The SDK honors the timeouts you configure.
+* `InterruptedIOException` — the calling thread was interrupted; the SDK
+ honors the cancellation rather than override it.
+* Typically deterministic failures: `UnknownHostException`,
+ `ConnectException`, `SSLHandshakeException`, `SSLPeerUnverifiedException`.
+ Retrying these would just delay surfacing a config bug.
+* If the calling thread is already interrupted when the predicate runs, the
+ retry is short-circuited regardless of the exception type.
+
+HTTP 4xx and 5xx responses are not retried — they are returned as
+`HttpResponse` objects (not `IOException`s) and surfaced through the existing
+exception hierarchy. Web service requests are idempotent GETs, so retried
+requests are byte-identical to the original.
+
+You can change the retry budget via the builder:
+
+```java
+WebServiceClient client = new WebServiceClient.Builder(42, "license_key")
+ .maxRetries(2) // up to two retries (three total attempts)
+ .build();
+```
+
+Set `.maxRetries(0)` to disable the retry entirely. Negative values throw
+`IllegalArgumentException`.
+
+If you frequently see `Connection reset` errors, you can also reduce the
+JDK's keep-alive timeout via the system property
+`jdk.httpclient.keepalive.timeout` (in seconds) to evict pooled connections
+before any intermediary does so.
+
## Web Service Example ##
### Country Service ###
diff --git a/mise.lock b/mise.lock
new file mode 100644
index 00000000..0145b93f
--- /dev/null
+++ b/mise.lock
@@ -0,0 +1,30 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
+
+[[tools.java]]
+version = "26.0.0"
+backend = "core:java"
+
+[[tools.maven]]
+version = "3.9.15"
+backend = "aqua:apache/maven"
+
+[tools.maven."platforms.linux-arm64"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.linux-arm64-musl"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.linux-x64"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.linux-x64-musl"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.macos-arm64"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.macos-x64"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
+
+[tools.maven."platforms.windows-x64"]
+url = "https://archive.apache.org/dist/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz"
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 00000000..c4027682
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,18 @@
+[settings]
+experimental = true
+lockfile = true
+disable_backends = [
+ "asdf",
+ "vfox",
+]
+
+[tools]
+java = "latest"
+maven = "latest"
+
+[hooks]
+enter = "mise install --quiet --locked"
+
+[[watch_files]]
+patterns = ["mise.toml", "mise.lock"]
+run = "mise install --quiet --locked"
diff --git a/src/main/java/com/maxmind/geoip2/WebServiceClient.java b/src/main/java/com/maxmind/geoip2/WebServiceClient.java
index 125a3af9..cc686891 100644
--- a/src/main/java/com/maxmind/geoip2/WebServiceClient.java
+++ b/src/main/java/com/maxmind/geoip2/WebServiceClient.java
@@ -20,20 +20,27 @@
import com.maxmind.geoip2.model.InsightsResponse;
import java.io.IOException;
import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.net.ConnectException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.URISyntaxException;
+import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
/**
*
@@ -112,6 +119,7 @@ public class WebServiceClient implements WebServiceProvider {
private final boolean useHttps;
private final int port;
private final Duration requestTimeout;
+ private final int maxRetries;
private final String userAgent = "GeoIP2/"
+ getClass().getPackage().getImplementationVersion()
+ " (Java/" + System.getProperty("java.version") + ")";
@@ -125,6 +133,7 @@ private WebServiceClient(Builder builder) {
this.port = builder.port;
this.useHttps = builder.useHttps;
this.locales = builder.locales;
+ this.maxRetries = builder.maxRetries;
// HttpClient supports basic auth, but it will only send it after the
// server responds with an unauthorized. As such, we just make the
@@ -182,6 +191,7 @@ public static final class Builder {
List locales = List.of("en");
private ProxySelector proxy = null;
private HttpClient httpClient = null;
+ private int maxRetries = 1;
/**
* @param accountId Your MaxMind account ID.
@@ -197,6 +207,7 @@ public Builder(int accountId, String licenseKey) {
* @param val Timeout duration to establish a connection to the
* web service. The default is 3 seconds.
* @return Builder object
+ * @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
*/
public Builder connectTimeout(Duration val) {
this.connectTimeout = val;
@@ -251,6 +262,7 @@ public Builder locales(List val) {
/**
* @param val Request timeout duration. The default is 20 seconds.
* @return Builder object
+ * @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
*/
public Builder requestTimeout(Duration val) {
this.requestTimeout = val;
@@ -271,6 +283,10 @@ public Builder proxy(ProxySelector val) {
* @param val the custom HttpClient to use for requests. When providing a
* custom HttpClient, you cannot also set connectTimeout or proxy
* parameters as these should be configured on the provided client.
+ *
+ * The SDK applies its own transport-failure retry on top of any
+ * supplied client; customers can disable it via
+ * {@link #maxRetries(int)} with {@code .maxRetries(0)}.
* @return Builder object
*/
public Builder httpClient(HttpClient val) {
@@ -278,6 +294,36 @@ public Builder httpClient(HttpClient val) {
return this;
}
+ /**
+ * @param val Maximum number of retries on transport-level failures
+ * (connection reset, broken pipe, EOF, ...).
+ * Applies uniformly to all endpoints. Defaults to 1.
+ * Set to 0 to disable.
+ * @return Builder.
+ * @throws IllegalArgumentException if {@code val} is negative.
+ * @apiNote Timeouts are not retried ({@link java.net.http.HttpTimeoutException},
+ * including the connect-phase subclass
+ * {@link java.net.http.HttpConnectTimeoutException}). When
+ * {@code maxRetries > 0}, retries are triggered only by fast transport
+ * failures, so each attempt is independently bounded by
+ * {@link #connectTimeout(Duration)} and {@link #requestTimeout(Duration)}.
+ * Because timeouts abort rather than retry, the worst-case wall clock
+ * is bounded by a single attempt's timeouts, not
+ * {@code (maxRetries + 1) x timeout}.
+ *
+ * Successful retries do not surface the prior failure to callers; if
+ * all attempts fail, the final exception carries the prior
+ * {@code IOException}s via {@link Throwable#getSuppressed()} so the
+ * full retry history is visible in stack traces.
+ */
+ public Builder maxRetries(int val) {
+ if (val < 0) {
+ throw new IllegalArgumentException("maxRetries must not be negative");
+ }
+ maxRetries = val;
+ return this;
+ }
+
/**
* @return an instance of {@code WebServiceClient} created from the
* fields set on this builder.
@@ -371,18 +417,79 @@ private T responseFor(String path, InetAddress ipAddress, Class cls)
.GET()
.build();
try {
- var response = this.httpClient
- .send(request, HttpResponse.BodyHandlers.ofInputStream());
+ var response = sendWithRetry(request);
try {
return handleResponse(response, cls);
} finally {
response.body().close();
}
} catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
throw new GeoIp2Exception("Interrupted sending request", e);
}
}
+ private HttpResponse sendWithRetry(HttpRequest request)
+ throws IOException, InterruptedException {
+ int attempts = 0;
+ List priors = new ArrayList<>();
+ while (true) {
+ try {
+ return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+ } catch (IOException e) {
+ // Attach all prior IOExceptions directly so the final stack
+ // trace carries the full retry history without nesting.
+ for (IOException p : priors) {
+ e.addSuppressed(p);
+ }
+ if (!isRetriableTransportFailure(e) || attempts >= maxRetries) {
+ throw e;
+ }
+ priors.add(e);
+ attempts++;
+ }
+ }
+ }
+
+ private static boolean isRetriableTransportFailure(IOException e) {
+ if (Thread.currentThread().isInterrupted()) {
+ return false;
+ }
+ // Both connect-phase and request-phase timeouts are customer-set
+ // budgets that retrying would silently extend.
+ // HttpConnectTimeoutException extends HttpTimeoutException, so this
+ // single check covers both.
+ if (e instanceof HttpTimeoutException) {
+ return false;
+ }
+ // The thread was interrupted during I/O; honor the cancellation.
+ if (e instanceof InterruptedIOException) {
+ return false;
+ }
+ // The four exclusions below are *occasionally* transient (DNS hiccup,
+ // TCP RST race during cert rotation, brief LB outage), but treating
+ // them as deterministic is a deliberate product decision: retrying
+ // would mask config bugs behind 2x latency, and the customer-visible
+ // cost of one extra failed call on a true transient is small.
+ if (e instanceof UnknownHostException) {
+ return false;
+ }
+ if (e instanceof ConnectException) {
+ return false;
+ }
+ if (e instanceof SSLHandshakeException) {
+ return false;
+ }
+ if (e instanceof SSLPeerUnverifiedException) {
+ return false;
+ }
+ // Everything else from httpClient.send() is a transport failure
+ // (connection reset, broken pipe, EOF, closed channel, ...).
+ // HTTP 4xx and 5xx responses do not reach this predicate -- they come
+ // back as HttpResponse objects rather than IOExceptions.
+ return true;
+ }
+
private T handleResponse(HttpResponse response, Class cls)
throws GeoIp2Exception, IOException {
var status = response.statusCode();
diff --git a/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java b/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java
index cd5c2af0..e00ca41c 100644
--- a/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java
+++ b/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java
@@ -3,6 +3,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static com.jcabi.matchers.RegexMatchers.matchesPattern;
@@ -15,8 +16,10 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.github.tomakehurst.wiremock.http.Fault;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.github.tomakehurst.wiremock.stubbing.Scenario;
import com.maxmind.geoip2.exception.AddressNotFoundException;
import com.maxmind.geoip2.exception.AuthenticationException;
import com.maxmind.geoip2.exception.GeoIp2Exception;
@@ -24,6 +27,8 @@
import com.maxmind.geoip2.exception.InvalidRequestException;
import com.maxmind.geoip2.exception.OutOfQueriesException;
import com.maxmind.geoip2.exception.PermissionRequiredException;
+import com.maxmind.geoip2.model.CityResponse;
+import com.maxmind.geoip2.model.CountryResponse;
import com.maxmind.geoip2.model.InsightsResponse;
import com.maxmind.geoip2.record.City;
import com.maxmind.geoip2.record.Continent;
@@ -33,16 +38,20 @@
import com.maxmind.geoip2.record.RepresentedCountry;
import com.maxmind.geoip2.record.Subdivision;
import com.maxmind.geoip2.record.Traits;
+import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.http.HttpClient;
+import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -460,4 +469,301 @@ public void testHttpClientWithDefaultSettingsDoesNotThrow() throws Exception {
assertNotNull(client);
}
+ @Test
+ public void testRetriesOnConnectionReset_country() throws Exception {
+ String url = "/geoip/v2.1/country/1.2.3.4";
+ String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-country")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
+ .willSetStateTo("succeeded"));
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-country")
+ .whenScenarioStateIs("succeeded")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type",
+ "application/vnd.maxmind.com-country+json; charset=UTF-8; version=2.1")
+ .withBody(body)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ CountryResponse response = client.country(InetAddress.getByName("1.2.3.4"));
+ assertNotNull(response);
+
+ wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testRetriesOnConnectionReset_city() throws Exception {
+ String url = "/geoip/v2.1/city/1.2.3.4";
+ String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-city")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
+ .willSetStateTo("succeeded"));
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-city")
+ .whenScenarioStateIs("succeeded")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type",
+ "application/vnd.maxmind.com-city+json; charset=UTF-8; version=2.1")
+ .withBody(body)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ CityResponse response = client.city(InetAddress.getByName("1.2.3.4"));
+ assertNotNull(response);
+
+ wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testRetriesOnConnectionReset_insights() throws Exception {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-insights")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
+ .willSetStateTo("succeeded"));
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-insights")
+ .whenScenarioStateIs("succeeded")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type",
+ "application/vnd.maxmind.com-insights+json; charset=UTF-8; version=2.1")
+ .withBody(body)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ InsightsResponse response = client.insights(InetAddress.getByName("1.2.3.4"));
+ assertNotNull(response);
+
+ wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testNoRetryOnHttpTimeoutException() {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withFixedDelay(2000)
+ .withBody("{}")));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .requestTimeout(Duration.ofMillis(100))
+ .build();
+
+ assertThrows(HttpTimeoutException.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+
+ wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testNoRetryOn5xx() {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse()
+ .withStatus(500)
+ .withHeader("Content-Type", "application/json")
+ .withBody("")));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ assertThrows(HttpException.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+
+ wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testNoRetryOn4xx() {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse()
+ .withStatus(402)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"code\":\"OUT_OF_QUERIES\",\"error\":\"out of credit\"}")));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ assertThrows(OutOfQueriesException.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+
+ wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
+ }
+
+ // Disabled on Windows: when WireMock immediately RSTs a fresh connection,
+ // the Windows TCP stack can cause the JDK's h2c upgrade probe to fail
+ // before negotiation completes, prompting the JDK to retry the request
+ // as plain HTTP/1.1. The HTTP/1.1 path then triggers the JDK's own
+ // idempotent-GET retry inside HttpClient.send(), producing two wire
+ // requests where the test expects one. This is platform-specific JDK
+ // behavior we cannot disable from application code; only idempotent
+ // methods (GET / HEAD) are affected.
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ public void testMaxRetriesZeroDisablesRetry() {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .maxRetries(0)
+ .build();
+
+ assertThrows(IOException.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+
+ wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
+ }
+
+ // Disabled on Windows: the JDK's internal idempotent-GET retry on the
+ // HTTP/1.1 fallback path (triggered by Windows-specific h2c upgrade
+ // failures against an immediate RST) stacks on top of our retry loop,
+ // multiplying wire counts (each of our 3 attempts becomes 2 wire
+ // requests, so the count assertion sees 6 instead of 3). This is JDK
+ // behavior we cannot disable from application code.
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ public void testRetriesExhausted() {
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .maxRetries(2)
+ .build();
+
+ IOException ex = assertThrows(IOException.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+
+ // 1 initial attempt + 2 retries.
+ wireMock.verify(3, getRequestedFor(urlEqualTo(url)));
+ // The final exception should carry the prior failures as suppressed
+ // exceptions so the full retry history is visible in stack traces.
+ assertEquals(2, ex.getSuppressed().length,
+ "expected the 2 prior IOExceptions to be attached as suppressed");
+ }
+
+ @Test
+ public void testCustomHttpClientStillRetries() throws Exception {
+ // The Javadoc on Builder.httpClient(HttpClient) promises that the SDK's
+ // transport-failure retry wraps any supplied client. Verify it.
+ String url = "/geoip/v2.1/country/1.2.3.4";
+ String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-custom-client")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
+ .willSetStateTo("succeeded"));
+
+ wireMock.stubFor(get(urlEqualTo(url))
+ .inScenario("retry-custom-client")
+ .whenScenarioStateIs("succeeded")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type",
+ "application/vnd.maxmind.com-country+json; charset=UTF-8; version=2.1")
+ .withBody(body)));
+
+ HttpClient customClient = HttpClient.newBuilder().build();
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .httpClient(customClient)
+ .build();
+
+ CountryResponse response = client.country(InetAddress.getByName("1.2.3.4"));
+ assertNotNull(response);
+
+ wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ public void testNegativeMaxRetriesThrows() {
+ WebServiceClient.Builder builder = new WebServiceClient.Builder(6, "0123456789");
+ assertThrows(IllegalArgumentException.class, () -> builder.maxRetries(-1));
+ }
+
+ @Test
+ public void testInterruptedThreadAbortsBeforeSend() {
+ // When the calling thread is already interrupted, HttpClient.send
+ // checks the interrupt status and throws InterruptedException before
+ // dispatching any wire request. The exception is caught and rewrapped
+ // as GeoIp2Exception, with the interrupt flag restored on the calling
+ // thread. The wire-count assertion (zero) guards against a regression
+ // where a pre-interrupt would silently let the request proceed.
+ // NOTE: this test does not exercise the predicate's own
+ // Thread.currentThread().isInterrupted() short-circuit, since the JDK
+ // aborts before that branch can be reached; a true mid-flight
+ // interrupt is hard to test deterministically.
+ String url = "/geoip/v2.1/insights/1.2.3.4";
+ wireMock.stubFor(get(urlEqualTo(url))
+ .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
+
+ WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
+ .host("localhost")
+ .port(wireMock.getPort())
+ .disableHttps()
+ .build();
+
+ Thread.currentThread().interrupt();
+ try {
+ assertThrows(GeoIp2Exception.class,
+ () -> client.insights(InetAddress.getByName("1.2.3.4")));
+ assertTrue(Thread.currentThread().isInterrupted(),
+ "interrupt flag should remain set after the call");
+ } finally {
+ // Clear the interrupt flag so it does not leak to other tests
+ // (and so wireMock.verify below isn't affected by it).
+ Thread.interrupted();
+ }
+ wireMock.verify(0, getRequestedFor(urlEqualTo(url)));
+ }
+
}