From e7f2b7aecdbbaae2d8c9298a57d7f8b1d8ca9d20 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 | 99 ++++++++++++++++++- 1 file changed, 97 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..6c071b9f 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,31 @@ 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)}. + * The multiplied worst-case wall clock a naive reading suggests is + * unreachable in practice, since hitting the timeout aborts the call + * rather than triggering a retry. + */ + 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 +411,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 +422,62 @@ 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) { + 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; + } + // Typically deterministic failures: retrying just delays surfacing the + // config bug without recovering the request. + 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 5a12750b9a2f7925daa89047c38d4a2c0c13624b 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 6c071b9f..dab7b022 100644 --- a/src/main/java/com/maxmind/geoip2/WebServiceClient.java +++ b/src/main/java/com/maxmind/geoip2/WebServiceClient.java @@ -418,6 +418,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 4e93c3e2a01496167e1a0fe03125eb0cf2f25723 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 | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java b/src/test/java/com/maxmind/geoip2/WebServiceClientTest.java index cd5c2af0..3e4a2f53 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,255 @@ 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; the equivalent + // POST-based test in minfraud-api-java is unaffected because the JDK + // does not internally retry non-idempotent requests. + @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 for the same reason as testMaxRetriesZeroDisablesRetry: + // the JDK's internal idempotent-GET retry can stack on top of our retry + // loop on the HTTP/1.1 fallback path, multiplying wire counts. + @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(); + + assertThrows(IOException.class, + () -> client.insights(InetAddress.getByName("1.2.3.4"))); + + // 1 initial attempt + 2 retries. + wireMock.verify(3, getRequestedFor(urlEqualTo(url))); + } + + @Test + public void testNegativeMaxRetriesThrows() { + WebServiceClient.Builder builder = new WebServiceClient.Builder(6, "0123456789"); + assertThrows(IllegalArgumentException.class, () -> builder.maxRetries(-1)); + } + + @Test + public void testInterruptDuringRetry() { + // Pre-interrupting the calling thread aborts the call before any wire + // request is dispatched: HttpClient.send checks the interrupt status + // and throws InterruptedException, which is caught and rewrapped as + // GeoIp2Exception with the interrupt flag restored. The wire-count + // assertion (zero) guards against a regression where pre-interrupt + // would silently let the request proceed. + 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 a9bfefce37eab996dc4b050eb02ec10f20ac80e1 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 | 6 ++++++ README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2353accc..43872f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ 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. + 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 ### From 3120c7c22eb9dd3bbc93e06ab239377dc4b30990 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"