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))); + } + }