Created
May 2, 2016 14:26
-
-
Save moreau-nicolas/2f4a0ea0051eb09714ba3435a67ff046 to your computer and use it in GitHub Desktop.
A generic retry mechanism in Java 8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
apply plugin: 'java' | |
sourceCompatibility = 1.8 | |
group = 'com.github.moreaunicolas' | |
version = '0.0.1-SNAPSHOT' | |
repositories { | |
mavenCentral() | |
} | |
dependencies { | |
compile 'ch.qos.logback:logback-classic:1.1.7' | |
testCompile 'junit:junit:4.12' | |
testCompile 'org.mockito:mockito-core:1.10.19' | |
testCompile 'org.powermock:powermock-module-junit4:1.6.4' | |
testCompile 'org.powermock:powermock-api-mockito:1.6.4' | |
testCompile 'org.assertj:assertj-core:3.4.1' | |
} | |
task wrapper(type: Wrapper) { | |
gradleVersion = '2.9' | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<groupId>com.github.moreaunicolas</groupId> | |
<artifactId>generic-retry</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<properties> | |
<maven.compiler.source>1.8</maven.compiler.source> | |
<maven.compiler.target>1.8</maven.compiler.target> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>ch.qos.logback</groupId> | |
<artifactId>logback-classic</artifactId> | |
<version>1.1.7</version> | |
</dependency> | |
<dependency> | |
<groupId>junit</groupId> | |
<artifactId>junit</artifactId> | |
<version>4.12</version> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.mockito</groupId> | |
<artifactId>mockito-core</artifactId> | |
<version>1.10.19</version> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.powermock</groupId> | |
<artifactId>powermock-module-junit4</artifactId> | |
<version>1.6.4</version> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.powermock</groupId> | |
<artifactId>powermock-api-mockito</artifactId> | |
<version>1.6.4</version> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.assertj</groupId> | |
<artifactId>assertj-core</artifactId> | |
<version>3.4.1</version> | |
</dependency> | |
</dependencies> | |
</project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.github.moreaunicolas; | |
import org.slf4j.Logger; | |
import org.slf4j.LoggerFactory; | |
import java.time.Duration; | |
import java.util.Optional; | |
import java.util.function.Predicate; | |
import java.util.function.Supplier; | |
public final class Retry<T> { | |
private static final Logger LOGGER = LoggerFactory.getLogger(Retry.class); | |
private static long DEFAULT_MAX_ATTEMPTS = 3; | |
private static Duration DEFAULT_WAIT = Duration.ofMillis(250); | |
private static double DEFAULT_WAIT_FACTOR = 1.; | |
private final String message; | |
private final Supplier<T> supplier; | |
private long maxAttempts = DEFAULT_MAX_ATTEMPTS; | |
private Duration wait = DEFAULT_WAIT; | |
private double waitFactor = DEFAULT_WAIT_FACTOR; | |
private long attempt = 0; | |
public static void setDefaultMaxAttempts(long defaultMaxAttempts) { | |
DEFAULT_MAX_ATTEMPTS = defaultMaxAttempts; | |
} | |
public static void setDefaultWait(Duration defaultWait) { | |
DEFAULT_WAIT = defaultWait; | |
} | |
public static void setDefaultWaitFactor(double defaultWaitFactor) { | |
DEFAULT_WAIT_FACTOR = defaultWaitFactor; | |
} | |
public static <U> Retry<U> get(String message, Supplier<U> supplier) { | |
return new Retry<>(message, supplier); | |
} | |
private Retry(String message, Supplier<T> supplier) { | |
this.message = message; | |
this.supplier = supplier; | |
} | |
public Retry<T> withMaxAttempts(long maxAttempts) { | |
this.maxAttempts = maxAttempts; | |
return this; | |
} | |
public Retry<T> withWait(Duration wait) { | |
this.wait = wait; | |
return this; | |
} | |
public Retry<T> withWaitFactor(double factor) { | |
this.waitFactor = factor; | |
return this; | |
} | |
public Optional<T> until(Predicate<T> expectation) { | |
boolean done; | |
T result = null; | |
do { | |
++attempt; | |
LOGGER.debug("{}, attempt #{}", message, attempt); | |
T value = supplier.get(); | |
LOGGER.debug("{}, got value {}", message, value); | |
done = expectation.test(value); | |
if (done) { | |
LOGGER.debug("{}, all done!", message); | |
result = value; | |
} else if (hasRemainingAttempts()) { | |
LOGGER.debug("{}, waiting {} ms", message, wait.toMillis()); | |
try { | |
Thread.sleep(wait.toMillis()); | |
multiplyWaitByWaitFactor(); | |
} catch (InterruptedException e) { | |
LOGGER.warn("{}, interrupted!", message); | |
// Restore the interrupted status | |
Thread.currentThread().interrupt(); | |
break; | |
} | |
} else { | |
LOGGER.debug("{}, giving up", message); | |
} | |
} while (!done && hasRemainingAttempts()); | |
return Optional.ofNullable(result); | |
} | |
private void multiplyWaitByWaitFactor() { | |
double nanos = waitFactor * wait.toNanos(); | |
wait = Duration.ofNanos((long) nanos); | |
} | |
private boolean hasRemainingAttempts() { | |
return attempt < maxAttempts; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.github.moreaunicolas; | |
import org.junit.Before; | |
import org.junit.Test; | |
import org.junit.runner.RunWith; | |
import org.mockito.Mock; | |
import org.powermock.api.mockito.PowerMockito; | |
import org.powermock.core.classloader.annotations.PrepareForTest; | |
import org.powermock.modules.junit4.PowerMockRunner; | |
import java.time.Duration; | |
import java.util.Optional; | |
import java.util.function.Predicate; | |
import java.util.function.Supplier; | |
import static org.assertj.core.api.Assertions.assertThat; | |
import static org.assertj.core.api.Assertions.catchThrowable; | |
import static org.mockito.Mockito.verify; | |
import static org.mockito.Mockito.eq; | |
import static org.mockito.Mockito.anyLong; | |
import static org.mockito.Mockito.doReturn; | |
import static org.mockito.Mockito.doThrow; | |
import static org.mockito.Mockito.times; | |
import static org.mockito.Mockito.never; | |
@RunWith(PowerMockRunner.class) | |
@PrepareForTest({ Retry.class }) | |
public class RetryTests { | |
private static final String MESSAGE = "testing generic retry mechanism"; | |
private static final String EXCEPTION_MESSAGE = "oops"; | |
@Mock private Supplier<Object> supplier; | |
@Before | |
public void setUp() throws InterruptedException { | |
Retry.setDefaultMaxAttempts(1); | |
Retry.setDefaultWait(Duration.ZERO); | |
Retry.setDefaultWaitFactor(1); | |
PowerMockito.spy(Thread.class); | |
PowerMockito.doNothing().when(Thread.class); | |
Thread.sleep(anyLong()); | |
doReturn(null).when(supplier).get(); | |
} | |
@Test | |
public void thatMaxAttemptZeroDoesNotPreventExecution() { | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(0) | |
.until(always -> false); | |
verify(supplier, times(1)).get(); | |
} | |
@Test | |
public void thatExactlyOneAttemptIsMade() { | |
Retry.get(MESSAGE, supplier) | |
.until(always -> true); | |
verify(supplier, times(1)).get(); | |
} | |
@Test | |
public void thatExactlyMaxAttemptsAreMade() { | |
final int MAX_ATTEMPTS = 5; | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(always -> false); | |
verify(supplier, times(MAX_ATTEMPTS)).get(); | |
} | |
@Test | |
public void thatDoesNotSleepOnFirstAttempt() throws InterruptedException { | |
Retry.get(MESSAGE, supplier) | |
.until(always -> true); | |
PowerMockito.verifyStatic(never()); | |
Thread.sleep(anyLong()); | |
} | |
@Test | |
public void thatDoesNotSleepOnLastAttempt() throws InterruptedException { | |
final int MAX_ATTEMPTS = 5; | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(always -> false); | |
PowerMockito.verifyStatic(times(MAX_ATTEMPTS - 1)); | |
Thread.sleep(anyLong()); | |
} | |
@Test | |
public void testThatSleepsTheRightAmount() throws InterruptedException { | |
final int MAX_ATTEMPTS = 2; | |
final long WAIT = 12345; | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.withWait(Duration.ofMillis(WAIT)) | |
.until(always -> false); | |
PowerMockito.verifyStatic(); | |
Thread.sleep(eq(WAIT)); | |
} | |
@Test | |
public void testThatWaitFactorWorks() throws InterruptedException { | |
final int MAX_ATTEMPTS = 4; | |
final long WAIT = 10; | |
final long WAIT_FACTOR = 5; | |
Retry.get(MESSAGE, () -> null) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.withWait(Duration.ofMillis(WAIT)) | |
.withWaitFactor(WAIT_FACTOR) | |
.until(always -> false); | |
PowerMockito.verifyStatic(); | |
Thread.sleep(eq(WAIT)); | |
PowerMockito.verifyStatic(); | |
Thread.sleep(eq(WAIT * WAIT_FACTOR)); | |
PowerMockito.verifyStatic(); | |
Thread.sleep(eq(WAIT * WAIT_FACTOR * WAIT_FACTOR)); | |
} | |
@Test | |
public void thatReturnsEmptyWhenExpectationNeverMet() { | |
final Optional<Object> actual = Retry.get(MESSAGE, supplier) | |
.until(always -> false); | |
assertThat(actual) | |
.isEmpty(); | |
} | |
@Test | |
public void thatReturnsExpectedValueOnFirstAttempt() { | |
final Object expected = new Object(); | |
doReturn(expected).when(supplier).get(); | |
final Optional<Object> actual = Retry.get(MESSAGE, supplier) | |
.until(always -> true); | |
assertThat(actual) | |
.isPresent() | |
.contains(expected); | |
} | |
private static class TrueOnNthAttempt<T> implements Predicate<T> { | |
private final long attempt; | |
private long counter = 1; | |
private TrueOnNthAttempt(long attempt) { | |
this.attempt = attempt; | |
} | |
@Override | |
public boolean test(T t) { | |
return counter++ == attempt; | |
} | |
} | |
@Test | |
public void thatReturnsExpectedValueOnAnySuccessfulAttempt() { | |
final long MAX_ATTEMPTS = 4; | |
final long SUCCESS_ATTEMPT = 2; | |
final Object expected = new Object(); | |
doReturn(expected).when(supplier).get(); | |
final Optional<Object> actual = Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(new TrueOnNthAttempt<>(SUCCESS_ATTEMPT)); | |
assertThat(actual) | |
.isPresent() | |
.contains(expected); | |
} | |
@Test | |
public void thatReturnsExpectedValueOnLastAttempt() { | |
final long MAX_ATTEMPTS = 4; | |
final Object expected = new Object(); | |
doReturn(expected).when(supplier).get(); | |
final Optional<Object> actual = Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(new TrueOnNthAttempt<>(MAX_ATTEMPTS)); | |
assertThat(actual) | |
.isPresent() | |
.contains(expected); | |
} | |
@Test | |
public void thatDoesNotRetryOnInterruptedException() throws InterruptedException { | |
final long MAX_ATTEMPTS = 5; | |
PowerMockito.doThrow(new InterruptedException(EXCEPTION_MESSAGE)).when(Thread.class); | |
Thread.sleep(anyLong()); | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(always -> false); | |
verify(supplier, times(1)).get(); | |
} | |
@Test | |
public void thatDoesNotRetryOnSupplierException() { | |
final long MAX_ATTEMPTS = 2; | |
doThrow(new RuntimeException(EXCEPTION_MESSAGE)).when(supplier).get(); | |
try { | |
Retry.get(MESSAGE, supplier) | |
.withMaxAttempts(MAX_ATTEMPTS) | |
.until(always -> false); | |
} catch (RuntimeException e) { | |
verify(supplier, times(1)).get(); | |
} | |
} | |
@Test | |
public void thatThrowsOnSupplierException() { | |
doThrow(new RuntimeException(EXCEPTION_MESSAGE)).when(supplier).get(); | |
Throwable caught = catchThrowable(() -> | |
Retry.get(MESSAGE, supplier) | |
.until(always -> false) | |
); | |
assertThat(caught) | |
.isExactlyInstanceOf(RuntimeException.class) | |
.hasMessage(EXCEPTION_MESSAGE); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
rootProject.name = 'generic-retry' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment