diff --git a/spring-boot-modules/spring-boot-libraries-2/pom.xml b/spring-boot-modules/spring-boot-libraries-2/pom.xml
index de4e879089..5811530a14 100644
--- a/spring-boot-modules/spring-boot-libraries-2/pom.xml
+++ b/spring-boot-modules/spring-boot-libraries-2/pom.xml
@@ -16,6 +16,15 @@
org.springframework.boot
spring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
ch.qos.logback
logback-classic
@@ -72,6 +81,20 @@
jandex
2.4.3.Final
+
+
+ io.github.resilience4j
+ resilience4j-spring-boot2
+ 1.7.0
+
+
+
+
+ com.github.tomakehurst
+ wiremock-jre8
+ 2.34.0
+ test
+
diff --git a/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ApiExceptionHandler.java b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ApiExceptionHandler.java
new file mode 100644
index 0000000000..d4527ef407
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ApiExceptionHandler.java
@@ -0,0 +1,35 @@
+package com.baeldung.resilientapp;
+
+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;
+
+import io.github.resilience4j.bulkhead.BulkheadFullException;
+import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
+import io.github.resilience4j.ratelimiter.RequestNotPermitted;
+
+@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() {
+ }
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalAPICaller.java b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalAPICaller.java
new file mode 100644
index 0000000000..d091633adb
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalAPICaller.java
@@ -0,0 +1,28 @@
+package com.baeldung.resilientapp;
+
+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-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalApiCallerConfig.java b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalApiCallerConfig.java
new file mode 100644
index 0000000000..6f5820237e
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ExternalApiCallerConfig.java
@@ -0,0 +1,15 @@
+package com.baeldung.resilientapp;
+
+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-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientApp.java b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientApp.java
new file mode 100644
index 0000000000..55d4fe1f18
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientApp.java
@@ -0,0 +1,14 @@
+package com.baeldung.resilientapp;
+
+import org.jobrunr.autoconfigure.JobRunrAutoConfiguration;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication(exclude = { JobRunrAutoConfiguration.class})
+public class ResilientApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ResilientApp.class, args);
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientAppController.java b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientAppController.java
new file mode 100644
index 0000000000..0b99f7cec0
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/java/com/baeldung/resilientapp/ResilientAppController.java
@@ -0,0 +1,62 @@
+package com.baeldung.resilientapp;
+
+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;
+
+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;
+
+@RestController
+@RequestMapping("/api/")
+public class ResilientAppController {
+
+ private final ExternalAPICaller externalAPICaller;
+
+ @Autowired
+ public ResilientAppController(ExternalAPICaller externalApi) {
+ this.externalAPICaller = externalApi;
+ }
+
+ @GetMapping("/circuit-breaker")
+ @CircuitBreaker(name = "CircuitBreakerService")
+ public String circuitBreakerApi() {
+ return externalAPICaller.callApi();
+ }
+
+ @GetMapping("/retry")
+ @Retry(name = "retryApi", fallbackMethod = "fallbackAfterRetry")
+ public String retryApi() {
+ return externalAPICaller.callApi();
+ }
+
+ @GetMapping("/time-limiter")
+ @TimeLimiter(name = "timeLimiterApi")
+ public CompletableFuture timeLimiterApi() {
+ return CompletableFuture.supplyAsync(externalAPICaller::callApiWithDelay);
+ }
+
+ @GetMapping("/bulkhead")
+ @Bulkhead(name = "bulkheadApi")
+ public String bulkheadApi() {
+ return externalAPICaller.callApi();
+ }
+
+ @GetMapping("/rate-limiter")
+ @RateLimiter(name = "rateLimiterApi")
+ public String rateLimitApi() {
+ return externalAPICaller.callApi();
+ }
+
+ public String fallbackAfterRetry(Exception ex) {
+ return "all retries have exhausted";
+ }
+
+}
diff --git a/spring-boot-modules/spring-boot-libraries-2/src/main/resources/application.properties b/spring-boot-modules/spring-boot-libraries-2/src/main/resources/application.properties
index 0fea0163f1..2247eb22cc 100644
--- a/spring-boot-modules/spring-boot-libraries-2/src/main/resources/application.properties
+++ b/spring-boot-modules/spring-boot-libraries-2/src/main/resources/application.properties
@@ -1,3 +1,50 @@
org.jobrunr.background-job-server.enabled=true
org.jobrunr.dashboard.enabled=true
org.jobrunr.dashboard.port=0
+
+logging.level.root=DEBUG
+
+management.health.circuitbreakers.enabled=true
+management.health.ratelimiters.enabled=true
+
+management.endpoints.web.exposure.include=*
+management.endpoint.health.show-details=always
+
+spring.jackson.serialization.indent-output=true
+
+
+resilience4j.circuitbreaker.instances.CircuitBreakerService.failure-rate-threshold=50
+resilience4j.circuitbreaker.instances.CircuitBreakerService.minimum-number-of-calls=5
+resilience4j.circuitbreaker.instances.CircuitBreakerService.automatic-transition-from-open-to-half-open-enabled=true
+resilience4j.circuitbreaker.instances.CircuitBreakerService.wait-duration-in-open-state=5s
+resilience4j.circuitbreaker.instances.CircuitBreakerService.permitted-number-of-calls-in-half-open-state=3
+resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-size=10
+resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-type=count_based
+
+resilience4j.circuitbreaker.metrics.enabled=true
+resilience4j.circuitbreaker.metrics.legacy.enabled=true
+resilience4j.circuitbreaker.instances.CircuitBreakerService.register-health-indicator=true
+resilience4j.circuitbreaker.instances.CircuitBreakerService.event-consumer-buffer-size=10
+
+
+resilience4j.retry.instances.retryApi.max-attempts=3
+resilience4j.retry.instances.retryApi.wait-duration=1s
+resilience4j.retry.metrics.legacy.enabled=true
+resilience4j.retry.metrics.enabled=true
+
+resilience4j.timelimiter.metrics.enabled=true
+resilience4j.timelimiter.instances.timeLimiterApi.timeout-duration=2s
+resilience4j.timelimiter.instances.timeLimiterApi.cancel-running-future=true
+
+resilience4j.bulkhead.metrics.enabled=true
+resilience4j.bulkhead.instances.bulkheadApi.max-concurrent-calls=3
+resilience4j.bulkhead.instances.bulkheadApi.max-wait-duration=1
+
+resilience4j.ratelimiter.metrics.enabled=true
+resilience4j.ratelimiter.instances.rateLimiterApi.register-health-indicator=true
+resilience4j.ratelimiter.instances.rateLimiterApi.limit-for-period=5
+resilience4j.ratelimiter.instances.rateLimiterApi.limit-refresh-period=60s
+resilience4j.ratelimiter.instances.rateLimiterApi.timeout-duration=0s
+resilience4j.ratelimiter.instances.rateLimiterApi.allow-health-indicator-to-fail=true
+resilience4j.ratelimiter.instances.rateLimiterApi.subscribe-for-events=true
+resilience4j.ratelimiter.instances.rateLimiterApi.event-consumer-buffer-size=50
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-libraries-2/src/test/java/com/baeldung/resilientapp/ResilientAppControllerUnitTest.java b/spring-boot-modules/spring-boot-libraries-2/src/test/java/com/baeldung/resilientapp/ResilientAppControllerUnitTest.java
new file mode 100644
index 0000000000..3022583204
--- /dev/null
+++ b/spring-boot-modules/spring-boot-libraries-2/src/test/java/com/baeldung/resilientapp/ResilientAppControllerUnitTest.java
@@ -0,0 +1,135 @@
+package com.baeldung.resilientapp;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.serverError;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import static org.springframework.http.HttpStatus.BANDWIDTH_LIMIT_EXCEEDED;
+import static org.springframework.http.HttpStatus.OK;
+import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import java.util.stream.IntStream;
+
+import org.junit.Assert;
+
+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.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ResilientAppControllerUnitTest {
+
+ @RegisterExtension
+ static WireMockExtension EXTERNAL_SERVICE = WireMockExtension.newInstance()
+ .options(WireMockConfiguration.wireMockConfig()
+ .port(9090))
+ .build();
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ public void testCircuitBreaker() {
+ 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);
+ });
+
+ IntStream.rangeClosed(1, 5)
+ .forEach(i -> {
+ ResponseEntity response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ });
+
+ EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external")));
+ }
+
+ @Test
+ public void testRetry() {
+ 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);
+ Assert.assertEquals(response2.getBody(), "all retries have exhausted");
+ EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external")));
+ }
+
+ @Test
+ public void testBulkhead() {
+ EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
+ .willReturn(ok()));
+ Map responseStatusCount = new ConcurrentHashMap<>();
+
+ IntStream.rangeClosed(1, 5)
+ .parallel()
+ .forEach(i -> {
+ ResponseEntity response = restTemplate.getForEntity("/api/bulkhead", String.class);
+ int statusCode = response.getStatusCodeValue();
+ responseStatusCount.put(statusCode, responseStatusCount.getOrDefault(statusCode, 0) + 1);
+ });
+
+ 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")));
+ }
+
+ @Test
+ public void testTimeLimiter() {
+ 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")));
+ }
+
+ @Test
+ public void testRatelimiter() {
+ EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
+ .willReturn(ok()));
+ Map responseStatusCount = new ConcurrentHashMap<>();
+
+ IntStream.rangeClosed(1, 50)
+ .parallel()
+ .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")));
+ }
+}
\ No newline at end of file