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;
+ }
+}