> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Implement Retry With Exponential Backoff in Java
- URL: https://www.ggorantala.dev/how-to-implement-retry-exponential-backoff-java/
- Published: 2026-02-18T05:57:00.000Z
- Updated: 2026-10-31T06:34:46.000Z
- Description: Build a retry helper in plain Java 21: exponential backoff, full jitter, Retry-After, a time budget and tests, run against a deliberately flaky HTTP server.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, backend-patterns, java-tutorials

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:

```log
[ 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:

```bash
mkdir -p rates/src/main/java/com/example/rates
cd rates

```

`settings.gradle.kts`:

```kotlin
rootProject.name = "rates"

```

`build.gradle.kts`:

```kotlin
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:

```log
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

```bash
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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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](https://ggorantala.dev/how-to-use-try-resources-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev) 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`:

```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`:

```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`:

```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.

```bash
gradle --offline -q run

```

```log
[ 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`:

```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);
        }
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.rates.Herd

```

```log
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`:

```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());
        }
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.rates.Traps

```

```log
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`:

```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);
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.rates.Checks

```

```log
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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) 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:

```log
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 `Listener` hook 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](https://ggorantala.dev/how-to-implement-circuit-breaker-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-implement-rate-limiting-java/?ref=ggorantala.dev).
- **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.** `RandomGenerator` is Java 17+. On Java 11, change the parameter to `java.util.Random`. Everything else is unchanged. I only ran Java 21.
- **Virtual threads.** On Java 21 a blocking `Thread.sleep` between 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-After` when 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.

## References

- [java.util.random.RandomGenerator, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/random/RandomGenerator.html?ref=ggorantala.dev)
- [java.net.http.HttpClient, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.html?ref=ggorantala.dev)
- [com.sun.net.httpserver, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/com/sun/net/httpserver/package-summary.html?ref=ggorantala.dev)
- [RFC 9110, section 10.2.3: Retry-After](https://www.rfc-editor.org/rfc/rfc9110?ref=ggorantala.dev#section-10.2.3)
- [Gradle application plugin](https://docs.gradle.org/8.14.3/userguide/application%5Fplugin.html?ref=ggorantala.dev)