From 185cde876e0692d07800909bdc3a1b45940922bd Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Sat, 2 May 2026 01:51:06 +0000 Subject: [PATCH 1/5] STF-322: Add bounded transport-failure retry to WebServiceClient When the JDK HttpClient pool reuses an idle connection that an intermediary (load balancer, proxy, NAT) has silently closed, the next send() fails with "Connection reset", "Broken pipe", or related transport errors. A single retry recovers transparently without exposing this race to callers. The default JDK keep-alive timeout exceeds many intermediaries' idle timeout, making this mismatch the common case. The retry predicate is permissive by exclusion: any IOException from httpClient.send() is retried EXCEPT HttpTimeoutException (covering both request-phase and connect-phase timeouts, since HttpConnectTimeoutException is a subclass) and InterruptedIOException. Both timeouts are customer-set budgets that retrying would silently extend; InterruptedIOException is a user-cancellation signal. HTTP 4xx and 5xx responses are surfaced as HttpException (and subclasses) from a separate code path -- they come back as HttpResponse objects rather than IOExceptions, so the predicate is structurally unable to retry them. Customers can opt out via .maxRetries(0). Default is 1 (one retry, two total attempts). The interrupt flag is restored before rewrapping InterruptedException, and a pre-set interrupt short-circuits the predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/maxmind/geoip2/WebServiceClient.java | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/maxmind/geoip2/WebServiceClient.java b/src/main/java/com/maxmind/geoip2/WebServiceClient.java index 125a3af9..33645548 100644 --- a/src/main/java/com/maxmind/geoip2/WebServiceClient.java +++ b/src/main/java/com/maxmind/geoip2/WebServiceClient.java @@ -20,20 +20,26 @@ 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.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 +118,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 +132,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 +190,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 +206,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 +261,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 +282,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 +293,27 @@ 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 Retries fire only on transient transport failures. + * Timeouts and other non-transient errors are not retried — see + * the README for the complete list. When all attempts fail, + * the prior {@code IOException}s are attached via + * {@link Throwable#getSuppressed()} for debugging. + */ + 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,8 +407,7 @@ 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 { @@ -383,6 +418,68 @@ private T responseFor(String path, InetAddress ipAddress, Class cls) } } + private HttpResponse sendWithRetry(HttpRequest request) + throws IOException, InterruptedException { + int attempts = 0; + IOException prior = null; + while (true) { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (IOException e) { + // Attach the immediate predecessor so the suppressed chain + // carries the full retry history (each link is the previous + // attempt's failure; walk via Throwable#getSuppressed). + if (prior != null) { + e.addSuppressed(prior); + } + if (!isRetriableTransportFailure(e) || attempts >= maxRetries) { + throw e; + } + prior = 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(); From a13a4af62a8680e15370377f0de6b36c13aa0465 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 6 May 2026 19:44:49 +0000 Subject: [PATCH 2/5] STF-322: Restore interrupt flag in InterruptedException rewrap path The existing catch (InterruptedException) block in responseFor() rewraps into GeoIp2Exception without restoring the thread's interrupt status, silently swallowing the cancellation signal. Per Java's interruption protocol, code that catches InterruptedException without rethrowing it should re-set the flag so callers up the stack can observe the cancellation. This is an independent bug fix bundled into the STF-322 retry work because the retry feature exposes the path more often. Per project commit hygiene it lands as a separate commit so it can be cherry-picked or reverted on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/com/maxmind/geoip2/WebServiceClient.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/maxmind/geoip2/WebServiceClient.java b/src/main/java/com/maxmind/geoip2/WebServiceClient.java index 33645548..34293855 100644 --- a/src/main/java/com/maxmind/geoip2/WebServiceClient.java +++ b/src/main/java/com/maxmind/geoip2/WebServiceClient.java @@ -414,6 +414,7 @@ private T responseFor(String path, InetAddress ipAddress, Class cls) response.body().close(); } } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new GeoIp2Exception("Interrupted sending request", e); } } From 28c937b0ce3c02ad5bfdbae6625c7f3e9b2102ff Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Sat, 2 May 2026 01:52:22 +0000 Subject: [PATCH 3/5] STF-322: Add tests for transport-failure retry Cover all 9 scenarios: connection-reset retry on country, city, and insights endpoints, no retry on HttpTimeoutException, retry on connect timeout (deterministic via a closed local ServerSocket), no retry on 4xx/5xx, .maxRetries(0) opt-out, and pre-interrupt short-circuit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../maxmind/geoip2/WebServiceClientTest.java | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java b/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java index cd5c2af0..cbee646f 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,308 @@ 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 full retry history is reachable via the suppressed chain: each + // exception carries its immediate predecessor as a suppressed + // exception. Walk the chain and confirm we have 2 priors. + int priorCount = 0; + Throwable cur = ex; + while (cur.getSuppressed().length > 0) { + cur = cur.getSuppressed()[0]; + priorCount++; + } + assertEquals(2, priorCount, + "expected the 2 prior IOExceptions in the suppressed chain"); + } + + @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))); + } + } From a517511d43a14e7673616c364fb14d48fc3acdb8 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Sat, 2 May 2026 01:52:50 +0000 Subject: [PATCH 4/5] STF-322: Document transport-failure retry in README and CHANGELOG Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++++++++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) 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..e72907eb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,46 @@ 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` reuses pooled HTTP connections for performance. Idle +connections can be silently closed by load balancers or other +intermediaries; when the next request reuses such a half-closed connection, +the JDK reports the failure as a `Connection reset`, `Broken pipe`, or +similar transport error. + +To smooth over these intermittent failures, the SDK retries once by +default. Most transport-level `IOException`s are retried; the SDK does +**not** retry: + +* **Timeouts** (`HttpTimeoutException`, including connect-phase timeouts). + The SDK honors the timeouts you configure rather than extending them. +* **Cancellation** (`InterruptedIOException`, or any interrupt observed + before the request runs). +* **Typically deterministic failures** — `UnknownHostException`, + `ConnectException`, `SSLHandshakeException`, `SSLPeerUnverifiedException`. + Retrying these would just delay surfacing a config bug. + +HTTP 4xx and 5xx responses are surfaced through the existing exception +hierarchy and are never retried. Request bodies are replayable, 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 ### From 9888efc1a56b61ea767377e79025b14a9296116c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Sat, 2 May 2026 02:53:54 +0000 Subject: [PATCH 5/5] Add mise config Mirrors minfraud-api-java's mise.toml and mise.lock so Java and Maven versions are pinned and auto-installed on directory entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- mise.lock | 30 ++++++++++++++++++++++++++++++ mise.toml | 18 ++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 mise.lock create mode 100644 mise.toml 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"