1616import com .maxmind .minfraud .response .ScoreResponse ;
1717import java .io .IOException ;
1818import java .io .InputStream ;
19+ import java .io .InterruptedIOException ;
20+ import java .net .ConnectException ;
1921import java .net .ProxySelector ;
2022import java .net .URI ;
2123import java .net .URISyntaxException ;
24+ import java .net .UnknownHostException ;
2225import java .net .http .HttpClient ;
2326import java .net .http .HttpRequest ;
2427import java .net .http .HttpResponse ;
28+ import java .net .http .HttpTimeoutException ;
2529import java .nio .charset .StandardCharsets ;
2630import java .time .Duration ;
2731import java .util .Base64 ;
2832import java .util .Collections ;
2933import java .util .HashMap ;
3034import java .util .List ;
3135import 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