Skip to content

Commit

Permalink
🧪 test(res4j-cbreaker): behaviour coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
franciscoengenheiro committed May 19, 2024
1 parent 6f25ccf commit 8922267
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 1 deletion.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ ktor-server-hostcommon = { module = "io.ktor:ktor-server-host-common", version.r
ktor-server-configyaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }

# Resilience4j
resilience4j-circuitbreaker = { module = "io.github.resilience4j:resilience4j-circuitbreaker", version.ref = "resilience4j" }
resilience4j-retry = { module = "io.github.resilience4j:resilience4j-retry", version.ref = "resilience4j" }
resilience4j-kotlin = { module = "io.github.resilience4j:resilience4j-kotlin", version.ref = "resilience4j" }

Expand Down
3 changes: 3 additions & 0 deletions resilience4j/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
- [Configuration](#configuration-1)
- [Decorators](#decorators-1)
2. [Circuit Breaker](#circuit-breaker)
- [State Machine](#state-machine)
- [Configuration](#configuration-2)
- [Sliding Window](#sliding-window)
- [Additional Details](#additional-details)
3. [Kotlin Multiplatform Design](#kotlin-multiplatform-design)
4. [Flow](#flow)

Expand Down
1 change: 1 addition & 0 deletions resilience4j/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ repositories {
}

dependencies {
testImplementation(libs.resilience4j.circuitbreaker)
testImplementation(libs.resilience4j.retry)
testImplementation(libs.resilience4j.kotlin)
testImplementation(libs.kotlinx.coroutines.core)
Expand Down
201 changes: 201 additions & 0 deletions resilience4j/src/test/java/circuitbreaker/CircuitBreakerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package circuitbreaker;

import exceptions.WebServiceException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import org.junit.jupiter.api.Test;
import service.RemoteService;

import java.time.Duration;
import java.util.function.Function;
import java.util.logging.Logger;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

public class CircuitBreakerTest {

static Logger logger = Logger.getLogger(CircuitBreakerTest.class.getName());

@Test
public void testCircuitBreakerNormalBehavior() {
// given: a remote service
RemoteService service = mock(RemoteService.class);

// and: a circuit breaker configuration
int minimumNrOfCalls = 5;
long waitDurationInOpenState = 2000;
long maxWaitDurationInHalfOpenState = 0; // should wait indefinitely for all permittedNumberOfCallsInHalfOpenState
int permittedNumberOfCallsInHalfOpenState = 2;
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.enableAutomaticTransitionFromOpenToHalfOpen()
.slidingWindow(100, minimumNrOfCalls, CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.failureRateThreshold(100) // 100%
.permittedNumberOfCallsInHalfOpenState(permittedNumberOfCallsInHalfOpenState)
.waitDurationInOpenState(Duration.ofMillis(waitDurationInOpenState))
.maxWaitDurationInHalfOpenState(Duration.ofMillis(maxWaitDurationInHalfOpenState))
.build();

// and: a function is decorated with a circuit breaker
CircuitBreaker circuitBreaker = CircuitBreaker.of("test", config);
Function<Integer, Integer> decorated = CircuitBreaker
.decorateFunction(circuitBreaker, service::process);

// and: logs are placed on all circuit breaker events
logAllCircuitBreakerEvents(circuitBreaker);

// and: the underlying service is configured to always throw an exception
when(service.process(anyInt()))
.thenThrow(new WebServiceException("BAM!"));

// and: before the failure rate threshold is reached, the circuit breaker is closed
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());

// when: the decorated function is invoked minimumNumberOfCalls times
for (int i = 1; i < minimumNrOfCalls; i++) {
try {
decorated.apply(i);
} catch (Exception ignore) {
// then: the circuit breaker is in the closed state because the failure rate threshold
// wasn't yet calculated (which is done after minimumNumberOfCalls)
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());
}
}

// when: the decorated function is invoked one more time
try {
decorated.apply(anyInt());
} catch (Exception ignore) {
// then: the circuit breaker is in the open state
assertSame(CircuitBreaker.State.OPEN, circuitBreaker.getState());
}

// and: after the wait duration in open state
sleepFor(waitDurationInOpenState + 1000);

// then: the circuit breaker is in the half-open state
assertSame(CircuitBreaker.State.HALF_OPEN, circuitBreaker.getState());

// and: after the max wait duration in half-open state
sleepFor(maxWaitDurationInHalfOpenState);

// and: the underlying service is configured to always return success
reset(service);
when(service.process(anyInt()))
.thenReturn(0);

for (int i = 0; i < permittedNumberOfCallsInHalfOpenState; i++) {
// when: the decorated function is invoked
try {
decorated.apply(i);
} catch (Exception ignore) {
// then: the circuit breaker is in the closed state
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());
}
}

}

@Test
public void testCircuitBreakerOpenState() {
// given: a remote service
RemoteService service = mock(RemoteService.class);

// and: a circuit breaker configuration
int failureRateThreshold = 100;
int minimumNrOfCalls = 10;
long waitDurationInOpenState = 2000;
long maxWaitDurationInHalfOpenState = 2000;
int slidingWindowSize = 10;
int permittedNumberOfCallsInHalfOpenState = 2;
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindow(slidingWindowSize, minimumNrOfCalls, CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.failureRateThreshold(failureRateThreshold)
.waitDurationInOpenState(Duration.ofMillis(waitDurationInOpenState))
.maxWaitDurationInHalfOpenState(Duration.ofMillis(maxWaitDurationInHalfOpenState))
.permittedNumberOfCallsInHalfOpenState(permittedNumberOfCallsInHalfOpenState)
.build();

// and: a function is decorated with a circuit breaker
CircuitBreaker circuitBreaker = CircuitBreaker.of("test", config);
Function<Integer, Integer> decorated = CircuitBreaker
.decorateFunction(circuitBreaker, service::process);

// and: logs are placed on all circuit breaker events
//logAllCircuitBreakerEvents(circuitBreaker);

// and: the underlying service is configured to always throw an exception
when(service.process(anyInt()))
.thenThrow(new WebServiceException("BAM!"));

// and: before the failure rate threshold is reached, the circuit breaker is closed
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());

// when: the decorated function is invoked minimumNumberOfCalls times
for (int i = 1; i < minimumNrOfCalls; i++) {
try {
decorated.apply(i);
} catch (Exception ignore) {
// then: the circuit breaker is in the closed state because the failure rate threshold
// wasn't yet calculated (which is done after minimumNumberOfCalls)
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());
}
}

// when: the decorated function is invoked one more time
try {
decorated.apply(anyInt());
} catch (Exception ignore) {
// then: the circuit breaker is in the open state
assertSame(CircuitBreaker.State.OPEN, circuitBreaker.getState());
}

// and: after the wait duration in open state
sleepFor(waitDurationInOpenState + 1000);

// then: the circuit breaker is still in the open state (because automatic transition is disabled)
assertSame(CircuitBreaker.State.OPEN, circuitBreaker.getState());

// when: the decorated function is invoked once
try {
decorated.apply(anyInt());
} catch (Exception ignore) {
// then: the circuit breaker is in the HALF_OPEN state
assertSame(CircuitBreaker.State.HALF_OPEN, circuitBreaker.getState());
}

// when: the service is configured to always return success
reset(service);
when(service.process(anyInt()))
.thenReturn(0);

// and: the decorated function is invoked the nr of times necessary to lower the failure rate below the threshold
for (int i = 0; i < permittedNumberOfCallsInHalfOpenState; i++) {
try {
decorated.apply(anyInt());
} catch (Exception ignore) {
// ignore
} finally {
System.out.println(circuitBreaker.getState());
}
}

// then: the circuit breaker is in the closed state
assertSame(CircuitBreaker.State.CLOSED, circuitBreaker.getState());
}

// TODO: slow call threshold, manual state transition, time-based sliding window

private static void sleepFor(Long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

private static void logAllCircuitBreakerEvents(CircuitBreaker circuitBreaker) {
circuitBreaker.getEventPublisher()
.onEvent(event -> logger.info(event.toString()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package exceptions;

public class BusinessServiceException extends RuntimeException {
public BusinessServiceException(String message) {
super(message);
}
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package exceptions;

// TODO: Why cant it extend only Exception?
public sealed class RemoteServiceException extends RuntimeException permits WebServiceException, NetworkException {
public RemoteServiceException(String message) {
super(message);
Expand Down
72 changes: 72 additions & 0 deletions resilience4j/src/test/java/retry/RetryServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package retry;

import exceptions.BusinessServiceException;
import exceptions.NetworkException;
import exceptions.RemoteServiceException;
import exceptions.WebServiceException;
Expand Down Expand Up @@ -943,4 +944,75 @@ public void overrideABaseConfigPolicy() {
assertEquals(baseConfig.isFailAfterMaxAttempts(), customConfig.isFailAfterMaxAttempts());

}

@Test
public void ignoreExceptionsOnRetry() {
// given: a retry configuration
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.retryExceptions(NetworkException.class)
.ignoreExceptions(BusinessServiceException.class)
.build();

// and: a retry instance
Retry retry = Retry.of("somename", config);

// and: a retry list to store the retry event
List<Type> retryEvents = new ArrayList<>();

// and: a set of listeners are registered to log all retry events
retry.getEventPublisher()
.onRetry(event -> {
Type type = event.getEventType();
logger.info("Interval: " + event.getWaitInterval()
+ " - Event: " + type);
retryEvents.add(type);
})
.onError(event -> {
Type type = event.getEventType();
logger.info("Error: " + type);
retryEvents.add(type);
})
.onIgnoredError(event -> {
Type type = event.getEventType();
logger.info("Ignored error: " + type);
retryEvents.add(type);
})
.onSuccess(event -> {
Type type = event.getEventType();
logger.info("Success: " + type);
retryEvents.add(type);
});

// when: a service is decorated with the retry mechanism
RemoteService service = mock(RemoteService.class);
Function<Integer, Void> decorated
= Retry.decorateFunction(retry, (Integer s) -> {
service.process(s);
return null;
});

// and: a remote service configuration that can throw an ignored exception
when(service.process(anyInt()))
.thenThrow(
new NetworkException("Thanks Vodafone!"),
new BusinessServiceException("BAM!"),
new WebServiceException("BAM!")
);

// when: the service is called
try {
decorated.apply(anyInt());
fail("Expected the retry to fail after first attempt");
} catch (Exception e) {
// then: it should not be retried
verify(service, times(2)).process(anyInt());
assertEquals(2, retryEvents.size());
List<Type> expectedList = List.of(
Type.RETRY,
Type.IGNORED_ERROR
);
assertTrue(expectedList.containsAll(retryEvents));
}
}
}

0 comments on commit 8922267

Please # to comment.