BAEL-5798: Guide to Resilience4j with Spring Boot (#12854)
Co-authored-by: Tapan Avasthi <tavasthi@Tapans-MacBook-Air.local>
This commit is contained in:
parent
6da922c20a
commit
58b522ec56
|
@ -16,6 +16,15 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
|
@ -72,6 +81,20 @@
|
|||
<artifactId>jandex</artifactId>
|
||||
<version>2.4.3.Final</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-spring-boot2</artifactId>
|
||||
<version>1.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/com.github.tomakehurst/wiremock-jre8 -->
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-jre8</artifactId>
|
||||
<version>2.34.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String> 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";
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|
@ -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<String> response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
|
||||
IntStream.rangeClosed(1, 5)
|
||||
.forEach(i -> {
|
||||
ResponseEntity<String> 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<String> 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<String> 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<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
|
||||
|
||||
IntStream.rangeClosed(1, 5)
|
||||
.parallel()
|
||||
.forEach(i -> {
|
||||
ResponseEntity<String> 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<String> 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<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
|
||||
|
||||
IntStream.rangeClosed(1, 50)
|
||||
.parallel()
|
||||
.forEach(i -> {
|
||||
ResponseEntity<String> 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")));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue