Skip to content

Commit d2480a4

Browse files
oschwaldclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 68f7c7d commit d2480a4

1 file changed

Lines changed: 102 additions & 3 deletions

File tree

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

Lines changed: 102 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,38 @@ 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; customers can disable it via {@link #maxRetries(int)} with
212+
* {@code .maxRetries(0)}.
198213
* @return Builder object
199214
*/
200215
public Builder httpClient(HttpClient val) {
201216
httpClient = val;
202217
return this;
203218
}
204219

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

312348
HttpResponse<InputStream> response = null;
313349
try {
314-
response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
350+
response = sendWithRetry(request);
315351
maybeThrowException(response, uri);
316352
exhaustBody(response);
317353
} catch (InterruptedException e) {
@@ -333,7 +369,7 @@ 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) {
339375
throw new MinFraudException("Interrupted sending request", e);
@@ -344,6 +380,68 @@ private <T> T responseFor(String service, AbstractModel transaction, Class<T> cl
344380
}
345381
}
346382

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

359458
if (requestTimeout != null) {

0 commit comments

Comments
 (0)