org.kiwiproject.retry.KiwiRetryer Maven / Gradle / Ivy
Show all versions of kiwi Show documentation
package org.kiwiproject.retry;
import static org.kiwiproject.base.KiwiStrings.f;
import static org.kiwiproject.collect.KiwiLists.isNotNullOrEmpty;
import static org.kiwiproject.retry.KiwiRetryerPredicates.CONNECTION_ERROR;
import static org.kiwiproject.retry.KiwiRetryerPredicates.NO_ROUTE_TO_HOST;
import static org.kiwiproject.retry.KiwiRetryerPredicates.SOCKET_TIMEOUT;
import static org.kiwiproject.retry.KiwiRetryerPredicates.UNKNOWN_HOST;
import lombok.Builder;
import lombok.Singular;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.kiwiproject.base.KiwiThrowables;
import org.kiwiproject.base.UUIDs;
import org.slf4j.event.Level;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
/**
* This is a wrapper class for {@link Retryer}; it wraps methods so that the {@link RetryException} and
* {@link InterruptedException} that are generated from the {@link Retryer#call(Callable)} method are converted to
* {@link KiwiRetryerException}. The Background Information section at the bottom gives some history on
* why this is even here in the first place. You might consider using {@link Retryer} directly now that it is
* within org.kiwiproject as the standalone
* retrying-again library.
*
* It also provides some kiwi-flavored default values, an identifier that can be used to distinguish between
* retryer instances in logs, logging of retry attempts, and some factories for creating retryer instances
* with common behavior.
*
* You can construct a {@link KiwiRetryer} using the builder obtained via {@code KiwiRetryer.builder()}.
*
* Available configuration options for KiwiRetryer:
*
* Name
* Default
* Description
*
*
* retryerId
* UUID generated by {@link UUIDs#randomUUIDString()}
* The identifier for the retryer
*
*
* initialSleepTimeAmount
* 100
*
* The initial sleep amount for the default incrementing wait strategy.
* This value will be ignored if an explicit {@link WaitStrategy} is defined.
*
*
*
* initialSleepTimeUnit
* {@link TimeUnit#MILLISECONDS}
*
* The initial sleep {@link TimeUnit} for the default incrementing wait strategy.
* This value will be ignored if an explicit {@link WaitStrategy} is defined.
*
*
*
* retryIncrementTimeAmount
* 200
*
* The subsequent retry increment amount for the default incrementing wait strategy.
* This value will be ignored if an explicit {@link WaitStrategy} is defined.
*
*
*
* retryIncrementTimeUnit
* {@link TimeUnit#MILLISECONDS}
*
* The subsequent retry increment {@link TimeUnit} for the default incrementing wait strategy.
* This value will be ignored if an explicit {@link WaitStrategy} is defined.
*
*
*
* maxAttempts
* 5
*
* The maximum number of attempts to use for the default stop strategy.
* This value will be ignored if an explicit {@link StopStrategy} is defined.
*
*
*
* processingLogLevel
* {@link Level#DEBUG}
* This log level controls the "happy path" messages that are logged by this retryer.
*
*
* exceptionLogLevel
* {@link Level#WARN}
* This log level controls the "sad path" messages (i.e. exceptions) that are logged by this retryer.
*
*
* retryOnAllExceptions
* false
*
* Tells the retryer to retry on any exception. NOTE: This supersedes any exceptions added to the
* {@code exceptionPredicates} list or if {@code retryOnAllRuntimeExceptions} is set to {@code true}
*
*
*
* retryOnAllRuntimeExceptions
* false
*
* Tells the retryer to retry on all {@link RuntimeException}s. NOTE: This supersedes any exceptions
* added to the {@code exceptionPredicates} list.
*
*
*
* exceptionPredicates
* empty list
*
* Defines the {@link Exception}s that should cause KiwiRetryer to retry its specified {@link Callable} if
* encountered during processing.
*
*
*
* resultPredicates
* empty list
*
* Defines the {@code T} objects that should cause KiwiRetryer to retry its specified {@link Callable} if
* returned by the {@link Callable} during processing.
*
*
*
* stopStrategy
* null
* An explicit {@code stopStrategy} which will override the default stop after attempts stop strategy.
*
*
* waitStrategy
* null
* An explicit {@code waitStrategy} which will override the default incrementing wait strategy.
*
*
*
* Background Information:
*
* Originally this class was created to wrap the (now defunct) guava-retrying
* library and add various defaults and conveniences, as well as an easier way to handle RetryException and its causes.
* The guava-retrying library stopped being maintained circa 2016, and was forked into
* re-retrying, which then stopped active development circa 2018.
* So, in late 2020 we forked re-retrying as retrying-again in order to keep it up to date at a minimum, and hopefully
* add some additional value where it makes sense. It is possible we might simply move some of this functionality
* into retrying-again and then deprecate and remove this functionality from kiwi. That would also allow us to use
* kiwi from retrying-again, which we currently cannot do without introducing a circular dependency.
*
* NOTE: The org.kiwiproject:retrying-again library must be available at runtime.
*/
@Slf4j
@Builder
public class KiwiRetryer {
private static final long DEFAULT_INITIAL_SLEEP_TIME_MILLISECONDS = 100;
private static final long DEFAULT_RETRY_INCREMENT_TIME_MILLISECONDS = 200;
private static final int DEFAULT_MAXIMUM_ATTEMPTS = 5;
@Builder.Default
private final String retryerId = UUIDs.randomUUIDString();
@Builder.Default
private final long initialSleepTimeAmount = DEFAULT_INITIAL_SLEEP_TIME_MILLISECONDS;
@Builder.Default
private final TimeUnit initialSleepTimeUnit = TimeUnit.MILLISECONDS;
@Builder.Default
private final long retryIncrementTimeAmount = DEFAULT_RETRY_INCREMENT_TIME_MILLISECONDS;
@Builder.Default
private final TimeUnit retryIncrementTimeUnit = TimeUnit.MILLISECONDS;
@Builder.Default
private final int maxAttempts = DEFAULT_MAXIMUM_ATTEMPTS;
@Builder.Default
private final Level processingLogLevel = Level.DEBUG;
@Builder.Default
private final Level exceptionLogLevel = Level.WARN;
private final boolean retryOnAllExceptions;
private final boolean retryOnAllRuntimeExceptions;
@Singular
private final List> exceptionPredicates;
@Singular
private final List> resultPredicates;
private final StopStrategy stopStrategy;
private final WaitStrategy waitStrategy;
/**
* Create a new instance with only the default values.
*
* @param type of object the retryer returns
* @return a KiwiRetryer for type {@code T}
*/
public static KiwiRetryer newRetryerWithDefaults() {
return KiwiRetryer.builder().build();
}
/**
* Create a new instance with several common network-related exception predicates.
*
* @param retryerId the retryer ID
* @param type of object the retryer returns
* @return a KiwiRetryer for type {@code T}
* @see KiwiRetryerPredicates#CONNECTION_ERROR
* @see KiwiRetryerPredicates#NO_ROUTE_TO_HOST
* @see KiwiRetryerPredicates#SOCKET_TIMEOUT
* @see KiwiRetryerPredicates#UNKNOWN_HOST
*/
public static KiwiRetryer newRetryerWithDefaultExceptions(String retryerId) {
return KiwiRetryer.builder()
.retryerId(retryerId)
.exceptionPredicate(CONNECTION_ERROR)
.exceptionPredicate(NO_ROUTE_TO_HOST)
.exceptionPredicate(SOCKET_TIMEOUT)
.exceptionPredicate(UNKNOWN_HOST)
.build();
}
/**
* Create a new instance that will retry on all exceptions.
*
* @param retryerId the retryer ID
* @param type of object the retryer returns
* @return a KiwiRetryer for type {@code T}
*/
public static KiwiRetryer newRetryerRetryingAllExceptions(String retryerId) {
return KiwiRetryer.builder()
.retryerId(retryerId)
.retryOnAllExceptions(true)
.build();
}
/**
* Create a new instance that will retry on all runtime exceptions.
*
* @param retryerId the retryer ID
* @param type of object the retryer returns
* @return a KiwiRetryer for type {@code T}
*/
public static KiwiRetryer newRetryerRetryingAllRuntimeExceptions(String retryerId) {
return KiwiRetryer.builder()
.retryerId(retryerId)
.retryOnAllRuntimeExceptions(true)
.build();
}
/**
* Invoke the retryer with the given {@link Callable}.
*
* @param callable the code that attempts to produce a result
* @return the result of the {@link Callable}
* @throws KiwiRetryerException if there was an unhandled exception during processing, or if the maximum
* number of attempts was reached without success. For further information about the cause, you can
* unwrap the exception.
* @see KiwiRetryerException#unwrapKiwiRetryerException(KiwiRetryerException)
* @see KiwiRetryerException#unwrapKiwiRetryerExceptionFully(KiwiRetryerException)
*/
public T call(Callable callable) {
return call(retryerId, callable);
}
/**
* Invoke the retryer with the given ID and {@link Callable}.
*
* This method allows you to use different IDs with the same {@link KiwiRetryer} instance, for example if
* the same retryer is called in separate threads it will be useful to be able to distinguish between them
* in logs.
*
* @param retryerId the ID for this retryer call (overrides the {@code retryerId} of this instance)
* @param callable the code that attempts to produce a result
* @return the result of the {@link Callable}
* @throws KiwiRetryerException if there was an unhandled exception during processing, or if the maximum
* number of attempts was reached without success. For further information about
* the cause, you can unwrap the exception.
* @see KiwiRetryerException#unwrapKiwiRetryerException(KiwiRetryerException)
* @see KiwiRetryerException#unwrapKiwiRetryerExceptionFully(KiwiRetryerException)
*/
public T call(String retryerId, Callable callable) {
try {
var retryer = buildRetryer(retryerId);
LOG.debug("Calling retryer with id: {}", retryerId);
return retryer.call(callable);
} catch (RetryException e) {
var message = f("KiwiRetryer {} failed all {} attempts. Error: {}",
retryerId, e.getNumberOfFailedAttempts(), e.getMessage());
throw new KiwiRetryerException(message, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
var message = f("KiwiRetryer {} interrupted making call. Wrapped exception: {}",
retryerId, e.getCause());
throw new KiwiRetryerException(message, e);
}
}
private Retryer buildRetryer(String retryerId) {
var theWaitStrategy = determineWaitStrategy();
var theStopStrategy = determineStopStrategy();
var theLogListener = new LoggingRetryListener(retryerId, processingLogLevel, exceptionLogLevel);
var retryerBuilder = RetryerBuilder.newBuilder()
.withWaitStrategy(theWaitStrategy)
.withStopStrategy(theStopStrategy)
.withRetryListener(theLogListener);
if (retryOnAllExceptions) {
logIfRetryOnRuntimeExceptionsIsSet();
logIfExceptionPredicatesIsNotEmpty();
retryerBuilder.retryIfException();
} else if (retryOnAllRuntimeExceptions) {
logIfExceptionPredicatesIsNotEmpty();
retryerBuilder.retryIfRuntimeException();
} else {
exceptionPredicates.forEach(retryerBuilder::retryIfException);
}
resultPredicates.forEach(retryerBuilder::retryIfResult);
return retryerBuilder.build();
}
private void logIfRetryOnRuntimeExceptionsIsSet() {
if (retryOnAllRuntimeExceptions) {
LOG.warn("Both retryOnAllExceptions and retryOnAllRuntimeExceptions are set;" +
" retryOnAllExceptions takes precedence");
}
}
private void logIfExceptionPredicatesIsNotEmpty() {
if (isNotNullOrEmpty(exceptionPredicates)) {
var field = retryOnAllExceptions ? "retryOnAllExceptions" : "retryOnAllRuntimeExceptions";
LOG.warn("{} is set while exceptionPredicates is populated: {} takes precedence", field, field);
}
}
private WaitStrategy determineWaitStrategy() {
return Optional.ofNullable(waitStrategy).orElseGet(this::newIncrementingWaitStrategy);
}
private WaitStrategy newIncrementingWaitStrategy() {
return WaitStrategies.incrementingWait(
initialSleepTimeAmount, initialSleepTimeUnit,
retryIncrementTimeAmount, retryIncrementTimeUnit
);
}
private StopStrategy determineStopStrategy() {
return Optional.ofNullable(stopStrategy)
.orElseGet(() -> StopStrategies.stopAfterAttempt(maxAttempts));
}
static class LoggingRetryListener implements RetryListener {
private static final String RETRY_ATTEMPT_MSG = "Retryer [{}], attempt #{} [delay since first attempt: {} ms]";
private static final String RESULT_MSG = "Result for retryer [{}]: {}";
private static final String EXCEPTION_MESSAGE = "Exception occurred for retryer [{}]: {}: {}";
private final String retryId;
private final Level processingLogLevel;
private final Level exceptionLogLevel;
LoggingRetryListener(String id, Level processingLogLevel, Level exceptionLogLevel) {
this.retryId = StringUtils.isBlank(id) ? UUIDs.randomUUIDString() : id;
this.processingLogLevel = processingLogLevel;
this.exceptionLogLevel = exceptionLogLevel;
}
/**
* @implNote The {@link Attempt} should have either a result or an exception, but we are being safe here
* and explicitly checking both of those cases instead of making any assumptions.
*/
@Override
public void onRetry(Attempt> attempt) {
var attemptNumber = attempt.getAttemptNumber();
RetryLogger.logAttempt(LOG, processingLogLevel, attemptNumber,
RETRY_ATTEMPT_MSG, retryId, attemptNumber, attempt.getDelaySinceFirstAttempt());
if (attempt.hasResult()) {
logResultAttempt(attempt, attemptNumber);
} else if (attempt.hasException()) {
logExceptionAttempt(attempt);
}
}
void logResultAttempt(Attempt attempt, long attemptNumber) {
RetryLogger.logAttempt(LOG, processingLogLevel, attemptNumber, RESULT_MSG, retryId, attempt.getResult());
}
/**
* Log all exceptions at exceptionLogLevel (only type and message, not stack trace).
*
* @implNote the throwable in attempt should never be null, but we guard against just in case
*/
void logExceptionAttempt(Attempt> attempt) {
var throwable = attempt.getException();
var type = KiwiThrowables.typeOfNullable(throwable).orElse(null);
var message = KiwiThrowables.messageOfNullable(throwable).orElse(null);
RetryLogger.logAttempt(LOG, exceptionLogLevel, EXCEPTION_MESSAGE, retryId, type, message);
}
}
}