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