Skip to content

Commit 08fef7d

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 af116dd commit 08fef7d

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

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

Lines changed: 189 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;
@@ -43,6 +45,7 @@
4345
import com.maxmind.minfraud.response.ScoreResponse;
4446
import java.net.InetAddress;
4547
import java.net.ProxySelector;
48+
import java.net.ServerSocket;
4649
import java.net.http.HttpClient;
4750
import java.time.Duration;
4851
import java.util.List;
@@ -423,4 +426,190 @@ public void testHttpClientWithProxyThrowsException() {
423426
assertEquals("Cannot set both httpClient and proxy. " +
424427
"Configure proxy on the provided HttpClient instead.", ex.getMessage());
425428
}
429+
430+
@Test
431+
public void testRetriesOnConnectionReset_score() throws Exception {
432+
var responseContent = readJsonFile("score-response");
433+
var url = "/minfraud/v2.0/score";
434+
435+
wireMock.stubFor(post(urlEqualTo(url))
436+
.inScenario("retry-score")
437+
.whenScenarioStateIs(Scenario.STARTED)
438+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
439+
.willSetStateTo("succeeded"));
440+
441+
wireMock.stubFor(post(urlEqualTo(url))
442+
.inScenario("retry-score")
443+
.whenScenarioStateIs("succeeded")
444+
.willReturn(aResponse()
445+
.withStatus(200)
446+
.withHeader("Content-Type",
447+
"application/vnd.maxmind.com-minfraud-score+json; charset=UTF-8; version=2.0\n")
448+
.withBody(responseContent)));
449+
450+
var client = new WebServiceClient.Builder(6, "0123456789")
451+
.host("localhost")
452+
.port(wireMock.getPort())
453+
.disableHttps()
454+
.build();
455+
456+
var response = client.score(fullTransaction());
457+
JSONAssert.assertEquals(responseContent, response.toJson(), true);
458+
459+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
460+
}
461+
462+
@Test
463+
public void testRetriesOnConnectionReset_reportTransaction() throws Exception {
464+
var url = "/minfraud/v2.0/transactions/report";
465+
466+
wireMock.stubFor(post(urlEqualTo(url))
467+
.inScenario("retry-report")
468+
.whenScenarioStateIs(Scenario.STARTED)
469+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))
470+
.willSetStateTo("succeeded"));
471+
472+
wireMock.stubFor(post(urlEqualTo(url))
473+
.inScenario("retry-report")
474+
.whenScenarioStateIs("succeeded")
475+
.willReturn(aResponse().withStatus(204)));
476+
477+
var client = new WebServiceClient.Builder(6, "0123456789")
478+
.host("localhost")
479+
.port(wireMock.getPort())
480+
.disableHttps()
481+
.build();
482+
483+
client.reportTransaction(fullTransactionReport());
484+
485+
wireMock.verify(2, postRequestedFor(urlEqualTo(url)));
486+
}
487+
488+
@Test
489+
public void testNoRetryOnHttpTimeoutException() {
490+
var url = "/minfraud/v2.0/insights";
491+
wireMock.stubFor(post(urlEqualTo(url))
492+
.willReturn(aResponse()
493+
.withStatus(200)
494+
.withFixedDelay(2000)
495+
.withBody("{}")));
496+
497+
var client = new WebServiceClient.Builder(6, "0123456789")
498+
.host("localhost")
499+
.port(wireMock.getPort())
500+
.disableHttps()
501+
.requestTimeout(Duration.ofMillis(100))
502+
.build();
503+
504+
// The request-phase timeout surfaces as a checked exception; we just
505+
// need to confirm it propagates (any throwable is acceptable here).
506+
assertThrows(Exception.class, () -> client.insights(fullTransaction()));
507+
508+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
509+
}
510+
511+
@Test
512+
public void testRetriesOnConnectTimeout() throws Exception {
513+
// Deterministic alternative to an unroutable address: bind to a free
514+
// local port, then immediately close it. Connection attempts to the
515+
// closed port fail fast with ConnectException, which exercises the
516+
// same retry branch as a connect timeout (both are predicate hits).
517+
int port;
518+
try (ServerSocket socket = new ServerSocket(0)) {
519+
port = socket.getLocalPort();
520+
}
521+
522+
var client = new WebServiceClient.Builder(6, "0123456789")
523+
.host("127.0.0.1")
524+
.port(port)
525+
.disableHttps()
526+
.build();
527+
528+
assertThrows(Exception.class, () -> client.insights(fullTransaction()));
529+
}
530+
531+
@Test
532+
public void testNoRetryOn5xx() {
533+
var url = "/minfraud/v2.0/insights";
534+
wireMock.stubFor(post(urlEqualTo(url))
535+
.willReturn(aResponse()
536+
.withStatus(500)
537+
.withHeader("Content-Type", "application/json")
538+
.withBody("")));
539+
540+
var client = new WebServiceClient.Builder(6, "0123456789")
541+
.host("localhost")
542+
.port(wireMock.getPort())
543+
.disableHttps()
544+
.build();
545+
546+
assertThrows(HttpException.class, () -> client.insights(fullTransaction()));
547+
548+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
549+
}
550+
551+
@Test
552+
public void testNoRetryOn4xx() {
553+
var url = "/minfraud/v2.0/insights";
554+
wireMock.stubFor(post(urlEqualTo(url))
555+
.willReturn(aResponse()
556+
.withStatus(402)
557+
.withHeader("Content-Type", "application/json")
558+
.withBody("{\"code\":\"INSUFFICIENT_FUNDS\",\"error\":\"out of credit\"}")));
559+
560+
var client = new WebServiceClient.Builder(6, "0123456789")
561+
.host("localhost")
562+
.port(wireMock.getPort())
563+
.disableHttps()
564+
.build();
565+
566+
assertThrows(InsufficientFundsException.class,
567+
() -> client.insights(fullTransaction()));
568+
569+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
570+
}
571+
572+
@Test
573+
public void testMaxRetriesZeroDisablesRetry() {
574+
var url = "/minfraud/v2.0/insights";
575+
wireMock.stubFor(post(urlEqualTo(url))
576+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
577+
578+
var client = new WebServiceClient.Builder(6, "0123456789")
579+
.host("localhost")
580+
.port(wireMock.getPort())
581+
.disableHttps()
582+
.maxRetries(0)
583+
.build();
584+
585+
assertThrows(Exception.class, () -> client.insights(fullTransaction()));
586+
587+
wireMock.verify(1, postRequestedFor(urlEqualTo(url)));
588+
}
589+
590+
@Test
591+
public void testInterruptDuringRetry() {
592+
// Pre-interrupt the calling thread; the predicate short-circuits when
593+
// the thread is interrupted, so no retry should occur and the
594+
// InterruptedException path in the client should restore the flag.
595+
var url = "/minfraud/v2.0/insights";
596+
wireMock.stubFor(post(urlEqualTo(url))
597+
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
598+
599+
var client = new WebServiceClient.Builder(6, "0123456789")
600+
.host("localhost")
601+
.port(wireMock.getPort())
602+
.disableHttps()
603+
.build();
604+
605+
Thread.currentThread().interrupt();
606+
try {
607+
assertThrows(Exception.class, () -> client.insights(fullTransaction()));
608+
assertTrue(Thread.currentThread().isInterrupted(),
609+
"interrupt flag should remain set after the call");
610+
} finally {
611+
// Clear the interrupt flag so it does not leak to other tests.
612+
Thread.interrupted();
613+
}
614+
}
426615
}

0 commit comments

Comments
 (0)