On This Page
This is what a retry with exponential backoff in Java looks like when it works. A currency-rates call hits a server that returns 503, 503, then 429 with a Retry-After: 1 header, then finally 200:
[ 451 ms] attempt 1 failed: HTTP 503 -> waiting 49 ms
[ 515 ms] attempt 2 failed: HTTP 503 -> waiting 87 ms
[ 606 ms] attempt 3 failed: HTTP 429 -> waiting 1000 ms
[1612 ms] success: EUR/USD 1.0842 (server saw 4 requests)
The caller wrote one line, client.rate("EURUSD"), and never saw a failure. Notice the third wait: the backoff alone would have waited well under a second, but the server asked for a full second, and the helper listened.
You'll build that helper from scratch, about 150 lines of plain Java with no libraries, plus a small HTTP client, a fake flaky server to test against, and 23 checks. It's for you if you call something over a network (a rates feed, a payments gateway, a message broker) and the occasional timeout or 503 currently turns into a page at 3 a.m. If you've written try/catch and used HttpClient once, you're set.
It's a read-only call on purpose. Retrying a write safely needs an idempotency key, and I'll say where that line is in the production section.
Before you start
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Gradle | 8.14.3 | gradle --version |
| Machine | 2 vCPUs, Linux | nproc |
| Libraries | none | n/a |
RandomGenerator needs Java 17 or later; HttpClient needs 11. Nothing here is specific to 21, so a 17 toolchain works if you change one line in the build file.
Two honest limits. My build environment couldn't reach Maven Central, so I ran Gradle with --offline (there's nothing to download anyway) and the tests are plain Java rather than JUnit; each check(...) line converts straight into a JUnit 5 @Test. And I didn't open IntelliJ IDEA or Eclipse, so the IDE steps come from the vendors' documentation and are marked.
Set up the project with Gradle, javac, IntelliJ IDEA or Eclipse
Gradle is the primary path this time. Make the folders and two build files:
mkdir -p rates/src/main/java/com/example/rates
cd rates
settings.gradle.kts:
rootProject.name = "rates"
build.gradle.kts:
plugins {
java
application
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
repositories { mavenCentral() } // nothing to download yet; JUnit joins in the testing section
tasks.withType<JavaCompile> {
options.compilerArgs.add("-Xlint:all")
}
application {
mainClass = providers.gradleProperty("main").orElse("com.example.rates.Main")
}
Two lines are doing quiet work. -Xlint:all makes the compiler complain about things like missing serialVersionUID, and I'd rather hear it now. The main property lets you pick which class gradle run starts, so one build file serves every demo in this article: gradle -q run -Pmain=com.example.rates.Checks. Without -P, it runs Main.
The project tree when you're done:
rates/
āāā build.gradle.kts
āāā settings.gradle.kts
āāā src/main/java/com/example/rates/
āāā Jitter.java
āāā RetryPolicy.java
āāā RetryHint.java
āāā TransientFailureException.java
āāā PermanentFailureException.java
āāā RetryExhaustedException.java
āāā Retrier.java
āāā RatesClient.java
āāā FlakyServer.java
āāā Main.java
āāā Herd.java
āāā Traps.java
āāā Checks.java
With plain javac
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.rates.Main
Silence from javac means a clean compile. I ran this too, and the 23 checks passed the same way.
In IntelliJ IDEA
Verified from the docs, not executed. File ā New ā Projectā¦, choose Java, set the build system to Gradle with Kotlin as the DSL and JDK 21, name it rates, and paste in the files. Open any class with a main method and click the green arrow in the gutter.
In Eclipse
Verified from the docs, not executed. Install Buildship if your Eclipse package doesn't include it, then File ā Import ā Gradle ā Existing Gradle Project and point it at the rates folder. Run a class with Run As ā Java Application.
Podman and Docker
Not needed. The "server" is com.sun.net.httpserver running inside the same JVM on a random local port.
Write the retry loop one decision at a time
A retry helper is five decisions. How long to wait. Which failures deserve another go. When to stop trying. What to do with the server's own advice. What to tell the caller at the end. Each step below is one of them.
Step 1: Decide how long to wait
Goal: a function from "this many attempts have failed" to "wait this long".
The vocabulary first. Exponential backoff means the wait doubles after each failure: 200 ms, 400, 800, and so on. Jitter means a random amount mixed in, so a thousand clients that failed together don't all come back together. A cap stops the doubling, because a wait of nine minutes helps nobody.
Make one tiny enum for the jitter styles. src/main/java/com/example/rates/Jitter.java:
package com.example.rates;
/** How much randomness to mix into each backoff delay. */
public enum Jitter {
/** Exactly the exponential delay. Every client that failed together retries together. */
NONE,
/** Anywhere between 0 and the exponential delay. */
FULL,
/** Half the exponential delay, plus a random amount up to the other half. */
EQUAL
}
Then the policy, which holds every number in one place. src/main/java/com/example/rates/RetryPolicy.java:
package com.example.rates;
import java.time.Duration;
import java.util.random.RandomGenerator;
/**
* The numbers behind a retry: how many attempts, how fast the delay grows, where it stops,
* how long the whole operation may take.
*/
public record RetryPolicy(int maxAttempts, Duration baseDelay, Duration maxDelay,
Jitter jitter, Duration totalTimeout) {
public RetryPolicy {
if (maxAttempts < 1) throw new IllegalArgumentException("maxAttempts must be >= 1: " + maxAttempts);
if (baseDelay.isNegative() || baseDelay.isZero()) throw new IllegalArgumentException("baseDelay must be positive");
if (maxDelay.compareTo(baseDelay) < 0) throw new IllegalArgumentException("maxDelay must be >= baseDelay");
if (totalTimeout.isNegative() || totalTimeout.isZero()) throw new IllegalArgumentException("totalTimeout must be positive");
}
/** 5 attempts, 200 ms base, 5 s cap, full jitter, 15 s overall. Reasonable for a read-only HTTP call. */
public static RetryPolicy standard() {
return new RetryPolicy(5, Duration.ofMillis(200), Duration.ofSeconds(5), Jitter.FULL, Duration.ofSeconds(15));
}
/** The wait before the next try, after {@code failedAttempt} attempts have failed (1 = the first). */
public Duration delayAfter(int failedAttempt, RandomGenerator random) {
long ceilingMs = ceilingMillis(failedAttempt);
long ms = switch (jitter) {
case NONE -> ceilingMs;
case FULL -> random.nextLong(ceilingMs + 1);
case EQUAL -> ceilingMs / 2 + random.nextLong(ceilingMs / 2 + 1);
};
return Duration.ofMillis(ms);
}
/** base * 2^(n-1), capped at maxDelay, without ever overflowing. */
long ceilingMillis(int failedAttempt) {
long base = baseDelay.toMillis();
long cap = maxDelay.toMillis();
int shift = Math.min(failedAttempt - 1, 30); // 2^30 * base is already past any sane cap
long grown = base << shift;
return (grown < 0 || grown > cap) ? cap : grown; // negative means the shift overflowed
}
}
The method to read slowly is ceilingMillis. The textbook formula is base * 2^(attempt-1), and written naively it blows up on a long enough run; the shift is capped at 30 and a negative result is treated as "past the cap". I'll show exactly what the naive version does in the traps section, because I got a surprise there.
FULL jitter picks anywhere between 0 and the ceiling. That's the variant I'd start with. EQUAL keeps at least half the ceiling, which is useful when a zero-millisecond wait would be rude to the server.
Checkpoint: compile with gradle --offline -q compileJava. No output is a clean build. If you see three [serial] warnings, they come from Step 3, not from this file.
Step 2: Sort failures into "retry" and "give up"
Goal: exceptions that say which kind of failure happened, so the retry loop doesn't have to guess.
Retrying a 404 is just a slower way to fail. So the code carries the distinction in the type: transient failures are retried, permanent ones are thrown straight back. A third exception, for running out of tries, keeps the history.
src/main/java/com/example/rates/RetryHint.java:
package com.example.rates;
import java.time.Duration;
/** Implemented by exceptions that carry the server's own advice on when to try again. */
public interface RetryHint {
Duration retryAfter();
}
src/main/java/com/example/rates/TransientFailureException.java:
package com.example.rates;
import java.time.Duration;
/** A failure that might not happen next time: 429, 502, 503, 504, a timeout. */
public class TransientFailureException extends RuntimeException implements RetryHint {
private static final long serialVersionUID = 1L;
private final Duration retryAfter;
public TransientFailureException(String message) {
this(message, null, null);
}
public TransientFailureException(String message, Duration retryAfter, Throwable cause) {
super(message, cause);
this.retryAfter = retryAfter;
}
@Override
public Duration retryAfter() {
return retryAfter; // null means "the server gave no advice"
}
}
src/main/java/com/example/rates/PermanentFailureException.java:
package com.example.rates;
/** A failure that will happen again however often you ask: 400, 401, 404. Never retried. */
public class PermanentFailureException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PermanentFailureException(String message) {
super(message);
}
}
src/main/java/com/example/rates/RetryExhaustedException.java:
package com.example.rates;
/** Thrown when the attempts or the time budget ran out. Earlier failures are attached as suppressed. */
public class RetryExhaustedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final int attempts;
public RetryExhaustedException(String message, int attempts, Throwable lastFailure) {
super(message, lastFailure);
this.attempts = attempts;
}
public int attempts() {
return attempts;
}
}
RetryHint is a one-method interface so the retry loop can read a server's "come back in five seconds" without knowing anything about HTTP. The serialVersionUID lines are there because my first version didn't have them and -Xlint:all printed three warnings.
Step 3: Write the loop
Goal: one call(task) method that runs the task, retries what it should, and stops when it must.
src/main/java/com/example/rates/Retrier.java:
package com.example.rates;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import java.util.function.Predicate;
import java.util.random.RandomGenerator;
/** Runs a task, and runs it again when it fails in a way {@code retryable} says is worth another go. */
public final class Retrier {
/** Pauses the current thread. A seam, so tests can skip the waiting. */
@FunctionalInterface
public interface Sleeper {
void sleep(Duration d) throws InterruptedException;
}
/** Called before each pause, for logging and metrics. */
@FunctionalInterface
public interface Listener {
void onRetry(int failedAttempt, Throwable failure, Duration delay);
}
private final RetryPolicy policy;
private final Predicate<Throwable> retryable;
private final Sleeper sleeper;
private final Supplier<RandomGenerator> random; // a supplier: ThreadLocalRandom must not be shared between threads
private final LongSupplier nanoClock;
private final Listener listener;
public Retrier(RetryPolicy policy, Predicate<Throwable> retryable, Sleeper sleeper,
Supplier<RandomGenerator> random, LongSupplier nanoClock, Listener listener) {
this.policy = policy;
this.retryable = retryable;
this.sleeper = sleeper;
this.random = random;
this.nanoClock = nanoClock;
this.listener = listener;
}
/** The everyday constructor: real sleeping, real clock, thread-local random, no listener. */
public Retrier(RetryPolicy policy, Predicate<Throwable> retryable) {
this(policy, retryable, d -> Thread.sleep(d), java.util.concurrent.ThreadLocalRandom::current,
System::nanoTime, (n, t, d) -> { });
}
public <T> T call(Callable<T> task) throws Exception {
long start = nanoClock.getAsLong();
Exception last = null;
for (int attempt = 1; ; attempt++) {
try {
return task.call();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} catch (Exception e) {
if (!retryable.test(e)) throw e; // permanent: stop now
if (last != null) e.addSuppressed(last); // keep the history
last = e;
if (attempt >= policy.maxAttempts()) {
throw new RetryExhaustedException(
"gave up after " + attempt + " attempts", attempt, e);
}
Duration delay = nextDelay(attempt, e);
Duration elapsed = Duration.ofNanos(nanoClock.getAsLong() - start);
if (elapsed.plus(delay).compareTo(policy.totalTimeout()) > 0) {
throw new RetryExhaustedException(
"gave up after " + attempt + " attempts: next wait of " + delay.toMillis()
+ " ms would pass the " + policy.totalTimeout().toMillis() + " ms budget",
attempt, e);
}
listener.onRetry(attempt, e, delay);
try {
sleeper.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ie;
}
}
}
}
private Duration nextDelay(int failedAttempt, Throwable failure) {
Duration backoff = policy.delayAfter(failedAttempt, random.get());
if (failure instanceof RetryHint hint && hint.retryAfter() != null) {
return backoff.compareTo(hint.retryAfter()) >= 0 ? backoff : hint.retryAfter(); // never sooner than asked
}
return backoff;
}
}
A few lines deserve a second look.
if (!retryable.test(e)) throw e; is the permanent-failure exit: it rethrows the original exception, not a wrapper, so the caller's catch (PermanentFailureException ...) still works.
e.addSuppressed(last) chains the failures together. When the loop finally gives up, the exception you get has the last failure as its cause and every earlier one hanging off it as suppressed, so the stack trace tells the whole story. (Suppressed exceptions are the same mechanism try-with-resources uses.)
The budget check is the one most tutorials leave out. Ten attempts with an 8-second cap can add up to over a minute, and a caller upstream may already have given up after ten seconds. Before sleeping, the loop adds the planned delay to the time already spent and refuses to start a wait it can't finish.
Last, InterruptedException. If someone cancels the thread while it's sleeping between attempts, you stop, you don't swallow it, and you set the interrupt flag back. The InterruptedException article covers why.
The Sleeper, the clock and the Supplier<RandomGenerator> are parameters so the tests can use a fake clock and finish instantly. In production the short constructor wires in real sleeping and ThreadLocalRandom::current. It's a supplier rather than a shared instance because ThreadLocalRandom mustn't be handed from thread to thread, and my first version did exactly that.
Checkpoint: gradle --offline -q compileJava prints nothing.
Step 4: Wire it to an HTTP client
Goal: a client that turns HTTP status codes into the right exception.
src/main/java/com/example/rates/RatesClient.java:
package com.example.rates;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.Set;
/** Fetches one exchange rate. Sorts every failure into "try again" or "don't bother". */
public final class RatesClient {
private static final Set<Integer> TRANSIENT = Set.of(408, 425, 429, 500, 502, 503, 504);
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
private final URI base;
private final Retrier retrier;
public RatesClient(URI base, Retrier retrier) {
this.base = base;
this.retrier = retrier;
}
public String rate(String pair) throws Exception {
return retrier.call(() -> fetchOnce(pair));
}
String fetchOnce(String pair) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(base.resolve("/rates/" + pair))
.timeout(Duration.ofMillis(800)) // per attempt; the policy has its own total budget
.GET().build();
HttpResponse<String> response;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
throw new TransientFailureException("timeout waiting for " + pair, null, e);
} catch (java.net.ConnectException e) {
throw new TransientFailureException("connection refused", null, e);
}
int status = response.statusCode();
if (status == 200) return response.body().strip();
if (TRANSIENT.contains(status)) {
throw new TransientFailureException("HTTP " + status, retryAfter(response), null);
}
throw new PermanentFailureException("HTTP " + status + " for " + pair);
}
/** Retry-After in its delta-seconds form. The HTTP-date form is left out to keep this short. */
private static Duration retryAfter(HttpResponse<?> response) {
return response.headers().firstValue("Retry-After").map(v -> {
try {
return Duration.ofSeconds(Long.parseLong(v.strip()));
} catch (NumberFormatException e) {
return null;
}
}).orElse(null);
}
}
The set of retryable statuses is deliberate: 408 (request timeout), 425, 429 (too many requests), and the 5xx trio of 500, 502, 503, 504. Everything else that isn't 200, such as 400, 401 and 404, becomes PermanentFailureException. Timeouts and refused connections are transient too. That's a judgement call about this API, so read your own provider's docs before copying the list.
There are two timeouts, and they do different jobs. .timeout(800 ms) bounds one attempt. The policy's totalTimeout bounds the whole operation including the waits. Without the per-attempt one, a hung server eats your budget on attempt one.
Retry-After can be a number of seconds or an HTTP date. I handled only the seconds form and say so in the comment. If your provider sends dates, parse them with DateTimeFormatter.RFC_1123_DATE_TIME.
Step 5: Build a server that fails on cue
Goal: something to test against that fails on a script instead of by luck.
src/main/java/com/example/rates/FlakyServer.java:
package com.example.rates;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/** A rates API that misbehaves on a script: "503", "429:1" (Retry-After 1), "slow", "404", "200". */
public final class FlakyServer implements AutoCloseable {
private final HttpServer server;
private final List<String> script;
private final AtomicInteger hits = new AtomicInteger();
private final ExecutorService pool = Executors.newCachedThreadPool();
public FlakyServer(List<String> script) throws IOException {
this.script = script;
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.server.createContext("/rates/", this::handle);
this.server.setExecutor(pool); // the default is ONE thread: a slow reply would block the retry behind it
this.server.start();
}
public URI uri() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
public int hits() {
return hits.get();
}
private void handle(HttpExchange ex) throws IOException {
int n = hits.getAndIncrement();
String step = script.get(Math.min(n, script.size() - 1)); // the last step repeats forever
try (ex) {
if (step.equals("slow")) {
try { Thread.sleep(2_000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
reply(ex, 200, "late");
} else if (step.startsWith("429:")) {
ex.getResponseHeaders().add("Retry-After", step.substring(4));
reply(ex, 429, "slow down");
} else {
int code = Integer.parseInt(step);
reply(ex, code, code == 200 ? "EUR/USD 1.0842" : "no");
}
}
}
private static void reply(HttpExchange ex, int code, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(code, bytes.length);
ex.getResponseBody().write(bytes);
}
@Override
public void close() {
server.stop(0);
pool.shutdownNow();
}
}
You give it a list such as List.of("503", "503", "200") and it answers request one with 503, request two with 503, and every later request with 200. "429:1" sends a 429 with Retry-After: 1, and "slow" sleeps two seconds, longer than the client's per-attempt timeout.
The setExecutor(pool) line is not decoration. I'll tell you what happened without it in the snags section.
Step 6: A driver to see it work
src/main/java/com/example/rates/Main.java:
package com.example.rates;
import java.time.Duration;
import java.util.List;
import java.util.Random;
public class Main {
public static void main(String[] args) throws Exception {
long t0 = System.nanoTime();
try (FlakyServer server = new FlakyServer(List.of("503", "503", "429:1", "200"))) {
RetryPolicy policy = new RetryPolicy(5, Duration.ofMillis(200), Duration.ofSeconds(5),
Jitter.FULL, Duration.ofSeconds(15));
Random seeded = new Random(42); // fixed seed so you get my delays
Retrier retrier = new Retrier(policy, e -> e instanceof TransientFailureException,
d -> Thread.sleep(d), () -> seeded, System::nanoTime,
(n, failure, delay) -> System.out.printf("[%4d ms] attempt %d failed: %s -> waiting %d ms%n",
(System.nanoTime() - t0) / 1_000_000, n, failure.getMessage(), delay.toMillis()));
RatesClient client = new RatesClient(server.uri(), retrier);
String rate = client.rate("EURUSD");
System.out.printf("[%4d ms] success: %s (server saw %d requests)%n",
(System.nanoTime() - t0) / 1_000_000, rate, server.hits());
}
}
}
The seeded Random(42) is there so your delays match mine. In real code you'd never seed it.
Checkpoint: run it.
gradle --offline -q run
[ 451 ms] attempt 1 failed: HTTP 503 -> waiting 49 ms
[ 515 ms] attempt 2 failed: HTTP 503 -> waiting 87 ms
[ 606 ms] attempt 3 failed: HTTP 429 -> waiting 1000 ms
[1612 ms] success: EUR/USD 1.0842 (server saw 4 requests)
Your delays of 49 and 87 ms should match mine exactly, because of the seed. The first timestamp (about half a second) is the server starting and HttpClient warming up, so yours will differ. If you see waiting 1000 ms on attempt 3, the Retry-After header made it through. If that wait is a few hundred milliseconds instead, the header was lost; check retryAfter(...) in RatesClient.
Watch it survive a flaky server
The real-HTTP part is the Main run above. Two more demos make the case for the two features people leave out.
Why jitter exists
Herd.java simulates 1,000 clients that all fail at the same instant and records when their retries land, in 10 ms buckets, on a virtual clock so it finishes immediately.
src/main/java/com/example/rates/Herd.java:
package com.example.rates;
import java.util.Random;
import java.util.TreeMap;
/** 1,000 clients all fail at t=0. When do their retries arrive? Virtual time, so it runs instantly. */
public class Herd {
public static void main(String[] args) {
for (Jitter jitter : new Jitter[]{Jitter.NONE, Jitter.FULL}) {
RetryPolicy p = new RetryPolicy(6, java.time.Duration.ofMillis(100), java.time.Duration.ofSeconds(10),
jitter, java.time.Duration.ofSeconds(60));
Random random = new Random(7);
TreeMap<Long, Integer> perBucket = new TreeMap<>(); // 10 ms buckets
for (int client = 0; client < 1_000; client++) {
long clock = 0;
for (int failed = 1; failed < p.maxAttempts(); failed++) {
clock += p.delayAfter(failed, random).toMillis();
perBucket.merge(clock / 10, 1, Integer::sum);
}
}
int worst = perBucket.values().stream().mapToInt(Integer::intValue).max().orElse(0);
System.out.printf("%-5s busiest 10 ms window: %,d retries (of 5,000 total)%n", jitter, worst);
}
}
}
gradle --offline -q run -Pmain=com.example.rates.Herd
NONE busiest 10 ms window: 1,000 retries (of 5,000 total)
FULL busiest 10 ms window: 159 retries (of 5,000 total)
Without jitter, every client retries in the same 10 ms window: all 1,000 at once, five times in a row. That's the "thundering herd", and it's how a server that was recovering gets knocked over again by the very clients waiting for it. With full jitter the busiest window gets 159.
One thing worth knowing: my first version of this demo used 100 ms buckets, and it reported FULL as worse (1,271 against 1,000). With a 100 ms base delay, the first retry wave is spread over 0 to 100 ms, which is exactly one bucket. The measurement was too coarse to see the spreading. Pick a bucket narrower than your base delay, or the numbers lie.
The traps
Traps.java runs the three mistakes I'd bet are in your codebase right now.
src/main/java/com/example/rates/Traps.java:
package com.example.rates;
import java.time.Duration;
import java.util.Random;
public class Traps {
public static void main(String[] args) throws Exception {
// 1. The delay formula everyone writes first
long baseMs = 200;
for (int attempt : new int[]{1, 5, 10, 40, 55, 56, 64}) {
System.out.printf("naive attempt %2d -> %,d ms%n", attempt, baseMs * (1L << attempt));
}
RetryPolicy p = RetryPolicy.standard();
for (int attempt : new int[]{1, 2, 3, 4, 5, 6, 40, 70, 1_000}) {
System.out.printf("capped attempt %4d -> ceiling %,d ms%n", attempt, p.ceilingMillis(attempt));
}
try {
Thread.sleep(baseMs * (1L << 56));
} catch (IllegalArgumentException e) {
System.out.println("Thread.sleep -> " + e);
}
// 2. Retrying a permanent failure
Retrier retrier = new Retrier(p, e -> true, d -> { }, () -> new Random(1), System::nanoTime, (n, t, d) -> { });
int[] calls = {0};
try {
retrier.call(() -> { calls[0]++; throw new PermanentFailureException("HTTP 404"); });
} catch (RetryExhaustedException e) {
System.out.println("retry-everything policy: " + e.getMessage() + " (calls=" + calls[0] + ")");
}
// 3. Interrupt
Retrier realSleeper = new Retrier(p, e -> e instanceof TransientFailureException);
Thread.currentThread().interrupt();
try {
realSleeper.call(() -> { throw new TransientFailureException("503"); });
} catch (InterruptedException e) {
System.out.println("interrupted during wait -> InterruptedException, flag restored=" + Thread.currentThread().isInterrupted());
}
}
}
gradle --offline -q run -Pmain=com.example.rates.Traps
naive attempt 1 -> 400 ms
naive attempt 5 -> 6,400 ms
naive attempt 10 -> 204,800 ms
naive attempt 40 -> 219,902,325,555,200 ms
naive attempt 55 -> 7,205,759,403,792,793,600 ms
naive attempt 56 -> -4,035,225,266,123,964,416 ms
naive attempt 64 -> 200 ms
capped attempt 1 -> ceiling 200 ms
capped attempt 2 -> ceiling 400 ms
capped attempt 3 -> ceiling 800 ms
capped attempt 4 -> ceiling 1,600 ms
capped attempt 5 -> ceiling 3,200 ms
capped attempt 6 -> ceiling 5,000 ms
capped attempt 40 -> ceiling 5,000 ms
capped attempt 70 -> ceiling 5,000 ms
capped attempt 1000 -> ceiling 5,000 ms
Thread.sleep -> java.lang.IllegalArgumentException: timeout value is negative
retry-everything policy: gave up after 5 attempts (calls=5)
interrupted during wait -> InterruptedException, flag restored=true
The overflow. base * (1L << attempt) is fine for five attempts and absurd by attempt 40 (that's about 7,000 years). At attempt 56 it goes negative, and at attempt 64 it wraps back to 200 ms, because shifting a long by 64 is the same as shifting by 0. Thread.sleep with the negative value throws. You'll only hit this with "retry forever" loops, which is exactly where you can least afford a surprise.
I made a mistake of my own while writing that demo. I first typed 1L << 64 + 1, meaning "64 plus one". Java gives + higher precedence than <<, so it shifted by 65, which is the same as shifting by 1, and Thread.sleep(400) ran without complaint. A demo of a bug that didn't show the bug.
Retrying everything. With e -> true as the predicate, a 404 is called five times. The result is the same failure, only slower, and the server logs five errors per bad request.
Interrupts. The last line shows the thread's interrupt flag still set after the helper threw. If your code catches InterruptedException and carries on, thread pools can't shut down.
Twenty-three checks, no waiting
JUnit wasn't reachable from my environment, so this is a small harness: PASS or FAIL per line, exit code 1 on any failure. The retry logic is tested on a fake clock, where "sleeping" just advances a counter, so those checks don't wait at all. The last three checks use the real HTTP server and real sleeping, with short delays.
src/main/java/com/example/rates/Checks.java:
package com.example.rates;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.atomic.AtomicLong;
/** Plain checks: no framework, exit code 1 on any failure. */
public class Checks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) passed++; else failed++;
System.out.println((ok ? "PASS " : "FAIL ") + name);
}
/** A Retrier on a fake clock: sleeping just moves the clock, so nothing actually waits. */
static final class Fake {
final AtomicLong nanos = new AtomicLong();
final List<Duration> sleeps = new ArrayList<>();
Retrier retrier(RetryPolicy policy, long seed) {
Random rnd = new Random(seed);
return new Retrier(policy, e -> e instanceof TransientFailureException,
d -> { sleeps.add(d); nanos.addAndGet(d.toNanos()); },
() -> rnd, nanos::get, (n, t, d) -> { });
}
}
static RetryPolicy noJitter(int attempts) {
return new RetryPolicy(attempts, Duration.ofMillis(100), Duration.ofSeconds(1), Jitter.NONE, Duration.ofSeconds(60));
}
public static void main(String[] args) throws Exception {
RetryPolicy p = noJitter(6);
check("delays double: 100,200,400", p.ceilingMillis(1) == 100 && p.ceilingMillis(2) == 200 && p.ceilingMillis(3) == 400);
check("delay stops growing at the cap (1000)", p.ceilingMillis(4) == 800 && p.ceilingMillis(5) == 1000 && p.ceilingMillis(6) == 1000);
check("huge attempt numbers don't overflow", p.ceilingMillis(1_000) == 1000 && p.ceilingMillis(64) == 1000);
check("NONE jitter returns the ceiling exactly", p.delayAfter(3, new Random(1)).toMillis() == 400);
Random r = new Random(3);
RetryPolicy full = new RetryPolicy(6, Duration.ofMillis(100), Duration.ofSeconds(1), Jitter.FULL, Duration.ofSeconds(60));
boolean inRange = true, varies = false; long first = full.delayAfter(4, r).toMillis();
for (int i = 0; i < 10_000; i++) {
long d = full.delayAfter(4, r).toMillis();
inRange &= d >= 0 && d <= 800;
varies |= d != first;
}
check("FULL jitter stays in [0, ceiling] over 10,000 draws", inRange);
check("FULL jitter actually varies", varies);
RetryPolicy equal = new RetryPolicy(6, Duration.ofMillis(100), Duration.ofSeconds(1), Jitter.EQUAL, Duration.ofSeconds(60));
boolean eqRange = true;
for (int i = 0; i < 10_000; i++) { long d = equal.delayAfter(4, r).toMillis(); eqRange &= d >= 400 && d <= 800; }
check("EQUAL jitter stays in [ceiling/2, ceiling]", eqRange);
// success after two failures
Fake f = new Fake();
int[] calls = {0};
String ok = f.retrier(p, 1).call(() -> { if (++calls[0] < 3) throw new TransientFailureException("503"); return "done"; });
check("succeeds on the third attempt", ok.equals("done") && calls[0] == 3);
check("slept 100 then 200 ms between attempts", f.sleeps.equals(List.of(Duration.ofMillis(100), Duration.ofMillis(200))));
// success first time: no sleeping
Fake f2 = new Fake();
check("no failure, no sleep", f2.retrier(p, 1).call(() -> "x").equals("x") && f2.sleeps.isEmpty());
// permanent failure
Fake f3 = new Fake(); int[] c3 = {0};
try { f3.retrier(p, 1).call(() -> { c3[0]++; throw new PermanentFailureException("404"); }); check("permanent propagates", false); }
catch (PermanentFailureException e) { check("permanent failure is thrown at once, 1 call, no sleep", c3[0] == 1 && f3.sleeps.isEmpty()); }
// exhaustion
Fake f4 = new Fake(); int[] c4 = {0};
try { f4.retrier(noJitter(4), 1).call(() -> { throw new TransientFailureException("503 #" + (++c4[0])); }); check("exhaustion throws", false); }
catch (RetryExhaustedException e) {
check("gives up after maxAttempts (4 calls, 3 sleeps)", c4[0] == 4 && f4.sleeps.size() == 3 && e.attempts() == 4);
check("last failure is the cause", e.getCause().getMessage().equals("503 #4"));
check("earlier failures hang off the cause as suppressed", e.getCause().getSuppressed().length == 1
&& e.getCause().getSuppressed()[0].getMessage().equals("503 #3"));
}
// Retry-After wins when longer, ignored when shorter
Fake f5 = new Fake(); int[] c5 = {0};
f5.retrier(noJitter(5), 1).call(() -> { if (++c5[0] < 3) throw new TransientFailureException("429", Duration.ofSeconds(5), null); return 1; });
check("Retry-After 5s beats a 100 ms backoff", f5.sleeps.get(0).equals(Duration.ofSeconds(5)));
Fake f6 = new Fake(); int[] c6 = {0};
f6.retrier(noJitter(5), 1).call(() -> { if (++c6[0] < 2) throw new TransientFailureException("429", Duration.ofMillis(10), null); return 1; });
check("a Retry-After shorter than the backoff is ignored", f6.sleeps.get(0).equals(Duration.ofMillis(100)));
// total budget
RetryPolicy tight = new RetryPolicy(10, Duration.ofSeconds(1), Duration.ofSeconds(8), Jitter.NONE, Duration.ofSeconds(5));
Fake f7 = new Fake(); int[] c7 = {0};
try { f7.retrier(tight, 1).call(() -> { c7[0]++; throw new TransientFailureException("503"); }); check("budget throws", false); }
catch (RetryExhaustedException e) {
check("stops before the 5 s budget would be passed (sleeps 1s+2s, refuses 4s)",
f7.sleeps.equals(List.of(Duration.ofSeconds(1), Duration.ofSeconds(2))) && c7[0] == 3 && e.getMessage().contains("budget"));
}
// interrupt
Retrier interruptible = new Retrier(p, e -> true, d -> { throw new InterruptedException("stop"); },
() -> new Random(1), System::nanoTime, (n, t, d) -> { });
try { interruptible.call(() -> { throw new TransientFailureException("503"); }); check("interrupt propagates", false); }
catch (InterruptedException e) { check("interrupt during sleep propagates and re-sets the flag", Thread.interrupted()); }
// policy validation
try { new RetryPolicy(0, Duration.ofMillis(1), Duration.ofMillis(1), Jitter.NONE, Duration.ofSeconds(1)); check("maxAttempts 0 rejected", false); }
catch (IllegalArgumentException e) { check("maxAttempts 0 rejected", true); }
try { new RetryPolicy(3, Duration.ofSeconds(2), Duration.ofSeconds(1), Jitter.NONE, Duration.ofSeconds(1)); check("cap below base rejected", false); }
catch (IllegalArgumentException e) { check("cap below base rejected", true); }
// real HTTP, real sleeping, scripted server
try (FlakyServer s = new FlakyServer(List.of("503", "503", "200"))) {
RetryPolicy fast = new RetryPolicy(5, Duration.ofMillis(20), Duration.ofMillis(200), Jitter.FULL, Duration.ofSeconds(5));
String body = new RatesClient(s.uri(), new Retrier(fast, e -> e instanceof TransientFailureException)).rate("EURUSD");
check("HTTP: two 503s then 200 returns the rate (3 hits)", body.equals("EUR/USD 1.0842") && s.hits() == 3);
}
try (FlakyServer s = new FlakyServer(List.of("404"))) {
RetryPolicy fast = new RetryPolicy(5, Duration.ofMillis(20), Duration.ofMillis(200), Jitter.FULL, Duration.ofSeconds(5));
try { new RatesClient(s.uri(), new Retrier(fast, e -> e instanceof TransientFailureException)).rate("XXXYYY"); check("404 throws", false); }
catch (PermanentFailureException e) { check("HTTP: 404 is not retried (1 hit)", s.hits() == 1); }
}
try (FlakyServer s = new FlakyServer(List.of("slow", "200"))) {
RetryPolicy fast = new RetryPolicy(3, Duration.ofMillis(20), Duration.ofMillis(200), Jitter.FULL, Duration.ofSeconds(10));
String body = new RatesClient(s.uri(), new Retrier(fast, e -> e instanceof TransientFailureException)).rate("EURUSD");
check("HTTP: a timed-out attempt is retried and succeeds", body.equals("EUR/USD 1.0842") && s.hits() == 2);
}
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
gradle --offline -q run -Pmain=com.example.rates.Checks
PASS delays double: 100,200,400
PASS delay stops growing at the cap (1000)
PASS huge attempt numbers don't overflow
PASS NONE jitter returns the ceiling exactly
PASS FULL jitter stays in [0, ceiling] over 10,000 draws
PASS FULL jitter actually varies
PASS EQUAL jitter stays in [ceiling/2, ceiling]
PASS succeeds on the third attempt
PASS slept 100 then 200 ms between attempts
PASS no failure, no sleep
PASS permanent failure is thrown at once, 1 call, no sleep
PASS gives up after maxAttempts (4 calls, 3 sleeps)
PASS last failure is the cause
PASS earlier failures hang off the cause as suppressed
PASS Retry-After 5s beats a 100 ms backoff
PASS a Retry-After shorter than the backoff is ignored
PASS stops before the 5 s budget would be passed (sleeps 1s+2s, refuses 4s)
PASS interrupt during sleep propagates and re-sets the flag
PASS maxAttempts 0 rejected
PASS cap below base rejected
PASS HTTP: two 503s then 200 returns the rate (3 hits)
PASS HTTP: 404 is not retried (1 hit)
PASS HTTP: a timed-out attempt is retried and succeeds
23 passed, 0 failed
Two of those are worth noticing. "Stops before the 5 s budget would be passed" proves the loop refuses a 4-second wait it can't finish: it sleeps 1 s and 2 s, then gives up after three calls. And the three HTTP checks cover a recovered 503, an un-retried 404, and a timed-out attempt that's retried to success.
In JUnit 5, each check(...) becomes an @Test with an AssertJ assertion, and the Fake class becomes a test helper. The JUnit 5 tutorial is the next stop for that move.
Why the delay is random, and why it's capped
Doubling gives a failing server room to recover: each wave of retries is half as dense as the last. Doubling alone doesn't separate clients from each other, though, which is the herd result above. Jitter does that. Full jitter's average wait is half the ceiling, so on average it also finishes sooner than the unjittered version. I didn't measure that, that comes from the arithmetic.
The cap exists for the person waiting. After six failures a pure doubling policy is waiting 6.4 seconds and heading for minutes. A cap at 5 seconds says "past this point, waiting longer doesn't help, so either it works or we stop". The attempt limit and the total timeout are the two ways to stop, and I use both: attempts for tidy logging, timeout because a caller upstream has its own deadline.
Notice what the helper doesn't do: it knows nothing about HTTP. It sees exceptions, a predicate and a policy. That's why Retrier works unchanged around a database call, a message publish or a file lock, and why the HTTP details live in RatesClient.
Snags from this build
FAIL HTTP: a timed-out attempt is retried and succeeds. My first run of the checks printed 22 passed, 1 failed. Cause: HttpServer uses a single thread by default. The "slow" handler sleeps two seconds on that thread, the client times out and retries after 800 ms, and the retry sits in the queue behind the sleeper. It timed out again. Fix: server.setExecutor(Executors.newCachedThreadPool()), which is the line in FlakyServer. The lesson isn't about the test server; a real service with one stuck worker thread behaves the same way, and your retries make the pile-up worse.
Three [serial] warnings from -Xlint:all. serializable class RetryExhaustedException has no definition of serialVersionUID, and the same for the other two exceptions. Exceptions are serializable, so the compiler wants a version id. Add private static final long serialVersionUID = 1L;.
The compile error you'll make with the policy constructor. Passing milliseconds as a bare number:
Bad.java:3: error: incompatible types: int cannot be converted to Duration
The constructor takes Duration everywhere on purpose. A long baseDelay is a bug waiting to happen (seconds or milliseconds?), and the compiler can catch it. Use Duration.ofMillis(200).
Thread.sleep throwing "timeout value is negative". Overflow from the naive delay formula; see the traps section.
Jitter looking worse than no jitter. Measurement bucket too wide, as described in the herd demo.
gradle run trying to download things. In my environment Maven Central was unreachable, so I used --offline. On your machine, drop the flag; with repositories { mavenCentral() } and no dependencies there's nothing to fetch either way.
The build itself took three compile-and-run rounds. The time went on the slow-server test and on working out why jitter looked worse than nothing.
Before this calls a real API
- Only retry what's safe to repeat. A GET is. A POST that charges a card is not, unless it carries an idempotency key the server uses to ignore duplicates. Most payment providers support one; check yours. Without it, a timeout followed by a retry can charge twice.
- Retry at one layer. If your HTTP client retries, your service retries, and the caller retries, three layers of three attempts each become 27 calls. Pick one place and make the others fail fast.
- Log every retry with the attempt number and delay. The
Listenerhook is for this. A retry that succeeds silently hides a dependency that's quietly degrading. - Don't retry forever. If the dependency is down for minutes, retries pile up. This is what a circuit breaker is for: after enough failures, stop calling for a while.
- Limit the retry rate. A budget such as "retries may be at most 10% of requests" stops retries from doubling load during an outage. See rate limiting in Java.
- Libraries exist. Resilience4j and Spring Retry do all of this with annotations and metrics. I'd still write it once by hand so you know what the knobs mean. I didn't test either library for this article.
- Version note.
RandomGeneratoris Java 17+. On Java 11, change the parameter tojava.util.Random. Everything else is unchanged. I only ran Java 21. - Virtual threads. On Java 21 a blocking
Thread.sleepbetween attempts is cheap on a virtual thread. I didn't run that variant.
What to remember
- Wait
base Ć 2^(nā1), cap it, and compute it without overflow. - Add jitter, so clients that failed together don't retry together.
- Retry transient failures, throw permanent ones back at once, and honour
Retry-Afterwhen it's longer than your backoff. - Give the whole operation a time budget, not only an attempt count, and restore the interrupt flag.
Retry and backoff questions people search
What is exponential backoff? A retry strategy where the wait doubles after each failure, usually with a maximum. In this article's policy it's 200 ms, 400, 800, 1,600 and so on, capped at 5 seconds.
Do I need jitter with exponential backoff? Yes, if more than one client can fail at the same moment. In my 1,000-client simulation, no jitter put all 1,000 retries in one 10 ms window; full jitter brought the busiest window down to 159.
Which HTTP status codes should I retry? Typically 408, 429, 500, 502, 503 and 504, plus timeouts and refused connections. Don't retry 400, 401, 403 or 404, since they'll fail the same way. Check your provider's documentation, because some APIs use 5xx for permanent problems.
How many retries is reasonable? Three to five attempts and a total time budget no longer than the caller will wait. The plan in this article is 5 attempts and 15 seconds.
Should I use a library like Resilience4j instead? For production, probably. Writing it once by hand is how you learn what its settings do. The logic here maps one-to-one onto them.