Skip to content

Commit 23248bb

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 e0444be commit 23248bb

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

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

Lines changed: 235 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;
@@ -37,6 +42,7 @@
3742
import java.net.InetAddress;
3843
import java.net.InetSocketAddress;
3944
import java.net.ProxySelector;
45+
import java.net.ServerSocket;
4046
import java.net.http.HttpClient;
4147
import java.nio.charset.StandardCharsets;
4248
import java.time.Duration;
@@ -460,4 +466,233 @@ public void testHttpClientWithDefaultSettingsDoesNotThrow() throws Exception {
460466
assertNotNull(client);
461467
}
462468

469+
@Test
470+
public void testRetriesOnConnectionReset_country() throws Exception {
471+
String url = "/geoip/v2.1/country/1.2.3.4";
472+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
473+
474+
wireMock.stubFor(get(urlEqualTo(url))
475+
.inScenario("retry-country")
476+
.whenScenarioStateIs(Scenario.STARTED)
477+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
478+
.willSetStateTo("succeeded"));
479+
480+
wireMock.stubFor(get(urlEqualTo(url))
481+
.inScenario("retry-country")
482+
.whenScenarioStateIs("succeeded")
483+
.willReturn(aResponse()
484+
.withStatus(200)
485+
.withHeader("Content-Type",
486+
"application/vnd.maxmind.com-country+json; charset=UTF-8; version=2.1")
487+
.withBody(body)));
488+
489+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
490+
.host("localhost")
491+
.port(wireMock.getPort())
492+
.disableHttps()
493+
.build();
494+
495+
CountryResponse response = client.country(InetAddress.getByName("1.2.3.4"));
496+
assertNotNull(response);
497+
498+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
499+
}
500+
501+
@Test
502+
public void testRetriesOnConnectionReset_city() throws Exception {
503+
String url = "/geoip/v2.1/city/1.2.3.4";
504+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
505+
506+
wireMock.stubFor(get(urlEqualTo(url))
507+
.inScenario("retry-city")
508+
.whenScenarioStateIs(Scenario.STARTED)
509+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
510+
.willSetStateTo("succeeded"));
511+
512+
wireMock.stubFor(get(urlEqualTo(url))
513+
.inScenario("retry-city")
514+
.whenScenarioStateIs("succeeded")
515+
.willReturn(aResponse()
516+
.withStatus(200)
517+
.withHeader("Content-Type",
518+
"application/vnd.maxmind.com-city+json; charset=UTF-8; version=2.1")
519+
.withBody(body)));
520+
521+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
522+
.host("localhost")
523+
.port(wireMock.getPort())
524+
.disableHttps()
525+
.build();
526+
527+
CityResponse response = client.city(InetAddress.getByName("1.2.3.4"));
528+
assertNotNull(response);
529+
530+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
531+
}
532+
533+
@Test
534+
public void testRetriesOnConnectionReset_insights() throws Exception {
535+
String url = "/geoip/v2.1/insights/1.2.3.4";
536+
String body = "{\"traits\":{\"ip_address\":\"1.2.3.4\"}}";
537+
538+
wireMock.stubFor(get(urlEqualTo(url))
539+
.inScenario("retry-insights")
540+
.whenScenarioStateIs(Scenario.STARTED)
541+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
542+
.willSetStateTo("succeeded"));
543+
544+
wireMock.stubFor(get(urlEqualTo(url))
545+
.inScenario("retry-insights")
546+
.whenScenarioStateIs("succeeded")
547+
.willReturn(aResponse()
548+
.withStatus(200)
549+
.withHeader("Content-Type",
550+
"application/vnd.maxmind.com-insights+json; charset=UTF-8; version=2.1")
551+
.withBody(body)));
552+
553+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
554+
.host("localhost")
555+
.port(wireMock.getPort())
556+
.disableHttps()
557+
.build();
558+
559+
InsightsResponse response = client.insights(InetAddress.getByName("1.2.3.4"));
560+
assertNotNull(response);
561+
562+
wireMock.verify(2, getRequestedFor(urlEqualTo(url)));
563+
}
564+
565+
@Test
566+
public void testNoRetryOnHttpTimeoutException() {
567+
String url = "/geoip/v2.1/insights/1.2.3.4";
568+
wireMock.stubFor(get(urlEqualTo(url))
569+
.willReturn(aResponse()
570+
.withStatus(200)
571+
.withFixedDelay(2000)
572+
.withBody("{}")));
573+
574+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
575+
.host("localhost")
576+
.port(wireMock.getPort())
577+
.disableHttps()
578+
.requestTimeout(Duration.ofMillis(100))
579+
.build();
580+
581+
// The request-phase timeout surfaces as a checked exception; we just
582+
// need to confirm it propagates (any throwable is acceptable here).
583+
assertThrows(Exception.class,
584+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
585+
586+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
587+
}
588+
589+
@Test
590+
public void testRetriesOnConnectTimeout() throws Exception {
591+
// Deterministic alternative to an unroutable address: bind to a free
592+
// local port, then immediately close it. Connection attempts to the
593+
// closed port fail fast with ConnectException, which exercises the
594+
// same retry branch as a connect timeout (both are predicate hits).
595+
int port;
596+
try (ServerSocket socket = new ServerSocket(0)) {
597+
port = socket.getLocalPort();
598+
}
599+
600+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
601+
.host("127.0.0.1")
602+
.port(port)
603+
.disableHttps()
604+
.build();
605+
606+
assertThrows(Exception.class,
607+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
608+
}
609+
610+
@Test
611+
public void testNoRetryOn5xx() {
612+
String url = "/geoip/v2.1/insights/1.2.3.4";
613+
wireMock.stubFor(get(urlEqualTo(url))
614+
.willReturn(aResponse()
615+
.withStatus(500)
616+
.withHeader("Content-Type", "application/json")
617+
.withBody("")));
618+
619+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
620+
.host("localhost")
621+
.port(wireMock.getPort())
622+
.disableHttps()
623+
.build();
624+
625+
assertThrows(HttpException.class,
626+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
627+
628+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
629+
}
630+
631+
@Test
632+
public void testNoRetryOn4xx() {
633+
String url = "/geoip/v2.1/insights/1.2.3.4";
634+
wireMock.stubFor(get(urlEqualTo(url))
635+
.willReturn(aResponse()
636+
.withStatus(402)
637+
.withHeader("Content-Type", "application/json")
638+
.withBody("{\"code\":\"OUT_OF_QUERIES\",\"error\":\"out of credit\"}")));
639+
640+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
641+
.host("localhost")
642+
.port(wireMock.getPort())
643+
.disableHttps()
644+
.build();
645+
646+
assertThrows(OutOfQueriesException.class,
647+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
648+
649+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
650+
}
651+
652+
@Test
653+
public void testMaxRetriesZeroDisablesRetry() {
654+
String url = "/geoip/v2.1/insights/1.2.3.4";
655+
wireMock.stubFor(get(urlEqualTo(url))
656+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
657+
658+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
659+
.host("localhost")
660+
.port(wireMock.getPort())
661+
.disableHttps()
662+
.maxRetries(0)
663+
.build();
664+
665+
assertThrows(Exception.class,
666+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
667+
668+
wireMock.verify(1, getRequestedFor(urlEqualTo(url)));
669+
}
670+
671+
@Test
672+
public void testInterruptDuringRetry() {
673+
// Pre-interrupt the calling thread; the predicate short-circuits when
674+
// the thread is interrupted, so no retry should occur and the
675+
// InterruptedException path in the client should restore the flag.
676+
String url = "/geoip/v2.1/insights/1.2.3.4";
677+
wireMock.stubFor(get(urlEqualTo(url))
678+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
679+
680+
WebServiceClient client = new WebServiceClient.Builder(6, "0123456789")
681+
.host("localhost")
682+
.port(wireMock.getPort())
683+
.disableHttps()
684+
.build();
685+
686+
Thread.currentThread().interrupt();
687+
try {
688+
assertThrows(Exception.class,
689+
() -> client.insights(InetAddress.getByName("1.2.3.4")));
690+
assertTrue(Thread.currentThread().isInterrupted(),
691+
"interrupt flag should remain set after the call");
692+
} finally {
693+
// Clear the interrupt flag so it does not leak to other tests.
694+
Thread.interrupted();
695+
}
696+
}
697+
463698
}

0 commit comments

Comments
 (0)