Skip to content

Commit 40b81b0

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 9627185 commit 40b81b0

1 file changed

Lines changed: 249 additions & 0 deletions

File tree

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

Lines changed: 249 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,249 @@ 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 full retry history is reachable via the suppressed chain: each
587+
// exception carries its immediate predecessor as a suppressed
588+
// exception. Walk the chain and confirm we have 2 priors.
589+
int priorCount = 0;
590+
Throwable cur = ex;
591+
while (cur.getSuppressed().length > 0) {
592+
cur = cur.getSuppressed()[0];
593+
priorCount++;
594+
}
595+
assertEquals(2, priorCount,
596+
"expected the 2 prior IOExceptions in the suppressed chain");
597+
}
598+
599+
@Test
600+
public void testCustomHttpClientStillRetries() throws Exception {
601+
// The Javadoc on Builder.httpClient(HttpClient) promises that the SDK's
602+
// transport-failure retry wraps any supplied client. Verify it.
603+
var responseContent = readJsonFile("score-response");
604+
var url = "/minfraud/v2.0/score";
605+
606+
wireMock.stubFor(post(urlEqualTo(url))
607+
.inScenario("retry-custom-client")
608+
.whenScenarioStateIs(Scenario.STARTED)
609+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
610+
.willSetStateTo("succeeded"));
611+
612+
wireMock.stubFor(post(urlEqualTo(url))
613+
.inScenario("retry-custom-client")
614+
.whenScenarioStateIs("succeeded")
615+
.willReturn(aResponse()
616+
.withStatus(200)
617+
.withHeader("Content-Type",
618+
"application/vnd.maxmind.com-minfraud-score+json; charset=UTF-8; version=2.0\n")
619+
.withBody(responseContent)));
620+
621+
var customClient = HttpClient.newBuilder().build();
622+
var client = new WebServiceClient.Builder(6, "0123456789")
623+
.host("localhost")
624+
.port(wireMock.getPort())
625+
.disableHttps()
626+
.httpClient(customClient)
627+
.build();
628+
629+
var response = client.score(fullTransaction());
630+
assertNotNull(response);
631+
632+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
633+
}
634+
635+
@Test
636+
public void testNegativeMaxRetriesThrows() {
637+
var builder = new WebServiceClient.Builder(6, "0123456789");
638+
assertThrows(IllegalArgumentException.class, () -> builder.maxRetries(-1));
639+
}
640+
641+
@Test
642+
public void testInterruptedThreadAbortsBeforeSend() {
643+
// When the calling thread is already interrupted, HttpClient.send
644+
// checks the interrupt status and throws InterruptedException before
645+
// dispatching any wire request. The exception is caught and rewrapped
646+
// as MinFraudException, with the interrupt flag restored on the
647+
// calling thread. The wire-count assertion (zero) guards against a
648+
// regression where a pre-interrupt would silently let the request
649+
// proceed. NOTE: this test does not exercise the predicate's own
650+
// Thread.currentThread().isInterrupted() short-circuit, since the JDK
651+
// aborts before that branch can be reached; a true mid-flight
652+
// interrupt is hard to test deterministically.
653+
var url = "/minfraud/v2.0/insights";
654+
wireMock.stubFor(post(urlEqualTo(url))
655+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
656+
657+
var client = new WebServiceClient.Builder(6, "0123456789")
658+
.host("localhost")
659+
.port(wireMock.getPort())
660+
.disableHttps()
661+
.build();
662+
663+
Thread.currentThread().interrupt();
664+
try {
665+
assertThrows(MinFraudException.class, () -> client.insights(fullTransaction()));
666+
assertTrue(Thread.currentThread().isInterrupted(),
667+
"interrupt flag should remain set after the call");
668+
} finally {
669+
// Clear the interrupt flag so it does not leak to other tests
670+
// (and so wireMock.verify below isn't affected by it).
671+
Thread.interrupted();
672+
}
673+
wireMock.verify(0, postRequestedFor(urlEqualTo(url)));
674+
}
426675
}

0 commit comments

Comments
 (0)