3

I'm using annotation-style Resilience4j with my SpringBoot app called "demo". When calling an external backend via RestTemplate I want to use TimeLimiter and Retry to achieve the following:

  1. Limit the REST-call duration to 5 seconds --> if it takes longer, fail with TimeoutException
  2. Retry on TimeoutException --> do maximum 2 attempts

To see if the configuration of my resilience-setup works as designed I wrote an IntegrationTest. This test runs under profile "test" and is configured using "application-test.yml":

  1. Uses TestRestTemplate to send a call to my "SimpleRestEndpointController"
  2. The controller calls my business-service "CallExternalService" which has an annotated method "getPersonById" (Annotations: @TimeLimiter, @Retry)
  3. From this method a mocked RestTemplate is used to call the external-backend at "FANCY_URL"
  4. Using Mockito the RestTemplate call to the external-backend is slowed down (using Thread.sleep)
  5. I expect that the TimeLimiter cancels the call after 5 seconds, and the Retry ensures that the RestTemplate call is tried again (verify RestTemplate to have been called twice)

PROBLEM: TimeLimiter and Retry are registered, but do not do their job (TimeLimiter doesn't limit the call duration). Therefore RestTemplate is only called once, delivering the empty Person (see code for clarification). The linked example project can be cloned and showcases the problem when running the test.

Code of application-test.yml (also here: Link to application-test.yml):

resilience4j:
  timelimiter:
    configs:
      default:
        timeoutDuration: 5s
        cancelRunningFuture: true
    instances:
      MY_RESILIENCE_KEY:
        baseConfig: default
  retry:
    configs:
      default:
        maxRetryAttempts: 2
        waitDuration: 100ms
        retryExceptions:
          - java.util.concurrent.TimeoutException
    instances:
      MY_RESILIENCE_KEY:
        baseConfig: default

The code of this Test (also here: Link to IntegrationTest.java):

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {DemoApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@EnableAutoConfiguration
@ActiveProfiles("test")
public class IntegrationTest {
    private TestRestTemplate testRestTemplate;
    public final String FANCY_URL = "https://my-fancy-url-doesnt-matter.com/person";
    private String apiUrl;
    private HttpHeaders headers;
    
    @LocalServerPort
    private String localServerPort;
    
    @MockBean
    RestTemplate restTemplate;
    
    @Autowired
    CallExternalService callExternalService;
    
    @Autowired
    SimpleRestEndpointController simpleRestEndpointController;
    
    @Before
    public void setup() {
        this.headers = new HttpHeaders();
        this.testRestTemplate = new TestRestTemplate("username", "password");
        this.apiUrl = String.format("http://localhost:%s/person", localServerPort);
    }
    
    @Test
    public void testShouldRetryOnceWhenTimelimitIsReached() {
        // Arrange
        Person mockPerson = new Person();
        mockPerson.setId(1);
        mockPerson.setFirstName("First");
        mockPerson.setLastName("Last");
        ResponseEntity<Person> mockResponse = new ResponseEntity<>(mockPerson, HttpStatus.OK);
            
        
        Answer customAnswer = new Answer() {
            private int invocationCount = 0;
            @Override
            public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
                invocationCount++;
                if (invocationCount == 1) {
                    Thread.sleep(6000);
                    return new ResponseEntity<>(new Person(), HttpStatus.OK);
                } else {
                    return mockResponse;
                }
            }
        };
        
        doAnswer(customAnswer)
        .when(restTemplate).exchange(
                FANCY_URL,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                new ParameterizedTypeReference<Person>() {});
        
        
        // Act
        ResponseEntity<Person> result = null;
        try {
            result = this.testRestTemplate.exchange(
                    apiUrl,
                    HttpMethod.GET,
                    new HttpEntity<>(headers),
                    new ParameterizedTypeReference<Person>() {
                    });
        } catch(Exception ex) {
            System.out.println(ex);         
        }
        
        
        // Assert
        verify(restTemplate, times(2)).exchange(
                FANCY_URL,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                new ParameterizedTypeReference<Person>() {});
        Assert.assertNotNull(result);
        Assert.assertEquals(mockPerson, result.getBody());      
        
    }
}

The code of my app showcasing the problem: https://github.com/SidekickJohn/demo

I created a swimlane diagram of the "logic" as part of the README.md: https://github.com/SidekickJohn/demo/blob/main/README.md

Sidekick.John
  • 183
  • 3
  • 14

1 Answers1

0

If you want to mock a real RestTemplate bean which is used by your CallExternalService , you have to use a Mockito Spy -> https://www.baeldung.com/mockito-spy

But I usually prefer and would recommend to use WireMock instead of Mockito to mock HTTP endpoints.

Robert Winkler
  • 1,734
  • 9
  • 8
  • Hmm. I do get the difference between mock and spy. And actually a mock is what I prefer here. I don't want the RestTemplate to execute the actual call. As listed above I want to "intercept" the exchange-Method (using "doAnswer(...)when(restTemplate).exchange). – Sidekick.John Dec 02 '20 at 12:03
  • Additionally: When I use the @MockBean annotation for the RestTemplate, the `CallExternalService` which is @Autowired should get this Mock injected. This seems to work. The failure-message after the test execution states that there has indeed been an interaction with this mock but it was just 1 interaction instead of 2. – Sidekick.John Dec 02 '20 at 12:07
  • Ok, I just saw that your CallExternalService isn't implementing an Interface. Spring AOP uses either JDK dynamic proxies or CGLIB to create the proxy for a given target object. (JDK dynamic proxies are preferred whenever you have a choice). If the target object to be proxied implements at least one interface then a JDK dynamic proxy will be used. If the target object does not implement any interfaces then a CGLIB proxy will be created. Our Aspects haven't been tested with CGLIB yet. We suggest to implement Interfaces. Could you please test it and check if RetryAspect.proceed is invoked? – Robert Winkler Dec 03 '20 at 07:51
  • I will try and check it out as soon as possible. Will provide you with feedback here.Thank you for the hint! – Sidekick.John Dec 04 '20 at 08:04
  • @RobertWinkler I deal with the same problem when trying to test the TimeLimiter functionality. Unfortunately introducing interface didn't help, the TimeLimiterAspect.proceed was not entertained. – miro Jan 21 '22 at 19:12
  • @Sidekick.John did you fix the problem? – Yogen Rai Feb 04 '22 at 23:38
  • @YogenRai sadly we didn't have enough time to dig into this. We therefore switched back to the functional approach of using resilience4j. More on this approach can be found on the resilience4j website/documentation. – Sidekick.John Feb 18 '22 at 06:04