Skip to content

Commit 2f068fa

Browse files
committed
fixup! STF-322: Add bounded transport-failure retry to WebServiceClient
1 parent a52fa6b commit 2f068fa

1 file changed

Lines changed: 39 additions & 18 deletions

File tree

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

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
import java.io.IOException;
1818
import java.io.InputStream;
1919
import java.io.InterruptedIOException;
20+
import java.net.ConnectException;
2021
import java.net.ProxySelector;
2122
import java.net.URI;
2223
import java.net.URISyntaxException;
24+
import java.net.UnknownHostException;
2325
import java.net.http.HttpClient;
2426
import java.net.http.HttpRequest;
2527
import java.net.http.HttpResponse;
@@ -31,6 +33,8 @@
3133
import java.util.HashMap;
3234
import java.util.List;
3335
import java.util.Map;
36+
import javax.net.ssl.SSLHandshakeException;
37+
import javax.net.ssl.SSLPeerUnverifiedException;
3438

3539
/**
3640
* Client for MaxMind minFraud Score, Insights, and Factors
@@ -124,13 +128,8 @@ public Builder(int accountId, String licenseKey) {
124128
/**
125129
* @param val Timeout duration to establish a connection to the web service. There is no
126130
* timeout by default.
127-
* <p>
128-
* When {@code maxRetries > 0}, one API call may incur up to
129-
* {@code (maxRetries + 1)} connection attempts, each subject to
130-
* {@code connectTimeout} and {@code requestTimeout}. Worst-case wall-clock
131-
* duration is roughly
132-
* {@code (maxRetries + 1) x (connectTimeout + requestTimeout)}.
133131
* @return Builder object
132+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
134133
*/
135134
public WebServiceClient.Builder connectTimeout(Duration val) {
136135
connectTimeout = val;
@@ -184,14 +183,9 @@ public WebServiceClient.Builder locales(List<String> val) {
184183

185184

186185
/**
187-
* @param val Request timeout duration. here is no timeout by default.
188-
* <p>
189-
* When {@code maxRetries > 0}, one API call may incur up to
190-
* {@code (maxRetries + 1)} connection attempts, each subject to
191-
* {@code connectTimeout} and {@code requestTimeout}. Worst-case wall-clock
192-
* duration is roughly
193-
* {@code (maxRetries + 1) x (connectTimeout + requestTimeout)}.
186+
* @param val Request timeout duration. There is no timeout by default.
194187
* @return Builder object
188+
* @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
195189
*/
196190
public Builder requestTimeout(Duration val) {
197191
requestTimeout = val;
@@ -225,11 +219,20 @@ public Builder httpClient(HttpClient val) {
225219

226220
/**
227221
* @param val Maximum number of retries on transport-level failures
228-
* (connection reset, broken pipe, connect timeout, ...).
222+
* (connection reset, broken pipe, EOF, ...).
229223
* Applies uniformly to all endpoints. Defaults to 1.
230224
* Set to 0 to disable.
231225
* @return Builder.
232226
* @throws IllegalArgumentException if {@code val} is negative.
227+
* @apiNote Timeouts are not retried ({@link java.net.http.HttpTimeoutException},
228+
* including the connect-phase subclass
229+
* {@link java.net.http.HttpConnectTimeoutException}). When
230+
* {@code maxRetries > 0}, retries are triggered only by fast transport
231+
* failures, so each attempt is independently bounded by
232+
* {@link #connectTimeout(Duration)} and {@link #requestTimeout(Duration)}.
233+
* The multiplied worst-case wall clock a naive reading suggests is
234+
* unreachable in practice, since hitting the timeout aborts the call
235+
* rather than triggering a retry.
233236
*/
234237
public Builder maxRetries(int val) {
235238
if (val < 0) {
@@ -386,13 +389,18 @@ private <T> T responseFor(String service, AbstractModel transaction, Class<T> cl
386389
private HttpResponse<InputStream> sendWithRetry(HttpRequest request)
387390
throws IOException, InterruptedException {
388391
int attempts = 0;
392+
IOException prior = null;
389393
while (true) {
390394
try {
391395
return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
392396
} catch (IOException e) {
397+
if (prior != null) {
398+
e.addSuppressed(prior);
399+
}
393400
if (!isRetriableTransportFailure(e) || attempts >= maxRetries) {
394401
throw e;
395402
}
403+
prior = e;
396404
attempts++;
397405
}
398406
}
@@ -413,11 +421,24 @@ private static boolean isRetriableTransportFailure(IOException e) {
413421
if (e instanceof InterruptedIOException) {
414422
return false;
415423
}
424+
// Typically deterministic failures: retrying just delays surfacing the
425+
// config bug without recovering the request.
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+
}
416438
// Everything else from httpClient.send() is a transport failure
417-
// (connection reset, broken pipe, EOF, closed channel, connect refused,
418-
// DNS, TLS, protocol). HTTP 4xx and 5xx responses cannot reach this
419-
// predicate -- they come back as HttpResponse objects rather than
420-
// IOExceptions.
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.
421442
return true;
422443
}
423444

0 commit comments

Comments
 (0)