Skip to content

Commit 22249cc

Browse files
oschwaldclaude
andcommitted
STF-322: Add tests for transport-failure retry
Cover all 8 scenarios: connection-reset retry on score and reportTransaction 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 f51ea96 commit 22249cc

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

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

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import static org.junit.jupiter.api.Assertions.assertThrows;
2323
import static org.junit.jupiter.api.Assertions.assertTrue;
2424

25+
import com.github.tomakehurst.wiremock.http.Fault;
2526
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
2627
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
28+
import com.github.tomakehurst.wiremock.stubbing.Scenario;
2729
import com.maxmind.minfraud.exception.AuthenticationException;
2830
import com.maxmind.minfraud.exception.HttpException;
2931
import com.maxmind.minfraud.exception.InsufficientFundsException;
@@ -41,9 +43,11 @@
4143
import com.maxmind.minfraud.response.InsightsResponse;
4244
import com.maxmind.minfraud.response.IpRiskReason;
4345
import com.maxmind.minfraud.response.ScoreResponse;
46+
import java.io.IOException;
4447
import java.net.InetAddress;
4548
import java.net.ProxySelector;
4649
import java.net.http.HttpClient;
50+
import java.net.http.HttpTimeoutException;
4751
import java.time.Duration;
4852
import java.util.List;
4953
import org.junit.jupiter.api.Test;
@@ -423,4 +427,242 @@ public void testHttpClientWithProxyThrowsException() {
423427
assertEquals("Cannot set both httpClient and proxy. " +
424428
"Configure proxy on the provided HttpClient instead.", ex.getMessage());
425429
}
430+
431+
@Test
432+
public void testRetriesOnConnectionReset_score() throws Exception {
433+
var responseContent = readJsonFile("score-response");
434+
var url = "/minfraud/v2.0/score";
435+
436+
wireMock.stubFor(post(urlEqualTo(url))
437+
.inScenario("retry-score")
438+
.whenScenarioStateIs(Scenario.STARTED)
439+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
440+
.willSetStateTo("succeeded"));
441+
442+
wireMock.stubFor(post(urlEqualTo(url))
443+
.inScenario("retry-score")
444+
.whenScenarioStateIs("succeeded")
445+
.willReturn(aResponse()
446+
.withStatus(200)
447+
.withHeader("Content-Type",
448+
"application/vnd.maxmind.com-minfraud-score+json; charset=UTF-8; version=2.0\n")
449+
.withBody(responseContent)));
450+
451+
var client = new WebServiceClient.Builder(6, "0123456789")
452+
.host("localhost")
453+
.port(wireMock.getPort())
454+
.disableHttps()
455+
.build();
456+
457+
var response = client.score(fullTransaction());
458+
JSONAssert.assertEquals(responseContent, response.toJson(), true);
459+
460+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
461+
}
462+
463+
@Test
464+
public void testRetriesOnConnectionReset_reportTransaction() throws Exception {
465+
var url = "/minfraud/v2.0/transactions/report";
466+
467+
wireMock.stubFor(post(urlEqualTo(url))
468+
.inScenario("retry-report")
469+
.whenScenarioStateIs(Scenario.STARTED)
470+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
471+
.willSetStateTo("succeeded"));
472+
473+
wireMock.stubFor(post(urlEqualTo(url))
474+
.inScenario("retry-report")
475+
.whenScenarioStateIs("succeeded")
476+
.willReturn(aResponse().withStatus(204)));
477+
478+
var client = new WebServiceClient.Builder(6, "0123456789")
479+
.host("localhost")
480+
.port(wireMock.getPort())
481+
.disableHttps()
482+
.build();
483+
484+
client.reportTransaction(fullTransactionReport());
485+
486+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
487+
}
488+
489+
@Test
490+
public void testNoRetryOnHttpTimeoutException() {
491+
var url = "/minfraud/v2.0/insights";
492+
wireMock.stubFor(post(urlEqualTo(url))
493+
.willReturn(aResponse()
494+
.withStatus(200)
495+
.withFixedDelay(2000)
496+
.withBody("{}")));
497+
498+
var client = new WebServiceClient.Builder(6, "0123456789")
499+
.host("localhost")
500+
.port(wireMock.getPort())
501+
.disableHttps()
502+
.requestTimeout(Duration.ofMillis(100))
503+
.build();
504+
505+
assertThrows(HttpTimeoutException.class, () -> client.insights(fullTransaction()));
506+
507+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
508+
}
509+
510+
@Test
511+
public void testNoRetryOn5xx() {
512+
var url = "/minfraud/v2.0/insights";
513+
wireMock.stubFor(post(urlEqualTo(url))
514+
.willReturn(aResponse()
515+
.withStatus(500)
516+
.withHeader("Content-Type", "application/json")
517+
.withBody("")));
518+
519+
var client = new WebServiceClient.Builder(6, "0123456789")
520+
.host("localhost")
521+
.port(wireMock.getPort())
522+
.disableHttps()
523+
.build();
524+
525+
assertThrows(HttpException.class, () -> client.insights(fullTransaction()));
526+
527+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
528+
}
529+
530+
@Test
531+
public void testNoRetryOn4xx() {
532+
var url = "/minfraud/v2.0/insights";
533+
wireMock.stubFor(post(urlEqualTo(url))
534+
.willReturn(aResponse()
535+
.withStatus(402)
536+
.withHeader("Content-Type", "application/json")
537+
.withBody("{\"code\":\"INSUFFICIENT_FUNDS\",\"error\":\"out of credit\"}")));
538+
539+
var client = new WebServiceClient.Builder(6, "0123456789")
540+
.host("localhost")
541+
.port(wireMock.getPort())
542+
.disableHttps()
543+
.build();
544+
545+
assertThrows(InsufficientFundsException.class,
546+
() -> client.insights(fullTransaction()));
547+
548+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
549+
}
550+
551+
@Test
552+
public void testMaxRetriesZeroDisablesRetry() {
553+
var url = "/minfraud/v2.0/insights";
554+
wireMock.stubFor(post(urlEqualTo(url))
555+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
556+
557+
var client = new WebServiceClient.Builder(6, "0123456789")
558+
.host("localhost")
559+
.port(wireMock.getPort())
560+
.disableHttps()
561+
.maxRetries(0)
562+
.build();
563+
564+
assertThrows(IOException.class, () -> client.insights(fullTransaction()));
565+
566+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
567+
}
568+
569+
@Test
570+
public void testRetriesExhausted() {
571+
var url = "/minfraud/v2.0/insights";
572+
wireMock.stubFor(post(urlEqualTo(url))
573+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
574+
575+
var client = new WebServiceClient.Builder(6, "0123456789")
576+
.host("localhost")
577+
.port(wireMock.getPort())
578+
.disableHttps()
579+
.maxRetries(2)
580+
.build();
581+
582+
var ex = assertThrows(IOException.class, () -> client.insights(fullTransaction()));
583+
584+
// 1 initial attempt + 2 retries.
585+
wireMock.verify(3, postRequestedFor(urlEqualTo(url)));
586+
// The final exception should carry the prior failures as suppressed
587+
// exceptions so the full retry history is visible in stack traces.
588+
assertEquals(2, ex.getSuppressed().length,
589+
"expected the 2 prior IOExceptions to be attached as suppressed");
590+
}
591+
592+
@Test
593+
public void testCustomHttpClientStillRetries() throws Exception {
594+
// The Javadoc on Builder.httpClient(HttpClient) promises that the SDK's
595+
// transport-failure retry wraps any supplied client. Verify it.
596+
var responseContent = readJsonFile("score-response");
597+
var url = "/minfraud/v2.0/score";
598+
599+
wireMock.stubFor(post(urlEqualTo(url))
600+
.inScenario("retry-custom-client")
601+
.whenScenarioStateIs(Scenario.STARTED)
602+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
603+
.willSetStateTo("succeeded"));
604+
605+
wireMock.stubFor(post(urlEqualTo(url))
606+
.inScenario("retry-custom-client")
607+
.whenScenarioStateIs("succeeded")
608+
.willReturn(aResponse()
609+
.withStatus(200)
610+
.withHeader("Content-Type",
611+
"application/vnd.maxmind.com-minfraud-score+json; charset=UTF-8; version=2.0\n")
612+
.withBody(responseContent)));
613+
614+
var customClient = HttpClient.newBuilder().build();
615+
var client = new WebServiceClient.Builder(6, "0123456789")
616+
.host("localhost")
617+
.port(wireMock.getPort())
618+
.disableHttps()
619+
.httpClient(customClient)
620+
.build();
621+
622+
var response = client.score(fullTransaction());
623+
assertNotNull(response);
624+
625+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
626+
}
627+
628+
@Test
629+
public void testNegativeMaxRetriesThrows() {
630+
var builder = new WebServiceClient.Builder(6, "0123456789");
631+
assertThrows(IllegalArgumentException.class, () -> builder.maxRetries(-1));
632+
}
633+
634+
@Test
635+
public void testInterruptedThreadAbortsBeforeSend() {
636+
// When the calling thread is already interrupted, HttpClient.send
637+
// checks the interrupt status and throws InterruptedException before
638+
// dispatching any wire request. The exception is caught and rewrapped
639+
// as MinFraudException, with the interrupt flag restored on the
640+
// calling thread. The wire-count assertion (zero) guards against a
641+
// regression where a pre-interrupt would silently let the request
642+
// proceed. NOTE: this test does not exercise the predicate's own
643+
// Thread.currentThread().isInterrupted() short-circuit, since the JDK
644+
// aborts before that branch can be reached; a true mid-flight
645+
// interrupt is hard to test deterministically.
646+
var url = "/minfraud/v2.0/insights";
647+
wireMock.stubFor(post(urlEqualTo(url))
648+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
649+
650+
var client = new WebServiceClient.Builder(6, "0123456789")
651+
.host("localhost")
652+
.port(wireMock.getPort())
653+
.disableHttps()
654+
.build();
655+
656+
Thread.currentThread().interrupt();
657+
try {
658+
assertThrows(MinFraudException.class, () -> client.insights(fullTransaction()));
659+
assertTrue(Thread.currentThread().isInterrupted(),
660+
"interrupt flag should remain set after the call");
661+
} finally {
662+
// Clear the interrupt flag so it does not leak to other tests
663+
// (and so wireMock.verify below isn't affected by it).
664+
Thread.interrupted();
665+
}
666+
wireMock.verify(0, postRequestedFor(urlEqualTo(url)));
667+
}
426668
}

0 commit comments

Comments
 (0)