From ee5f5b2015ed408aafa043e62312b81f3a0e4c02 Mon Sep 17 00:00:00 2001 From: Adrian Bob Date: Tue, 4 Apr 2023 16:49:54 +0300 Subject: [PATCH] BAEL-6071: Resilience4j events endpoints (#13697) * Add project for Resilience4j Events Endpoints article * Update README * Add spring-boot-resilience4j module * Renamed test class to meet constraints * Update package structure * Added formatting * Replaces .parallel() with Executor * Updated concurrent calls for bulkhead test --- spring-boot-modules/pom.xml | 1 + .../spring-boot-resilience4j/README.md | 3 + .../spring-boot-resilience4j/pom.xml | 53 +++ .../eventendpoints/ApiExceptionHandler.java | 29 ++ .../eventendpoints/ExternalAPICaller.java | 28 ++ .../ExternalApiCallerConfig.java | 14 + .../eventendpoints/ResilientApp.java | 12 + .../ResilientAppController.java | 58 ++++ .../src/main/resources/application.yml | 60 ++++ ...ResilientAppControllerIntegrationTest.java | 318 ++++++++++++++++++ .../eventendpoints/model/BulkheadEvent.java | 50 +++ .../eventendpoints/model/BulkheadEvents.java | 16 + .../model/CircuitBreakerEvent.java | 81 +++++ .../model/CircuitBreakerEvents.java | 16 + .../model/RateLimiterEvent.java | 50 +++ .../model/RateLimiterEvents.java | 16 + .../eventendpoints/model/RetryEvent.java | 70 ++++ .../eventendpoints/model/RetryEvents.java | 16 + .../model/TimeLimiterEvent.java | 50 +++ .../model/TimeLimiterEvents.java | 16 + 20 files changed, 957 insertions(+) create mode 100644 spring-boot-modules/spring-boot-resilience4j/README.md create mode 100644 spring-boot-modules/spring-boot-resilience4j/pom.xml create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ApiExceptionHandler.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalAPICaller.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalApiCallerConfig.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientApp.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientAppController.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/main/resources/application.yml create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/ResilientAppControllerIntegrationTest.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvent.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvents.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvent.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvents.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvent.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvents.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvent.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvents.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvent.java create mode 100644 spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvents.java diff --git a/spring-boot-modules/pom.xml b/spring-boot-modules/pom.xml index 6d542f40dd..83b935f845 100644 --- a/spring-boot-modules/pom.xml +++ b/spring-boot-modules/pom.xml @@ -93,6 +93,7 @@ spring-boot-3-native spring-boot-3-observation spring-boot-3-test-pitfalls + spring-boot-resilience4j diff --git a/spring-boot-modules/spring-boot-resilience4j/README.md b/spring-boot-modules/spring-boot-resilience4j/README.md new file mode 100644 index 0000000000..e6c73674ce --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/README.md @@ -0,0 +1,3 @@ +### Relevant Articles: + +- [Resilience4j Events Endpoints](https://www.baeldung.com/resilience4j-events-endpoints) \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-resilience4j/pom.xml b/spring-boot-modules/spring-boot-resilience4j/pom.xml new file mode 100644 index 0000000000..609bc7fc49 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + com.example + spring-boot-resilience4j + 0.0.1-SNAPSHOT + spring-boot-resilience4j + + + com.baeldung.spring-boot-modules + spring-boot-modules + 1.0.0-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-actuator + + + io.github.resilience4j + resilience4j-spring-boot2 + 2.0.2 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.14.2 + + + org.springframework.boot + spring-boot-starter-test + test + + + com.github.tomakehurst + wiremock-jre8 + 2.35.0 + test + + + + \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ApiExceptionHandler.java b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ApiExceptionHandler.java new file mode 100644 index 0000000000..4e14d5c532 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ApiExceptionHandler.java @@ -0,0 +1,29 @@ +package com.baeldung.resilience4j.eventendpoints; + +import io.github.resilience4j.bulkhead.BulkheadFullException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import java.util.concurrent.TimeoutException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +public class ApiExceptionHandler { + @ExceptionHandler({CallNotPermittedException.class}) + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + public void handleCallNotPermittedException() {} + + @ExceptionHandler({TimeoutException.class}) + @ResponseStatus(HttpStatus.REQUEST_TIMEOUT) + public void handleTimeoutException() {} + + @ExceptionHandler({BulkheadFullException.class}) + @ResponseStatus(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED) + public void handleBulkheadFullException() {} + + @ExceptionHandler({RequestNotPermitted.class}) + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + public void handleRequestNotPermitted() {} +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalAPICaller.java b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalAPICaller.java new file mode 100644 index 0000000000..a860e7e3cc --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalAPICaller.java @@ -0,0 +1,28 @@ +package com.baeldung.resilience4j.eventendpoints; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class ExternalAPICaller { + private final RestTemplate restTemplate; + + @Autowired + public ExternalAPICaller(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public String callApi() { + return restTemplate.getForObject("/api/external", String.class); + } + + public String callApiWithDelay() { + String result = restTemplate.getForObject("/api/external", String.class); + try { + Thread.sleep(5000); + } catch (InterruptedException ignore) { + } + return result; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalApiCallerConfig.java b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalApiCallerConfig.java new file mode 100644 index 0000000000..7145e58877 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ExternalApiCallerConfig.java @@ -0,0 +1,14 @@ +package com.baeldung.resilience4j.eventendpoints; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class ExternalApiCallerConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplateBuilder().rootUri("http://localhost:9090").build(); + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientApp.java b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientApp.java new file mode 100644 index 0000000000..35b999a9de --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientApp.java @@ -0,0 +1,12 @@ +package com.baeldung.resilience4j.eventendpoints; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication() +public class ResilientApp { + + public static void main(String[] args) { + SpringApplication.run(ResilientApp.class, args); + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientAppController.java b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientAppController.java new file mode 100644 index 0000000000..ccd0108c2f --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/java/com/baeldung/resilience4j/eventendpoints/ResilientAppController.java @@ -0,0 +1,58 @@ +package com.baeldung.resilience4j.eventendpoints; + +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; +import io.github.resilience4j.retry.annotation.Retry; +import io.github.resilience4j.timelimiter.annotation.TimeLimiter; +import java.util.concurrent.CompletableFuture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/") +public class ResilientAppController { + + private final ExternalAPICaller externalAPICaller; + + @Autowired + public ResilientAppController(ExternalAPICaller externalApi) { + this.externalAPICaller = externalApi; + } + + @GetMapping("/circuit-breaker") + @CircuitBreaker(name = "externalService") + public String circuitBreakerApi() { + return externalAPICaller.callApi(); + } + + @GetMapping("/retry") + @Retry(name = "externalService", fallbackMethod = "fallbackAfterRetry") + public String retryApi() { + return externalAPICaller.callApi(); + } + + @GetMapping("/bulkhead") + @Bulkhead(name = "externalService") + public String bulkheadApi() { + return externalAPICaller.callApi(); + } + + @GetMapping("/rate-limiter") + @RateLimiter(name = "externalService") + public String rateLimitApi() { + return externalAPICaller.callApi(); + } + + @GetMapping("/time-limiter") + @TimeLimiter(name = "externalService") + public CompletableFuture timeLimiterApi() { + return CompletableFuture.supplyAsync(externalAPICaller::callApiWithDelay); + } + + public String fallbackAfterRetry(Exception ex) { + return "all retries have exhausted"; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/main/resources/application.yml b/spring-boot-modules/spring-boot-resilience4j/src/main/resources/application.yml new file mode 100644 index 0000000000..81904f56d9 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/main/resources/application.yml @@ -0,0 +1,60 @@ +management: + endpoints: + web: + exposure: + include: '*' + +resilience4j.circuitbreaker: + configs: + default: + registerHealthIndicator: true + slidingWindowSize: 10 + minimumNumberOfCalls: 5 + permittedNumberOfCallsInHalfOpenState: 3 + automaticTransitionFromOpenToHalfOpenEnabled: true + waitDurationInOpenState: 5s + failureRateThreshold: 50 + eventConsumerBufferSize: 50 + instances: + externalService: + baseConfig: default + +resilience4j.retry: + configs: + default: + maxAttempts: 3 + waitDuration: 100 + instances: + externalService: + baseConfig: default + +resilience4j.timelimiter: + configs: + default: + cancelRunningFuture: true + timeoutDuration: 2s + instances: + externalService: + baseConfig: default + +resilience4j.bulkhead: + configs: + default: + max-concurrent-calls: 3 + max-wait-duration: 1 + instances: + externalService: + baseConfig: default + +resilience4j.ratelimiter: + configs: + default: + limit-for-period: 5 + limit-refresh-period: 60s + timeout-duration: 0s + allow-health-indicator-to-fail: true + subscribe-for-events: true + event-consumer-buffer-size: 50 + instances: + externalService: + baseConfig: default \ No newline at end of file diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/ResilientAppControllerIntegrationTest.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/ResilientAppControllerIntegrationTest.java new file mode 100644 index 0000000000..ae1b89764d --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/ResilientAppControllerIntegrationTest.java @@ -0,0 +1,318 @@ +package com.baeldung.resilience4j.eventendpoints; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.http.HttpStatus.*; + +import com.baeldung.resilience4j.eventendpoints.model.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JSR310Module; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.stream.IntStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ResilientAppControllerIntegrationTest { + + @Autowired private TestRestTemplate restTemplate; + + @LocalServerPort private Integer port; + + private static final ObjectMapper objectMapper = + new ObjectMapper().registerModule(new JSR310Module()); + + @RegisterExtension + static WireMockExtension EXTERNAL_SERVICE = + WireMockExtension.newInstance() + .options(WireMockConfiguration.wireMockConfig().port(9090)) + .build(); + + @Test + void testCircuitBreakerEvents() throws Exception { + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(serverError())); + + IntStream.rangeClosed(1, 5) + .forEach( + i -> { + ResponseEntity response = + restTemplate.getForEntity("/api/circuit-breaker", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + }); + + // Fetch the events generated by the above calls + List circuitBreakerEvents = getCircuitBreakerEvents(); + assertThat(circuitBreakerEvents.size()).isEqualTo(7); + + // The first 5 events are the error events corresponding to the above server error responses + IntStream.rangeClosed(0, 4) + .forEach( + i -> { + assertThat(circuitBreakerEvents.get(i).getCircuitBreakerName()) + .isEqualTo("externalService"); + assertThat(circuitBreakerEvents.get(i).getType()).isEqualTo("ERROR"); + assertThat(circuitBreakerEvents.get(i).getCreationTime()).isNotNull(); + assertThat(circuitBreakerEvents.get(i).getErrorMessage()).isNotNull(); + assertThat(circuitBreakerEvents.get(i).getDurationInMs()).isNotNull(); + assertThat(circuitBreakerEvents.get(i).getStateTransition()).isNull(); + }); + + // Following event signals the configured failure rate exceeded + CircuitBreakerEvent failureRateExceededEvent = circuitBreakerEvents.get(5); + assertThat(failureRateExceededEvent.getCircuitBreakerName()).isEqualTo("externalService"); + assertThat(failureRateExceededEvent.getType()).isEqualTo("FAILURE_RATE_EXCEEDED"); + assertThat(failureRateExceededEvent.getCreationTime()).isNotNull(); + assertThat(failureRateExceededEvent.getErrorMessage()).isNull(); + assertThat(failureRateExceededEvent.getDurationInMs()).isNull(); + assertThat(failureRateExceededEvent.getStateTransition()).isNull(); + + // Following event signals the state transition from CLOSED TO OPEN + CircuitBreakerEvent stateTransitionEvent = circuitBreakerEvents.get(6); + assertThat(stateTransitionEvent.getCircuitBreakerName()).isEqualTo("externalService"); + assertThat(stateTransitionEvent.getType()).isEqualTo("STATE_TRANSITION"); + assertThat(stateTransitionEvent.getCreationTime()).isNotNull(); + assertThat(stateTransitionEvent.getErrorMessage()).isNull(); + assertThat(stateTransitionEvent.getDurationInMs()).isNull(); + assertThat(stateTransitionEvent.getStateTransition()).isEqualTo("CLOSED_TO_OPEN"); + + IntStream.rangeClosed(1, 5) + .forEach( + i -> { + ResponseEntity response = + restTemplate.getForEntity("/api/circuit-breaker", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + }); + + /// Fetch the events generated by the above calls + List updatedCircuitBreakerEvents = getCircuitBreakerEvents(); + assertThat(updatedCircuitBreakerEvents.size()).isEqualTo(12); + + // Newly added events will be of type NOT_PERMITTED since the Circuit Breaker is in OPEN state + IntStream.rangeClosed(7, 11) + .forEach( + i -> { + assertThat(updatedCircuitBreakerEvents.get(i).getCircuitBreakerName()) + .isEqualTo("externalService"); + assertThat(updatedCircuitBreakerEvents.get(i).getType()).isEqualTo("NOT_PERMITTED"); + assertThat(updatedCircuitBreakerEvents.get(i).getCreationTime()).isNotNull(); + assertThat(updatedCircuitBreakerEvents.get(i).getErrorMessage()).isNull(); + assertThat(updatedCircuitBreakerEvents.get(i).getDurationInMs()).isNull(); + assertThat(updatedCircuitBreakerEvents.get(i).getStateTransition()).isNull(); + }); + + EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external"))); + } + + private List getCircuitBreakerEvents() throws Exception { + String jsonEventsList = + IOUtils.toString( + new URI("http://localhost:" + port + "/actuator/circuitbreakerevents"), + Charset.forName("UTF-8")); + CircuitBreakerEvents circuitBreakerEvents = + objectMapper.readValue(jsonEventsList, CircuitBreakerEvents.class); + return circuitBreakerEvents.getCircuitBreakerEvents(); + } + + @Test + void testRetryEvents() throws Exception { + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok())); + ResponseEntity response1 = restTemplate.getForEntity("/api/retry", String.class); + EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external"))); + + EXTERNAL_SERVICE.resetRequests(); + + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(serverError())); + ResponseEntity response2 = restTemplate.getForEntity("/api/retry", String.class); + assertThat(response2.getBody()).isEqualTo("all retries have exhausted"); + EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external"))); + + List retryEvents = getRetryEvents(); + assertThat(retryEvents.size()).isEqualTo(3); + + // First 2 events should be retry events + IntStream.rangeClosed(0, 1) + .forEach( + i -> { + assertThat(retryEvents.get(i).getRetryName()).isEqualTo("externalService"); + assertThat(retryEvents.get(i).getType()).isEqualTo("RETRY"); + assertThat(retryEvents.get(i).getCreationTime()).isNotNull(); + assertThat(retryEvents.get(i).getErrorMessage()).isNotNull(); + assertThat(retryEvents.get(i).getNumberOfAttempts()).isEqualTo(i + 1); + }); + + // Last event should be an error event because the configured num of retries is reached + RetryEvent errorRetryEvent = retryEvents.get(2); + assertThat(errorRetryEvent.getRetryName()).isEqualTo("externalService"); + assertThat(errorRetryEvent.getType()).isEqualTo("ERROR"); + assertThat(errorRetryEvent.getCreationTime()).isNotNull(); + assertThat(errorRetryEvent.getErrorMessage()).isNotNull(); + assertThat(errorRetryEvent.getNumberOfAttempts()).isEqualTo(3); + } + + private List getRetryEvents() throws Exception { + String jsonEventsList = + IOUtils.toString( + new URI("http://localhost:" + port + "/actuator/retryevents"), + Charset.forName("UTF-8")); + RetryEvents retryEvents = objectMapper.readValue(jsonEventsList, RetryEvents.class); + return retryEvents.getRetryEvents(); + } + + @Test + void testTimeLimiterEvents() throws Exception { + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok())); + ResponseEntity response = restTemplate.getForEntity("/api/time-limiter", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT); + EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external"))); + + List timeLimiterEvents = getTimeLimiterEvents(); + assertThat(timeLimiterEvents.size()).isEqualTo(1); + TimeLimiterEvent timeoutEvent = timeLimiterEvents.get(0); + assertThat(timeoutEvent.getTimeLimiterName()).isEqualTo("externalService"); + assertThat(timeoutEvent.getType()).isEqualTo("TIMEOUT"); + assertThat(timeoutEvent.getCreationTime()).isNotNull(); + } + + private List getTimeLimiterEvents() throws Exception { + String jsonEventsList = + IOUtils.toString( + new URI("http://localhost:" + port + "/actuator/timelimiterevents"), + Charset.forName("UTF-8")); + TimeLimiterEvents timeLimiterEvents = + objectMapper.readValue(jsonEventsList, TimeLimiterEvents.class); + return timeLimiterEvents.getTimeLimiterEvents(); + } + + @Test + void testBulkheadEvents() throws Exception { + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok())); + Map responseStatusCount = new ConcurrentHashMap<>(); + ExecutorService executorService = Executors.newFixedThreadPool(5); + + List> tasks = new ArrayList<>(); + IntStream.rangeClosed(1, 5) + .forEach( + i -> + tasks.add( + () -> { + ResponseEntity response = + restTemplate.getForEntity("/api/bulkhead", String.class); + return response.getStatusCodeValue(); + })); + + List> futures = executorService.invokeAll(tasks); + for (Future future : futures) { + int statusCode = future.get(); + responseStatusCount.merge(statusCode, 1, Integer::sum); + } + executorService.shutdown(); + + assertEquals(2, responseStatusCount.keySet().size()); + assertTrue(responseStatusCount.containsKey(BANDWIDTH_LIMIT_EXCEEDED.value())); + assertTrue(responseStatusCount.containsKey(OK.value())); + EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external"))); + + List bulkheadEvents = getBulkheadEvents(); + + // Based on the configuration, the first 3 calls should be permitted, so we should see the + // CALL_PERMITTED events + IntStream.rangeClosed(0, 2) + .forEach( + i -> { + assertThat(bulkheadEvents.get(i).getBulkheadName()).isEqualTo("externalService"); + assertThat(bulkheadEvents.get(i).getType()).isEqualTo("CALL_PERMITTED"); + assertThat(bulkheadEvents.get(i).getCreationTime()).isNotNull(); + }); + + // For the other 2 calls made we should see the CALL_REJECTED events + IntStream.rangeClosed(3, 4) + .forEach( + i -> { + assertThat(bulkheadEvents.get(i).getBulkheadName()).isEqualTo("externalService"); + assertThat(bulkheadEvents.get(i).getType()).isEqualTo("CALL_REJECTED"); + assertThat(bulkheadEvents.get(i).getCreationTime()).isNotNull(); + }); + } + + private List getBulkheadEvents() throws Exception { + String jsonEventsList = + IOUtils.toString( + new URI("http://localhost:" + port + "/actuator/bulkheadevents"), + Charset.forName("UTF-8")); + BulkheadEvents bulkheadEvents = objectMapper.readValue(jsonEventsList, BulkheadEvents.class); + return bulkheadEvents.getBulkheadEvents(); + } + + @Test + void testRateLimiterEvents() throws Exception { + EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok())); + Map responseStatusCount = new ConcurrentHashMap<>(); + + IntStream.rangeClosed(1, 50) + .forEach( + i -> { + ResponseEntity response = + restTemplate.getForEntity("/api/rate-limiter", String.class); + int statusCode = response.getStatusCodeValue(); + responseStatusCount.put( + statusCode, responseStatusCount.getOrDefault(statusCode, 0) + 1); + }); + + assertEquals(2, responseStatusCount.keySet().size()); + assertTrue(responseStatusCount.containsKey(TOO_MANY_REQUESTS.value())); + assertTrue(responseStatusCount.containsKey(OK.value())); + EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external"))); + + List rateLimiterEvents = getRateLimiterEvents(); + assertThat(rateLimiterEvents.size()).isEqualTo(50); + + // First allowed calls in the rate limit is 5, so we should see for those SUCCESSFUL_ACQUIRE + // events + IntStream.rangeClosed(0, 4) + .forEach( + i -> { + assertThat(rateLimiterEvents.get(i).getRateLimiterName()) + .isEqualTo("externalService"); + assertThat(rateLimiterEvents.get(i).getType()).isEqualTo("SUCCESSFUL_ACQUIRE"); + assertThat(rateLimiterEvents.get(i).getCreationTime()).isNotNull(); + }); + + // the rest should be FAILED_ACQUIRE events since the rate limiter kicks in + IntStream.rangeClosed(5, rateLimiterEvents.size() - 1) + .forEach( + i -> { + assertThat(rateLimiterEvents.get(i).getRateLimiterName()) + .isEqualTo("externalService"); + assertThat(rateLimiterEvents.get(i).getType()).isEqualTo("FAILED_ACQUIRE"); + assertThat(rateLimiterEvents.get(i).getCreationTime()).isNotNull(); + }); + } + + private List getRateLimiterEvents() throws Exception { + String jsonEventsList = + IOUtils.toString( + new URI("http://localhost:" + port + "/actuator/ratelimiterevents"), + Charset.forName("UTF-8")); + RateLimiterEvents rateLimiterEvents = + objectMapper.readValue(jsonEventsList, RateLimiterEvents.class); + return rateLimiterEvents.getRateLimiterEvents(); + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvent.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvent.java new file mode 100644 index 0000000000..6d25006bbb --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvent.java @@ -0,0 +1,50 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.time.ZonedDateTime; +import java.util.Objects; + +public class BulkheadEvent { + + private String bulkheadName; + private String type; + private ZonedDateTime creationTime; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BulkheadEvent that = (BulkheadEvent) o; + return Objects.equals(bulkheadName, that.bulkheadName) + && Objects.equals(type, that.type) + && Objects.equals(creationTime, that.creationTime); + } + + @Override + public int hashCode() { + return Objects.hash(bulkheadName, type, creationTime); + } + + public String getBulkheadName() { + return bulkheadName; + } + + public void setBulkheadName(String bulkheadName) { + this.bulkheadName = bulkheadName; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ZonedDateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(ZonedDateTime creationTime) { + this.creationTime = creationTime; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvents.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvents.java new file mode 100644 index 0000000000..7d8b6b2a5f --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/BulkheadEvents.java @@ -0,0 +1,16 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.util.List; + +public class BulkheadEvents { + + private List bulkheadEvents; + + public List getBulkheadEvents() { + return bulkheadEvents; + } + + public void setBulkheadEvents(List bulkheadEvents) { + this.bulkheadEvents = bulkheadEvents; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvent.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvent.java new file mode 100644 index 0000000000..0a07c9b495 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvent.java @@ -0,0 +1,81 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.time.ZonedDateTime; +import java.util.Objects; + +public class CircuitBreakerEvent { + + private String circuitBreakerName; + private String type; + private ZonedDateTime creationTime; + private String errorMessage; + private Integer durationInMs; + private String stateTransition; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CircuitBreakerEvent that = (CircuitBreakerEvent) o; + return Objects.equals(circuitBreakerName, that.circuitBreakerName) + && Objects.equals(type, that.type) + && Objects.equals(creationTime, that.creationTime) + && Objects.equals(errorMessage, that.errorMessage) + && Objects.equals(durationInMs, that.durationInMs) + && Objects.equals(stateTransition, that.stateTransition); + } + + @Override + public int hashCode() { + return Objects.hash( + circuitBreakerName, type, creationTime, errorMessage, durationInMs, stateTransition); + } + + public String getCircuitBreakerName() { + return circuitBreakerName; + } + + public void setCircuitBreakerName(String circuitBreakerName) { + this.circuitBreakerName = circuitBreakerName; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ZonedDateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(ZonedDateTime creationTime) { + this.creationTime = creationTime; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Integer getDurationInMs() { + return durationInMs; + } + + public void setDurationInMs(Integer durationInMs) { + this.durationInMs = durationInMs; + } + + public String getStateTransition() { + return stateTransition; + } + + public void setStateTransition(String stateTransition) { + this.stateTransition = stateTransition; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvents.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvents.java new file mode 100644 index 0000000000..c3ec741d05 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/CircuitBreakerEvents.java @@ -0,0 +1,16 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.util.List; + +public class CircuitBreakerEvents { + + private List circuitBreakerEvents; + + public List getCircuitBreakerEvents() { + return circuitBreakerEvents; + } + + public void setCircuitBreakerEvents(List circuitBreakerEvents) { + this.circuitBreakerEvents = circuitBreakerEvents; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvent.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvent.java new file mode 100644 index 0000000000..ccc0f5a9b3 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvent.java @@ -0,0 +1,50 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.time.ZonedDateTime; +import java.util.Objects; + +public class RateLimiterEvent { + + private String rateLimiterName; + private String type; + private ZonedDateTime creationTime; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RateLimiterEvent that = (RateLimiterEvent) o; + return Objects.equals(rateLimiterName, that.rateLimiterName) + && Objects.equals(type, that.type) + && Objects.equals(creationTime, that.creationTime); + } + + @Override + public int hashCode() { + return Objects.hash(rateLimiterName, type, creationTime); + } + + public String getRateLimiterName() { + return rateLimiterName; + } + + public void setRateLimiterName(String rateLimiterName) { + this.rateLimiterName = rateLimiterName; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ZonedDateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(ZonedDateTime creationTime) { + this.creationTime = creationTime; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvents.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvents.java new file mode 100644 index 0000000000..c0cfcdf0d7 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RateLimiterEvents.java @@ -0,0 +1,16 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.util.List; + +public class RateLimiterEvents { + + private List rateLimiterEvents; + + public List getRateLimiterEvents() { + return rateLimiterEvents; + } + + public void setRateLimiterEvents(List rateLimiterEvents) { + this.rateLimiterEvents = rateLimiterEvents; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvent.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvent.java new file mode 100644 index 0000000000..a17d45c679 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvent.java @@ -0,0 +1,70 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.time.ZonedDateTime; +import java.util.Objects; + +public class RetryEvent { + + private String retryName; + private String type; + private ZonedDateTime creationTime; + private String errorMessage; + private Integer numberOfAttempts; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RetryEvent that = (RetryEvent) o; + return Objects.equals(retryName, that.retryName) + && Objects.equals(type, that.type) + && Objects.equals(creationTime, that.creationTime) + && Objects.equals(errorMessage, that.errorMessage) + && Objects.equals(numberOfAttempts, that.numberOfAttempts); + } + + @Override + public int hashCode() { + return Objects.hash(retryName, type, creationTime, errorMessage, numberOfAttempts); + } + + public String getRetryName() { + return retryName; + } + + public void setRetryName(String retryName) { + this.retryName = retryName; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ZonedDateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(ZonedDateTime creationTime) { + this.creationTime = creationTime; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Integer getNumberOfAttempts() { + return numberOfAttempts; + } + + public void setNumberOfAttempts(Integer numberOfAttempts) { + this.numberOfAttempts = numberOfAttempts; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvents.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvents.java new file mode 100644 index 0000000000..e5bb73ff0c --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/RetryEvents.java @@ -0,0 +1,16 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.util.List; + +public class RetryEvents { + + private List retryEvents; + + public List getRetryEvents() { + return retryEvents; + } + + public void setRetryEvents(List retryEvents) { + this.retryEvents = retryEvents; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvent.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvent.java new file mode 100644 index 0000000000..07f891c401 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvent.java @@ -0,0 +1,50 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.time.ZonedDateTime; +import java.util.Objects; + +public class TimeLimiterEvent { + + private String timeLimiterName; + private String type; + private ZonedDateTime creationTime; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TimeLimiterEvent that = (TimeLimiterEvent) o; + return Objects.equals(timeLimiterName, that.timeLimiterName) + && Objects.equals(type, that.type) + && Objects.equals(creationTime, that.creationTime); + } + + @Override + public int hashCode() { + return Objects.hash(timeLimiterName, type, creationTime); + } + + public String getTimeLimiterName() { + return timeLimiterName; + } + + public void setTimeLimiterName(String timeLimiterName) { + this.timeLimiterName = timeLimiterName; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public ZonedDateTime getCreationTime() { + return creationTime; + } + + public void setCreationTime(ZonedDateTime creationTime) { + this.creationTime = creationTime; + } +} diff --git a/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvents.java b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvents.java new file mode 100644 index 0000000000..739ff91916 --- /dev/null +++ b/spring-boot-modules/spring-boot-resilience4j/src/test/java/com/baeldung/resilience4j/eventendpoints/model/TimeLimiterEvents.java @@ -0,0 +1,16 @@ +package com.baeldung.resilience4j.eventendpoints.model; + +import java.util.List; + +public class TimeLimiterEvents { + + private List timeLimiterEvents; + + public List getTimeLimiterEvents() { + return timeLimiterEvents; + } + + public void setTimeLimiterEvents(List timeLimiterEvents) { + this.timeLimiterEvents = timeLimiterEvents; + } +}