0% completed
To solidify these concepts, let’s examine a simple Java implementation of the Retry pattern without using any external frameworks. We’ll implement a utility that retries a given operation with a configurable policy. The code will illustrate a basic retry loop with options for constant vs. exponential backoff and jitter.
import java.util.concurrent.Callable; import java.util.Random; public class RetryUtil { /** * Retries the given task according to the specified parameters. * * @param task The operation to perform (as a Callable returning a result of type T). * @param maxAttempts Maximum number of attempts (initial try + retries). * @param baseDelayMillis Base delay in milliseconds before the first retry. * @param backoffFactor Multiplicative factor for exponential backoff (e.g. 2.0 for doubling). * @param useJitter Whether to randomize delays to avoid synchronized retries. * @return the result of the task if successful within the allowed attempts. * @throws Exception if the task fails in all attempts (the last encountered exception is thrown). */ public static <T> T executeWithRetry(Callable<T> task, int maxAttempts, long baseDelayMillis, double backoffFactor, boolean useJitter) throws Exception { int attempt = 1; Exception lastException = null; Random random = new Random(); while (attempt <= maxAttempts) { try { // Try to execute the task return task.call(); } catch (Exception e) { lastException = e; // Capture the exception for potential rethrow if (attempt == maxAttempts) { // No retries left, break out to throw the exception break; } // Calculate the delay before the next retry long delay = baseDelayMillis; if (backoffFactor > 1.0) { // Exponential backoff: increase delay by backoff factor^(attempt-1) delay = (long) (baseDelayMillis * Math.pow(backoffFactor, attempt - 1)); } if (useJitter) { // Add jitter: randomize the delay a bit to avoid herd effect long jitter = (long) (random.nextDouble() * delay); // Example: use "full jitter" strategy – random between 0 and the calculated delay delay = jitter; } // Optionally, could cap the delay to a maximum value here to avoid excessively long waits. // Wait for the computed delay before retrying try { Thread.sleep(delay); } catch (InterruptedException ie) { // If sleep is interrupted, restore interrupt status and break (stop retrying) Thread.currentThread().interrupt(); break; } // Increment attempt counter and loop to try again attempt++; } } // All attempts exhausted, throw the last caught exception to the caller throw lastException; } }
In the code above, executeWithRetry
is a generic utility that takes a Callable<T>
(allowing any operation that returns a result of type T or throws an exception). The parameters maxAttempts
, baseDelayMillis
, backoffFactor
, and useJitter
define the retry policy:
-
maxAttempts
: The total number of tries (including the initial attempt). For example, ifmaxAttempts=3
, the algorithm will try at most 3 times (1 initial try + 2 retries if needed). -
baseDelayMillis
: The base wait time in milliseconds before a retry. If we are using constant delay (andbackoffFactor
is 1), this is the fixed delay each time. If using exponential backoff, this is the initial delay before the first retry, which will multiply on subsequent retries. -
backoffFactor
: The multiplier for exponential backoff. In the code, ifbackoffFactor > 1.0
, we computedelay = baseDelay * (backoffFactor)^(attempt-1)
. For example, with baseDelay = 100ms and factor = 2.0, the first retry waits 100ms, the second waits 200ms, the third 400ms, etc. If you wanted a constant retry interval, you could setbackoffFactor = 1.0
(so the delay stays the same each time). You could also implement other strategies here (like linear increase or a custom sequence) by adjusting this calculation. -
useJitter
: If true, we apply a jitter by randomizing the delay. The code uses a simple full jitter approach where we take a random value between 0 and the calculated delay. So if the deterministic backoff delay was, say, 1000ms, with jitter we might actually wait anywhere from 0ms up to 1000ms. This randomness helps avoid synchronized retry bursts. In a real system, you might use a slightly more refined jitter formula (for instance, random between 50% and 100% of the delay, to avoid extremely short backoffs), but the principle is demonstrated here.
Inside the loop, the logic is straightforward: call the task and catch any exception. If the call succeeds (task.call()
returns), we return the result immediately. If an exception is thrown, we record it and check if we have retries left. If not (attempt == maxAttempts
), we break out and later throw the exception. If yes, we calculate how long to sleep before the next attempt. We then use Thread.sleep
to wait for that duration (handling InterruptedException
properly by breaking out if interrupted). Then increment the attempt counter and loop again for the retry. If all attempts fail, we throw the last caught exception to the caller, so the caller knows the operation ultimately did not succeed.
Design Choices Explained: This basic implementation makes a few important design choices:
-
We use a generic
Callable<T>
to allow the retry utility to wrap any operation (it could be a network call, a database query, etc.). This makes the retry logic reusable. You could easily adapt this to a void operation (Runnable) or specific interfaces as needed. -
The code catches a broad
Exception
. In a real scenario, you might want to catch only specific exceptions that are deemed transient (e.g., IOExceptions, timeouts, etc.) and let others bubble up without retry. This example keeps it simple by treating any exception as retryable, but in practice you should integrate your error filtering logic here. -
We include configurable backoff and jitter. This allows switching between strategies: for instance, constant delay with no jitter (
backoffFactor=1.0, useJitter=false
), exponential backoff without jitter (backoffFactor=2.0, useJitter=false
), or exponential backoff with jitter (backoffFactor=2.0, useJitter=true
). This flexibility is crucial in tuning retries for different scenarios. The defaults you choose would depend on your system’s needs (e.g., a fast retry for quick transient errors vs. a slow backoff for potentially overloaded services). -
We consider maxAttempts to avoid infinite retry loops. This is vital – an unbounded retry loop could endlessly hammer a failing service. In our code, after
maxAttempts
tries, it breaks out and throws the exception, so the failure can be handled or reported upstream after giving it a fair number of retries. -
The implementation could be extended with additional features: for example, logging each retry attempt, or adding metrics, or adding a circuit-breaker check (e.g., abort retrying if a higher-level circuit is open). But as a core logic, this covers the essentials: attempt, catch, wait, and retry according to policy.
With this code in place, using the Retry Pattern in a microservice becomes as easy as wrapping calls through this utility. For example, if fetchData()
is a function that calls an external API and might throw a TimeoutException
, you could do:
String result = RetryUtil.executeWithRetry( () -> fetchData(url), 3, // try up to 3 times 1000, // start with 1 second delay 2.0, // exponential backoff factor (2x) true // use jitter to avoid synchronized retries );
This would attempt to fetch the data, and on failure, retry up to 2 more times, waiting 1s before the first retry and 2s before the second (with some randomness in those waits). If all attempts fail, it throws the exception after about 3 seconds of total wait. In many cases, that one retry might be all that’s needed to ride out a transient glitch and succeed on the second try, improving the robustness of the service.
.....
.....
.....
On this page
Setting Up the Stage
Building the Retry Logic
Testing Our Retry Logic
Using a Retry Library
A Word of Caution
Going Further
Final Thoughts