Skip to content

Commit 3534024

Browse files
authored
Merge pull request #619 from maxmind/greg/stf-322
STF-322: Bounded transport-failure retry in WebServiceClient
2 parents 681bebc + 7576a52 commit 3534024

4 files changed

Lines changed: 398 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ CHANGELOG
77
* Added `FAT_ZEBRA` to the `Payment.Processor` enum.
88
* Added `CLEAR` to the `TransactionReport.Tag` enum for use with the Report
99
Transaction API.
10+
* Added `WebServiceClient.Builder.maxRetries(int)` to bound transport-failure
11+
retries (default 1; set 0 to disable). See the README for retry semantics.
12+
**Behavior change:** previously, transient transport failures (connection
13+
reset, broken pipe, etc.) surfaced to callers immediately. They are now
14+
retried once by default; pass `.maxRetries(0)` to restore the prior
15+
behavior.
1016

1117
4.2.0 (2026-02-26)
1218
------------------

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,46 @@ exception will be thrown.
110110

111111
See the API documentation for more details.
112112

113+
### Connection pooling and transport retries ###
114+
115+
`WebServiceClient` reuses pooled HTTP connections for performance. Idle
116+
connections can be silently closed by load balancers or other
117+
intermediaries; when the next request reuses such a half-closed connection,
118+
the JDK reports the failure as a `Connection reset`, `Broken pipe`, or
119+
similar transport error.
120+
121+
To smooth over these intermittent failures, the SDK retries once by
122+
default. Most transport-level `IOException`s are retried; the SDK does
123+
**not** retry:
124+
125+
* **Timeouts** (`HttpTimeoutException`, including connect-phase timeouts).
126+
The SDK honors the timeouts you configure rather than extending them.
127+
* **Cancellation** (`InterruptedIOException`, or any interrupt observed
128+
before the request runs).
129+
* **Typically deterministic failures**`UnknownHostException`,
130+
`ConnectException`, `SSLHandshakeException`, `SSLPeerUnverifiedException`.
131+
Retrying these would just delay surfacing a config bug.
132+
133+
HTTP 4xx and 5xx responses are surfaced through the existing exception
134+
hierarchy and are never retried. Request bodies are replayable, so retried
135+
requests are byte-identical to the original.
136+
137+
You can change the retry budget via the builder:
138+
139+
```java
140+
WebServiceClient client = new WebServiceClient.Builder(6, "ABCD567890")
141+
.maxRetries(2) // up to two retries (three total attempts)
142+
.build();
143+
```
144+
145+
Set `.maxRetries(0)` to disable the retry entirely. Negative values throw
146+
`IllegalArgumentException`.
147+
148+
If you frequently see `Connection reset` errors, you can also reduce the
149+
JDK's keep-alive timeout via the system property
150+
`jdk.httpclient.keepalive.timeout` (in seconds) to evict pooled connections
151+
before any intermediary does so.
152+
113153
### Exceptions ###
114154

115155
Runtime exceptions:

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

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,25 @@
1616
import com.maxmind.minfraud.response.ScoreResponse;
1717
import java.io.IOException;
1818
import java.io.InputStream;
19+
import java.io.InterruptedIOException;
20+
import java.net.ConnectException;
1921
import java.net.ProxySelector;
2022
import java.net.URI;
2123
import java.net.URISyntaxException;
24+
import java.net.UnknownHostException;
2225
import java.net.http.HttpClient;
2326
import java.net.http.HttpRequest;
2427
import java.net.http.HttpResponse;
28+
import java.net.http.HttpTimeoutException;
2529
import java.nio.charset.StandardCharsets;
2630
import java.time.Duration;
2731
import java.util.Base64;
2832
import java.util.Collections;
2933
import java.util.HashMap;
3034
import java.util.List;
3135
import java.util.Map;
36+
import javax.net.ssl.SSLHandshakeException;
37+
import javax.net.ssl.SSLPeerUnverifiedException;
3238

3339
/**
3440
* Client for MaxMind minFraud Score, Insights, and Factors
@@ -45,6 +51,7 @@ public final class WebServiceClient {
4551
private final boolean useHttps;
4652
private final List<String> locales;
4753
private final Duration requestTimeout;
54+
private final int maxRetries;
4855

4956
private final HttpClient httpClient;
5057

@@ -63,6 +70,7 @@ private WebServiceClient(WebServiceClient.Builder builder) {
6370
.getBytes(StandardCharsets.UTF_8));
6471

6572
requestTimeout = builder.requestTimeout;
73+
maxRetries = builder.maxRetries;
6674
if (builder.httpClient != null) {
6775
httpClient = builder.httpClient;
6876
} else {
@@ -106,6 +114,7 @@ public static final class Builder {
106114
List<String> locales = List.of("en");
107115
private ProxySelector proxy;
108116
private HttpClient httpClient;
117+
private int maxRetries = 1;
109118

110119
/**
111120
* @param accountId Your MaxMind account ID.
@@ -120,6 +129,7 @@ public Builder(int accountId, String licenseKey) {
120129
* @param val Timeout duration to establish a connection to the web service. There is no
121130
* timeout by default.
122131
* @return Builder object
132+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
123133
*/
124134
public WebServiceClient.Builder connectTimeout(Duration val) {
125135
connectTimeout = val;
@@ -173,8 +183,9 @@ public WebServiceClient.Builder locales(List<String> val) {
173183

174184

175185
/**
176-
* @param val Request timeout duration. here is no timeout by default.
186+
* @param val Request timeout duration. There is no timeout by default.
177187
* @return Builder object
188+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
178189
*/
179190
public Builder requestTimeout(Duration val) {
180191
requestTimeout = val;
@@ -195,13 +206,37 @@ public Builder proxy(ProxySelector val) {
195206
* @param val the HttpClient to use when making requests. When provided,
196207
* connectTimeout and proxy settings will be ignored as the
197208
* custom client should handle these configurations.
209+
* <p>
210+
* The SDK applies its own transport-failure retry on top of any supplied
211+
* client; pass {@code 0} to {@link #maxRetries(int)} to disable.
198212
* @return Builder object
199213
*/
200214
public Builder httpClient(HttpClient val) {
201215
httpClient = val;
202216
return this;
203217
}
204218

219+
/**
220+
* @param val Maximum number of retries on transport-level failures
221+
* (connection reset, broken pipe, EOF, ...).
222+
* Applies uniformly to all endpoints. Defaults to 1.
223+
* Set to 0 to disable.
224+
* @return Builder.
225+
* @throws IllegalArgumentException if {@code val} is negative.
226+
* @apiNote Retries fire only on transient transport failures.
227+
* Timeouts and other non-transient errors are not retried — see
228+
* the README for the complete list. When all attempts fail,
229+
* the prior {@code IOException}s are attached via
230+
* {@link Throwable#getSuppressed()} for debugging.
231+
*/
232+
public Builder maxRetries(int val) {
233+
if (val < 0) {
234+
throw new IllegalArgumentException("maxRetries must not be negative");
235+
}
236+
maxRetries = val;
237+
return this;
238+
}
239+
205240
/**
206241
* @return an instance of {@code WebServiceClient} created from the fields set on this
207242
* builder.
@@ -311,10 +346,11 @@ public void reportTransaction(TransactionReport transaction) throws IOException,
311346

312347
HttpResponse<InputStream> response = null;
313348
try {
314-
response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
349+
response = sendWithRetry(request);
315350
maybeThrowException(response, uri);
316351
exhaustBody(response);
317352
} catch (InterruptedException e) {
353+
Thread.currentThread().interrupt();
318354
throw new MinFraudException("Interrupted sending request", e);
319355
} finally {
320356
if (response != null) {
@@ -333,9 +369,10 @@ private <T> T responseFor(String service, AbstractModel transaction, Class<T> cl
333369

334370
HttpResponse<InputStream> response = null;
335371
try {
336-
response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
372+
response = sendWithRetry(request);
337373
return handleResponse(response, uri, cls);
338374
} catch (InterruptedException e) {
375+
Thread.currentThread().interrupt();
339376
throw new MinFraudException("Interrupted sending request", e);
340377
} finally {
341378
if (response != null) {
@@ -344,6 +381,68 @@ private <T> T responseFor(String service, AbstractModel transaction, Class<T> cl
344381
}
345382
}
346383

384+
private HttpResponse<InputStream> sendWithRetry(HttpRequest request)
385+
throws IOException, InterruptedException {
386+
int attempts = 0;
387+
IOException prior = null;
388+
while (true) {
389+
try {
390+
return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
391+
} catch (IOException e) {
392+
// Attach the immediate predecessor so the suppressed chain
393+
// carries the full retry history (each link is the previous
394+
// attempt's failure; walk via Throwable#getSuppressed).
395+
if (prior != null) {
396+
e.addSuppressed(prior);
397+
}
398+
if (!isRetriableTransportFailure(e) || attempts >= maxRetries) {
399+
throw e;
400+
}
401+
prior = e;
402+
attempts++;
403+
}
404+
}
405+
}
406+
407+
private static boolean isRetriableTransportFailure(IOException e) {
408+
if (Thread.currentThread().isInterrupted()) {
409+
return false;
410+
}
411+
// Both connect-phase and request-phase timeouts are customer-set
412+
// budgets that retrying would silently extend.
413+
// HttpConnectTimeoutException extends HttpTimeoutException, so this
414+
// single check covers both.
415+
if (e instanceof HttpTimeoutException) {
416+
return false;
417+
}
418+
// The thread was interrupted during I/O; honor the cancellation.
419+
if (e instanceof InterruptedIOException) {
420+
return false;
421+
}
422+
// The four exclusions below are *occasionally* transient (DNS hiccup,
423+
// TCP RST race during cert rotation, brief LB outage), but treating
424+
// them as deterministic is a deliberate product decision: retrying
425+
// would mask config bugs behind 2x latency, and the customer-visible
426+
// cost of one extra failed call on a true transient is small.
427+
if (e instanceof UnknownHostException) {
428+
return false;
429+
}
430+
if (e instanceof ConnectException) {
431+
return false;
432+
}
433+
if (e instanceof SSLHandshakeException) {
434+
return false;
435+
}
436+
if (e instanceof SSLPeerUnverifiedException) {
437+
return false;
438+
}
439+
// Everything else from httpClient.send() is a transport failure
440+
// (connection reset, broken pipe, EOF, closed channel, ...).
441+
// HTTP 4xx and 5xx responses do not reach this predicate -- they come
442+
// back as HttpResponse objects rather than IOExceptions.
443+
return true;
444+
}
445+
347446
private HttpRequest requestFor(AbstractModel transaction, URI uri)
348447
throws MinFraudException, IOException {
349448
var builder = HttpRequest.newBuilder()
@@ -354,6 +453,7 @@ private HttpRequest requestFor(AbstractModel transaction, URI uri)
354453
.header("User-Agent", userAgent)
355454
// XXX - creating this JSON string is somewhat wasteful. We
356455
// could use an input stream instead.
456+
// BodyPublishers.ofString() is replayable; safe for retry attempts
357457
.POST(HttpRequest.BodyPublishers.ofString(transaction.toJson()));
358458

359459
if (requestTimeout != null) {

0 commit comments

Comments
 (0)