Skip to content

Commit 4e93c3e

Browse files
oschwaldclaude
andcommitted
STF-322: Add tests for transport-failure retry
Cover all 9 scenarios: connection-reset retry on country, city, and insights endpoints, no retry on HttpTimeoutException, retry on connect timeout (deterministic via a closed local ServerSocket), no retry on 4xx/5xx, .maxRetries(0) opt-out, and pre-interrupt short-circuit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5a12750 commit 4e93c3e

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

src/test/java/com/maxmind/geoip2/WebServiceClientTest.java

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
44
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
55
import static com.github.tomakehurst.wiremock.client.WireMock.get;
6+
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
67
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
78
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
89
import static com.jcabi.matchers.RegexMatchers.matchesPattern;
@@ -15,15 +16,19 @@
1516
import static org.junit.jupiter.api.Assertions.assertThrows;
1617
import static org.junit.jupiter.api.Assertions.assertTrue;
1718

19+
import com.github.tomakehurst.wiremock.http.Fault;
1820
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
1921
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
22+
import com.github.tomakehurst.wiremock.stubbing.Scenario;
2023
import com.maxmind.geoip2.exception.AddressNotFoundException;
2124
import com.maxmind.geoip2.exception.AuthenticationException;
2225
import com.maxmind.geoip2.exception.GeoIp2Exception;
2326
import com.maxmind.geoip2.exception.HttpException;
2427
import com.maxmind.geoip2.exception.InvalidRequestException;
2528
import com.maxmind.geoip2.exception.OutOfQueriesException;
2629
import com.maxmind.geoip2.exception.PermissionRequiredException;
30+
import com.maxmind.geoip2.model.CityResponse;
31+
import com.maxmind.geoip2.model.CountryResponse;
2732
import com.maxmind.geoip2.model.InsightsResponse;
2833
import com.maxmind.geoip2.record.City;
2934
import com.maxmind.geoip2.record.Continent;
@@ -33,16 +38,20 @@
3338
import com.maxmind.geoip2.record.RepresentedCountry;
3439
import com.maxmind.geoip2.record.Subdivision;
3540
import com.maxmind.geoip2.record.Traits;
41+
import java.io.IOException;
3642
import java.io.UnsupportedEncodingException;
3743
import java.net.InetAddress;
3844
import java.net.InetSocketAddress;
3945
import java.net.ProxySelector;
4046
import java.net.http.HttpClient;
47+
import java.net.http.HttpTimeoutException;
4148
import java.nio.charset.StandardCharsets;
4249
import java.time.Duration;
4350
import java.util.List;
4451
import org.hamcrest.CoreMatchers;
4552
import org.junit.jupiter.api.Test;
53+
import org.junit.jupiter.api.condition.DisabledOnOs;
54+
import org.junit.jupiter.api.condition.OS;
4655
import org.junit.jupiter.api.extension.RegisterExtension;
4756
import org.junit.jupiter.params.ParameterizedTest;
4857
import org.junit.jupiter.params.provider.ValueSource;
@@ -460,4 +469,255 @@ public void testHttpClientWithDefaultSettingsDoesNotThrow() throws Exception {
460469
assertNotNull(client);
461470
}
462471

472+
@Test
473+
public void testRetriesOnConnectionReset_country() throws Exception {
474+
String url = "/geoip/v2.1/country/1.2.3.4";
475+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
476+
477+
wireMock.stubFor(get(urlEqualTo(url))
478+
.inScenario("retry-country")
479+
.whenScenarioStateIs(Scenario.STARTED)
480+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
481+
.willSetStateTo("succeeded"));
482+
483+
wireMock.stubFor(get(urlEqualTo(url))
484+
.inScenario("retry-country")
485+
.whenScenarioStateIs("succeeded")
486+
.willReturn(aResponse()
487+
.withStatus(200)
488+
.withHeader("Content-Type",
489+
"application/vnd.maxmind.com-country+json; charset=UTF-8; version=2.1")
490+
.withBody(body)));
491+
492+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
493+
.host("localhost")
494+
.port(wireMock.getPort())
495+
.disableHttps()
496+
.build();
497+
498+
CountryResponse response = client.country(InetAddress.getByName("1.2.3.4"));
499+
assertNotNull(response);
500+
501+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
502+
}
503+
504+
@Test
505+
public void testRetriesOnConnectionReset_city() throws Exception {
506+
String url = "/geoip/v2.1/city/1.2.3.4";
507+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
508+
509+
wireMock.stubFor(get(urlEqualTo(url))
510+
.inScenario("retry-city")
511+
.whenScenarioStateIs(Scenario.STARTED)
512+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
513+
.willSetStateTo("succeeded"));
514+
515+
wireMock.stubFor(get(urlEqualTo(url))
516+
.inScenario("retry-city")
517+
.whenScenarioStateIs("succeeded")
518+
.willReturn(aResponse()
519+
.withStatus(200)
520+
.withHeader("Content-Type",
521+
"application/vnd.maxmind.com-city+json; charset=UTF-8; version=2.1")
522+
.withBody(body)));
523+
524+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
525+
.host("localhost")
526+
.port(wireMock.getPort())
527+
.disableHttps()
528+
.build();
529+
530+
CityResponse response = client.city(InetAddress.getByName("1.2.3.4"));
531+
assertNotNull(response);
532+
533+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
534+
}
535+
536+
@Test
537+
public void testRetriesOnConnectionReset_insights() throws Exception {
538+
String url = "/geoip/v2.1/insights/1.2.3.4";
539+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
540+
541+
wireMock.stubFor(get(urlEqualTo(url))
542+
.inScenario("retry-insights")
543+
.whenScenarioStateIs(Scenario.STARTED)
544+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
545+
.willSetStateTo("succeeded"));
546+
547+
wireMock.stubFor(get(urlEqualTo(url))
548+
.inScenario("retry-insights")
549+
.whenScenarioStateIs("succeeded")
550+
.willReturn(aResponse()
551+
.withStatus(200)
552+
.withHeader("Content-Type",
553+
"application/vnd.maxmind.com-insights+json; charset=UTF-8; version=2.1")
554+
.withBody(body)));
555+
556+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
557+
.host("localhost")
558+
.port(wireMock.getPort())
559+
.disableHttps()
560+
.build();
561+
562+
InsightsResponse response = client.insights(InetAddress.getByName("1.2.3.4"));
563+
assertNotNull(response);
564+
565+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
566+
}
567+
568+
@Test
569+
public void testNoRetryOnHttpTimeoutException() {
570+
String url = "/geoip/v2.1/insights/1.2.3.4";
571+
wireMock.stubFor(get(urlEqualTo(url))
572+
.willReturn(aResponse()
573+
.withStatus(200)
574+
.withFixedDelay(2000)
575+
.withBody("{}")));
576+
577+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
578+
.host("localhost")
579+
.port(wireMock.getPort())
580+
.disableHttps()
581+
.requestTimeout(Duration.ofMillis(100))
582+
.build();
583+
584+
assertThrows(HttpTimeoutException.class,
585+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
586+
587+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
588+
}
589+
590+
@Test
591+
public void testNoRetryOn5xx() {
592+
String url = "/geoip/v2.1/insights/1.2.3.4";
593+
wireMock.stubFor(get(urlEqualTo(url))
594+
.willReturn(aResponse()
595+
.withStatus(500)
596+
.withHeader("Content-Type", "application/json")
597+
.withBody("")));
598+
599+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
600+
.host("localhost")
601+
.port(wireMock.getPort())
602+
.disableHttps()
603+
.build();
604+
605+
assertThrows(HttpException.class,
606+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
607+
608+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
609+
}
610+
611+
@Test
612+
public void testNoRetryOn4xx() {
613+
String url = "/geoip/v2.1/insights/1.2.3.4";
614+
wireMock.stubFor(get(urlEqualTo(url))
615+
.willReturn(aResponse()
616+
.withStatus(402)
617+
.withHeader("Content-Type", "application/json")
618+
.withBody("{\"code\":\"OUT_OF_QUERIES\",\"error\":\"out of credit\"}")));
619+
620+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
621+
.host("localhost")
622+
.port(wireMock.getPort())
623+
.disableHttps()
624+
.build();
625+
626+
assertThrows(OutOfQueriesException.class,
627+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
628+
629+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
630+
}
631+
632+
// Disabled on Windows: when WireMock immediately RSTs a fresh connection,
633+
// the Windows TCP stack can cause the JDK's h2c upgrade probe to fail
634+
// before negotiation completes, prompting the JDK to retry the request
635+
// as plain HTTP/1.1. The HTTP/1.1 path then triggers the JDK's own
636+
// idempotent-GET retry inside HttpClient.send(), producing two wire
637+
// requests where the test expects one. This is platform-specific JDK
638+
// behavior we cannot disable from application code; the equivalent
639+
// POST-based test in minfraud-api-java is unaffected because the JDK
640+
// does not internally retry non-idempotent requests.
641+
@Test
642+
@DisabledOnOs(OS.WINDOWS)
643+
public void testMaxRetriesZeroDisablesRetry() {
644+
String url = "/geoip/v2.1/insights/1.2.3.4";
645+
wireMock.stubFor(get(urlEqualTo(url))
646+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
647+
648+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
649+
.host("localhost")
650+
.port(wireMock.getPort())
651+
.disableHttps()
652+
.maxRetries(0)
653+
.build();
654+
655+
assertThrows(IOException.class,
656+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
657+
658+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
659+
}
660+
661+
// Disabled on Windows for the same reason as testMaxRetriesZeroDisablesRetry:
662+
// the JDK's internal idempotent-GET retry can stack on top of our retry
663+
// loop on the HTTP/1.1 fallback path, multiplying wire counts.
664+
@Test
665+
@DisabledOnOs(OS.WINDOWS)
666+
public void testRetriesExhausted() {
667+
String url = "/geoip/v2.1/insights/1.2.3.4";
668+
wireMock.stubFor(get(urlEqualTo(url))
669+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
670+
671+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
672+
.host("localhost")
673+
.port(wireMock.getPort())
674+
.disableHttps()
675+
.maxRetries(2)
676+
.build();
677+
678+
assertThrows(IOException.class,
679+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
680+
681+
// 1 initial attempt + 2 retries.
682+
wireMock.verify(3, getRequestedFor(urlEqualTo(url)));
683+
}
684+
685+
@Test
686+
public void testNegativeMaxRetriesThrows() {
687+
WebServiceClient.Builder builder = new WebServiceClient.Builder(6, "0123456789");
688+
assertThrows(IllegalArgumentException.class, () -> builder.maxRetries(-1));
689+
}
690+
691+
@Test
692+
public void testInterruptDuringRetry() {
693+
// Pre-interrupting the calling thread aborts the call before any wire
694+
// request is dispatched: HttpClient.send checks the interrupt status
695+
// and throws InterruptedException, which is caught and rewrapped as
696+
// GeoIp2Exception with the interrupt flag restored. The wire-count
697+
// assertion (zero) guards against a regression where pre-interrupt
698+
// would silently let the request proceed.
699+
String url = "/geoip/v2.1/insights/1.2.3.4";
700+
wireMock.stubFor(get(urlEqualTo(url))
701+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
702+
703+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
704+
.host("localhost")
705+
.port(wireMock.getPort())
706+
.disableHttps()
707+
.build();
708+
709+
Thread.currentThread().interrupt();
710+
try {
711+
assertThrows(GeoIp2Exception.class,
712+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
713+
assertTrue(Thread.currentThread().isInterrupted(),
714+
"interrupt flag should remain set after the call");
715+
} finally {
716+
// Clear the interrupt flag so it does not leak to other tests
717+
// (and so wireMock.verify below isn't affected by it).
718+
Thread.interrupted();
719+
}
720+
wireMock.verify(0, getRequestedFor(urlEqualTo(url)));
721+
}
722+
463723
}

0 commit comments

Comments
 (0)