2020import com .maxmind .geoip2 .model .InsightsResponse ;
2121import java .io .IOException ;
2222import java .io .InputStream ;
23+ import java .io .InterruptedIOException ;
24+ import java .net .ConnectException ;
2325import java .net .InetAddress ;
2426import java .net .InetSocketAddress ;
2527import java .net .ProxySelector ;
2628import java .net .URI ;
2729import java .net .URISyntaxException ;
30+ import java .net .UnknownHostException ;
2831import java .net .http .HttpClient ;
2932import java .net .http .HttpRequest ;
3033import java .net .http .HttpResponse ;
34+ import java .net .http .HttpTimeoutException ;
3135import java .nio .charset .StandardCharsets ;
3236import java .time .Duration ;
3337import java .util .Base64 ;
3438import java .util .HashMap ;
3539import java .util .List ;
3640import java .util .Map ;
41+ import javax .net .ssl .SSLHandshakeException ;
42+ import javax .net .ssl .SSLPeerUnverifiedException ;
3743
3844/**
3945 * <p>
@@ -112,6 +118,7 @@ public class WebServiceClient implements WebServiceProvider {
112118 private final boolean useHttps ;
113119 private final int port ;
114120 private final Duration requestTimeout ;
121+ private final int maxRetries ;
115122 private final String userAgent = "GeoIP2/"
116123 + getClass ().getPackage ().getImplementationVersion ()
117124 + " (Java/" + System .getProperty ("java.version" ) + ")" ;
@@ -125,6 +132,7 @@ private WebServiceClient(Builder builder) {
125132 this .port = builder .port ;
126133 this .useHttps = builder .useHttps ;
127134 this .locales = builder .locales ;
135+ this .maxRetries = builder .maxRetries ;
128136
129137 // HttpClient supports basic auth, but it will only send it after the
130138 // server responds with an unauthorized. As such, we just make the
@@ -182,6 +190,7 @@ public static final class Builder {
182190 List <String > locales = List .of ("en" );
183191 private ProxySelector proxy = null ;
184192 private HttpClient httpClient = null ;
193+ private int maxRetries = 1 ;
185194
186195 /**
187196 * @param accountId Your MaxMind account ID.
@@ -197,6 +206,7 @@ public Builder(int accountId, String licenseKey) {
197206 * @param val Timeout duration to establish a connection to the
198207 * web service. The default is 3 seconds.
199208 * @return Builder object
209+ * @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
200210 */
201211 public Builder connectTimeout (Duration val ) {
202212 this .connectTimeout = val ;
@@ -251,6 +261,7 @@ public Builder locales(List<String> val) {
251261 /**
252262 * @param val Request timeout duration. The default is 20 seconds.
253263 * @return Builder object
264+ * @apiNote See {@link #maxRetries(int)} for how this timeout interacts with retries.
254265 */
255266 public Builder requestTimeout (Duration val ) {
256267 this .requestTimeout = val ;
@@ -271,13 +282,38 @@ public Builder proxy(ProxySelector val) {
271282 * @param val the custom HttpClient to use for requests. When providing a
272283 * custom HttpClient, you cannot also set connectTimeout or proxy
273284 * parameters as these should be configured on the provided client.
285+ * <p>
286+ * The SDK applies its own transport-failure retry on top of any
287+ * supplied client; pass {@code 0} to {@link #maxRetries(int)} to
288+ * disable.
274289 * @return Builder object
275290 */
276291 public Builder httpClient (HttpClient val ) {
277292 this .httpClient = val ;
278293 return this ;
279294 }
280295
296+ /**
297+ * @param val Maximum number of retries on transport-level failures
298+ * (connection reset, broken pipe, EOF, ...).
299+ * Applies uniformly to all endpoints. Defaults to 1.
300+ * Set to 0 to disable.
301+ * @return Builder.
302+ * @throws IllegalArgumentException if {@code val} is negative.
303+ * @apiNote Retries fire only on transient transport failures.
304+ * Timeouts and other non-transient errors are not retried — see
305+ * the README for the complete list. When all attempts fail,
306+ * the prior {@code IOException}s are attached via
307+ * {@link Throwable#getSuppressed()} for debugging.
308+ */
309+ public Builder maxRetries (int val ) {
310+ if (val < 0 ) {
311+ throw new IllegalArgumentException ("maxRetries must not be negative" );
312+ }
313+ maxRetries = val ;
314+ return this ;
315+ }
316+
281317 /**
282318 * @return an instance of {@code WebServiceClient} created from the
283319 * fields set on this builder.
@@ -371,18 +407,80 @@ private <T> T responseFor(String path, InetAddress ipAddress, Class<T> cls)
371407 .GET ()
372408 .build ();
373409 try {
374- var response = this .httpClient
375- .send (request , HttpResponse .BodyHandlers .ofInputStream ());
410+ var response = sendWithRetry (request );
376411 try {
377412 return handleResponse (response , cls );
378413 } finally {
379414 response .body ().close ();
380415 }
381416 } catch (InterruptedException e ) {
417+ Thread .currentThread ().interrupt ();
382418 throw new GeoIp2Exception ("Interrupted sending request" , e );
383419 }
384420 }
385421
422+ private HttpResponse <InputStream > sendWithRetry (HttpRequest request )
423+ throws IOException , InterruptedException {
424+ int attempts = 0 ;
425+ IOException prior = null ;
426+ while (true ) {
427+ try {
428+ return httpClient .send (request , HttpResponse .BodyHandlers .ofInputStream ());
429+ } catch (IOException e ) {
430+ // Attach the immediate predecessor so the suppressed chain
431+ // carries the full retry history (each link is the previous
432+ // attempt's failure; walk via Throwable#getSuppressed).
433+ if (prior != null ) {
434+ e .addSuppressed (prior );
435+ }
436+ if (!isRetriableTransportFailure (e ) || attempts >= maxRetries ) {
437+ throw e ;
438+ }
439+ prior = e ;
440+ attempts ++;
441+ }
442+ }
443+ }
444+
445+ private static boolean isRetriableTransportFailure (IOException e ) {
446+ if (Thread .currentThread ().isInterrupted ()) {
447+ return false ;
448+ }
449+ // Both connect-phase and request-phase timeouts are customer-set
450+ // budgets that retrying would silently extend.
451+ // HttpConnectTimeoutException extends HttpTimeoutException, so this
452+ // single check covers both.
453+ if (e instanceof HttpTimeoutException ) {
454+ return false ;
455+ }
456+ // The thread was interrupted during I/O; honor the cancellation.
457+ if (e instanceof InterruptedIOException ) {
458+ return false ;
459+ }
460+ // The four exclusions below are *occasionally* transient (DNS hiccup,
461+ // TCP RST race during cert rotation, brief LB outage), but treating
462+ // them as deterministic is a deliberate product decision: retrying
463+ // would mask config bugs behind 2x latency, and the customer-visible
464+ // cost of one extra failed call on a true transient is small.
465+ if (e instanceof UnknownHostException ) {
466+ return false ;
467+ }
468+ if (e instanceof ConnectException ) {
469+ return false ;
470+ }
471+ if (e instanceof SSLHandshakeException ) {
472+ return false ;
473+ }
474+ if (e instanceof SSLPeerUnverifiedException ) {
475+ return false ;
476+ }
477+ // Everything else from httpClient.send() is a transport failure
478+ // (connection reset, broken pipe, EOF, closed channel, ...).
479+ // HTTP 4xx and 5xx responses do not reach this predicate -- they come
480+ // back as HttpResponse objects rather than IOExceptions.
481+ return true ;
482+ }
483+
386484 private <T > T handleResponse (HttpResponse <InputStream > response , Class <T > cls )
387485 throws GeoIp2Exception , IOException {
388486 var status = response .statusCode ();
0 commit comments