Skip to content

Commit 77defec

Browse files
authored
Merge pull request #693 from maxmind/greg/stf-322
STF-322: Bounded transport-failure retry in WebServiceClient
2 parents 9c48198 + 716c849 commit 77defec

6 files changed

Lines changed: 511 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
CHANGELOG
22
=========
33

4+
5.1.0 (unreleased)
5+
------------------
6+
7+
* Added `WebServiceClient.Builder.maxRetries(int)` to bound transport-failure
8+
retries (default 1; set 0 to disable). See the README for retry semantics.
9+
**Behavior change:** previously, transient transport failures (connection
10+
reset, broken pipe, etc.) surfaced to callers immediately. They are now
11+
retried once by default; pass `.maxRetries(0)` to restore the prior
12+
behavior.
13+
414
5.0.2 (2025-12-08)
515
------------------
616

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ are not created for each request.
7272
See the [API documentation](https://maxmind.github.io/GeoIP2-java/) for
7373
more details.
7474

75+
### Connection pooling and transport retries ###
76+
77+
`WebServiceClient` reuses pooled HTTP connections for performance. Idle
78+
connections can be silently closed by load balancers or other
79+
intermediaries; when the next request reuses such a half-closed connection,
80+
the JDK reports the failure as a `Connection reset`, `Broken pipe`, or
81+
similar transport error.
82+
83+
To smooth over these intermittent failures, the SDK retries once by
84+
default. Most transport-level `IOException`s are retried; the SDK does
85+
**not** retry:
86+
87+
* **Timeouts** (`HttpTimeoutException`, including connect-phase timeouts).
88+
The SDK honors the timeouts you configure rather than extending them.
89+
* **Cancellation** (`InterruptedIOException`, or any interrupt observed
90+
before the request runs).
91+
* **Typically deterministic failures**`UnknownHostException`,
92+
`ConnectException`, `SSLHandshakeException`, `SSLPeerUnverifiedException`.
93+
Retrying these would just delay surfacing a config bug.
94+
95+
HTTP 4xx and 5xx responses are surfaced through the existing exception
96+
hierarchy and are never retried. Request bodies are replayable, so retried
97+
requests are byte-identical to the original.
98+
99+
You can change the retry budget via the builder:
100+
101+
```java
102+
WebServiceClient client = new WebServiceClient.Builder(42, "license_key")
103+
.maxRetries(2) // up to two retries (three total attempts)
104+
.build();
105+
```
106+
107+
Set `.maxRetries(0)` to disable the retry entirely. Negative values throw
108+
`IllegalArgumentException`.
109+
110+
If you frequently see `Connection reset` errors, you can also reduce the
111+
JDK's keep-alive timeout via the system property
112+
`jdk.httpclient.keepalive.timeout` (in seconds) to evict pooled connections
113+
before any intermediary does so.
114+
75115
## Web Service Example ##
76116

77117
### Country Service ###

mise.lock

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mise.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[settings]
2+
experimental = true
3+
lockfile = true
4+
disable_backends = [
5+
"asdf",
6+
"vfox",
7+
]
8+
9+
[tools]
10+
java = "latest"
11+
maven = "latest"
12+
13+
[hooks]
14+
enter = "mise install --quiet --locked"
15+
16+
[[watch_files]]
17+
patterns = ["mise.toml", "mise.lock"]
18+
run = "mise install --quiet --locked"

src/main/java/com/maxmind/geoip2/WebServiceClient.java

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,26 @@
2020
import com.maxmind.geoip2.model.InsightsResponse;
2121
import java.io.IOException;
2222
import java.io.InputStream;
23+
import java.io.InterruptedIOException;
24+
import java.net.ConnectException;
2325
import java.net.InetAddress;
2426
import java.net.InetSocketAddress;
2527
import java.net.ProxySelector;
2628
import java.net.URI;
2729
import java.net.URISyntaxException;
30+
import java.net.UnknownHostException;
2831
import java.net.http.HttpClient;
2932
import java.net.http.HttpRequest;
3033
import java.net.http.HttpResponse;
34+
import java.net.http.HttpTimeoutException;
3135
import java.nio.charset.StandardCharsets;
3236
import java.time.Duration;
3337
import java.util.Base64;
3438
import java.util.HashMap;
3539
import java.util.List;
3640
import java.util.Map;
41+
import javax.net.ssl.SSLHandshakeException;
42+
import javax.net.ssl.SSLPeerUnverifiedException;
3743

3844
/**
3945
* <p>
@@ -112,6 +118,7 @@ public class WebServiceClient implements WebServiceProvider {
112118
private final boolean useHttps;
113119
private final int port;
114120
private final Duration requestTimeout;
121+
private final int maxRetries;
115122
private final String userAgent = "GeoIP2/"
116123
+ getClass().getPackage().getImplementationVersion()
117124
+ " (Java/" + System.getProperty("java.version") + ")";
@@ -125,6 +132,7 @@ private WebServiceClient(Builder builder) {
125132
this.port = builder.port;
126133
this.useHttps = builder.useHttps;
127134
this.locales = builder.locales;
135+
this.maxRetries = builder.maxRetries;
128136

129137
// HttpClient supports basic auth, but it will only send it after the
130138
// server responds with an unauthorized. As such, we just make the
@@ -182,6 +190,7 @@ public static final class Builder {
182190
List<String> locales = List.of("en");
183191
private ProxySelector proxy = null;
184192
private HttpClient httpClient = null;
193+
private int maxRetries = 1;
185194

186195
/**
187196
* @param accountId Your MaxMind account ID.
@@ -197,6 +206,7 @@ public Builder(int accountId, String licenseKey) {
197206
* @param val Timeout duration to establish a connection to the
198207
* web service. The default is 3 seconds.
199208
* @return Builder object
209+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
200210
*/
201211
public Builder connectTimeout(Duration val) {
202212
this.connectTimeout = val;
@@ -251,6 +261,7 @@ public Builder locales(List<String> val) {
251261
/**
252262
* @param val Request timeout duration. The default is 20 seconds.
253263
* @return Builder object
264+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
254265
*/
255266
public Builder requestTimeout(Duration val) {
256267
this.requestTimeout = val;
@@ -271,13 +282,38 @@ public Builder proxy(ProxySelector val) {
271282
* @param val the custom HttpClient to use for requests. When providing a
272283
* custom HttpClient, you cannot also set connectTimeout or proxy
273284
* parameters as these should be configured on the provided client.
285+
* <p>
286+
* The SDK applies its own transport-failure retry on top of any
287+
* supplied client; pass {@code 0} to {@link #maxRetries(int)} to
288+
* disable.
274289
* @return Builder object
275290
*/
276291
public Builder httpClient(HttpClient val) {
277292
this.httpClient = val;
278293
return this;
279294
}
280295

296+
/**
297+
* @param val Maximum number of retries on transport-level failures
298+
* (connection reset, broken pipe, EOF, ...).
299+
* Applies uniformly to all endpoints. Defaults to 1.
300+
* Set to 0 to disable.
301+
* @return Builder.
302+
* @throws IllegalArgumentException if {@code val} is negative.
303+
* @apiNote Retries fire only on transient transport failures.
304+
* Timeouts and other non-transient errors are not retried — see
305+
* the README for the complete list. When all attempts fail,
306+
* the prior {@code IOException}s are attached via
307+
* {@link Throwable#getSuppressed()} for debugging.
308+
*/
309+
public Builder maxRetries(int val) {
310+
if (val < 0) {
311+
throw new IllegalArgumentException("maxRetries must not be negative");
312+
}
313+
maxRetries = val;
314+
return this;
315+
}
316+
281317
/**
282318
* @return an instance of {@code WebServiceClient} created from the
283319
* fields set on this builder.
@@ -371,18 +407,80 @@ private <T> T responseFor(String path, InetAddress ipAddress, Class<T> cls)
371407
.GET()
372408
.build();
373409
try {
374-
var response = this.httpClient
375-
.send(request, HttpResponse.BodyHandlers.ofInputStream());
410+
var response = sendWithRetry(request);
376411
try {
377412
return handleResponse(response, cls);
378413
} finally {
379414
response.body().close();
380415
}
381416
} catch (InterruptedException e) {
417+
Thread.currentThread().interrupt();
382418
throw new GeoIp2Exception("Interrupted sending request", e);
383419
}
384420
}
385421

422+
private HttpResponse<InputStream> sendWithRetry(HttpRequest request)
423+
throws IOException, InterruptedException {
424+
int attempts = 0;
425+
IOException prior = null;
426+
while (true) {
427+
try {
428+
return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
429+
} catch (IOException e) {
430+
// Attach the immediate predecessor so the suppressed chain
431+
// carries the full retry history (each link is the previous
432+
// attempt's failure; walk via Throwable#getSuppressed).
433+
if (prior != null) {
434+
e.addSuppressed(prior);
435+
}
436+
if (!isRetriableTransportFailure(e) || attempts >= maxRetries) {
437+
throw e;
438+
}
439+
prior = e;
440+
attempts++;
441+
}
442+
}
443+
}
444+
445+
private static boolean isRetriableTransportFailure(IOException e) {
446+
if (Thread.currentThread().isInterrupted()) {
447+
return false;
448+
}
449+
// Both connect-phase and request-phase timeouts are customer-set
450+
// budgets that retrying would silently extend.
451+
// HttpConnectTimeoutException extends HttpTimeoutException, so this
452+
// single check covers both.
453+
if (e instanceof HttpTimeoutException) {
454+
return false;
455+
}
456+
// The thread was interrupted during I/O; honor the cancellation.
457+
if (e instanceof InterruptedIOException) {
458+
return false;
459+
}
460+
// The four exclusions below are *occasionally* transient (DNS hiccup,
461+
// TCP RST race during cert rotation, brief LB outage), but treating
462+
// them as deterministic is a deliberate product decision: retrying
463+
// would mask config bugs behind 2x latency, and the customer-visible
464+
// cost of one extra failed call on a true transient is small.
465+
if (e instanceof UnknownHostException) {
466+
return false;
467+
}
468+
if (e instanceof ConnectException) {
469+
return false;
470+
}
471+
if (e instanceof SSLHandshakeException) {
472+
return false;
473+
}
474+
if (e instanceof SSLPeerUnverifiedException) {
475+
return false;
476+
}
477+
// Everything else from httpClient.send() is a transport failure
478+
// (connection reset, broken pipe, EOF, closed channel, ...).
479+
// HTTP 4xx and 5xx responses do not reach this predicate -- they come
480+
// back as HttpResponse objects rather than IOExceptions.
481+
return true;
482+
}
483+
386484
private <T> T handleResponse(HttpResponse<InputStream> response, Class<T> cls)
387485
throws GeoIp2Exception, IOException {
388486
var status = response.statusCode();

0 commit comments

Comments
 (0)