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,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