Skip to content

Commit d1feb11

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 5bc3ea1 commit d1feb11

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

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

Lines changed: 244 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,11 +38,13 @@
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;
@@ -460,4 +467,241 @@ public void testHttpClientWithDefaultSettingsDoesNotThrow() throws Exception {
460467
assertNotNull(client);
461468
}
462469

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

0 commit comments

Comments
 (0)