Retry
@Retry calls the method again when it fails, waiting a little longer before each new attempt.
| Annotation | Position | Return types | Defaults |
|---|---|---|---|
@Retry |
2 of 7, inside Fallback | sync and CompletionStage |
3 attempts, 200 ms exponential backoff ×2 up to 10 s, full jitter, retries Exception |
Use it when failures are transient and the operation is safe to repeat: a dropped connection, a 503, a lost race for a lock, an idempotent read or write.
Don't use it when the operation is not idempotent (charging a card twice is worse than failing once), when the failure is permanent (a validation error, a 404), or when the caller cannot afford the extra latency. Retrying an overloaded dependency makes it worse: put a circuit breaker inside the retry so that an open breaker stops the retries.
Minimal example
import dev.resilience4cata.annotation.Retry;
public interface InventoryApi {
@Retry(maxAttempts = 3, delay = "100ms")
int stock(String sku);
}
public final class InventoryApi$Resilient implements InventoryApi {
// …
public InventoryApi$Resilient(InventoryApi delegate, dev.resilience4cata.ResilienceRegistry registry) {
// …
this.retry0 = registry.retryPolicy("dev.resilience4cata.examples.docs.retry.InventoryApi#stock(java.lang.String)", "", "", r4c$b -> {
r4c$b.maxAttempts(3);
r4c$b.delay(java.time.Duration.ofNanos(100000000L));
});
}
@Override
public int stock(java.lang.String p0) {
final dev.resilience4cata.generated.RetryPolicy r4c$retry = this.retry0;
final long r4c$start = r4c$retry.start();
int r4c$attempts = 0;
while (true) {
try {
return this.delegate.stock(p0);
} catch (java.lang.Throwable r4c$e) {
final long r4c$delay = r4c$retry.onFailure(++r4c$attempts, r4c$e, r4c$start);
if (r4c$delay < 0 || !r4c$retry.pause(r4c$delay)) {
throw r4c$e;
}
}
}
}
}
Abridged: the real file uses fully qualified names. Yours is in build/generated/sources/annotationProcessor/java/main (Gradle) or target/generated-sources/annotations (Maven).
Wrap an implementation once, at startup, and use the wrapper everywhere:
ResilienceRegistry registry = ResilienceRegistry.builder().build();
InventoryApi inventory = Resilient.of(InventoryApi.class, backend, registry);
int units = inventory.stock("sku-1"); // up to 3 attempts
The generated method is a plain loop around your call. Nothing is allocated and no clock is read while calls succeed; the policy is consulted only after a failure.
How it behaves
flowchart TD
attempt["Call the method"] --> threw{"Did it throw?"}
threw -- no --> done["Return the result"]
threw -- yes --> retryable{"Retryable?<br/>in retryOn, not in abortOn, not an open breaker,<br/>attempts left, thread not interrupted"}
retryable -- no --> fail["Rethrow the last failure<br/>(or run the fallback)"]
retryable -- yes --> delay["Compute the delay:<br/>backoff, capped at maxDelay, then jitter"]
delay --> budget{"maxDuration set and<br/>elapsed + delay > maxDuration?"}
budget -- yes --> fail
budget -- no --> retryBudget{"Retry budget set<br/>and used up?"}
retryBudget -- yes --> fail
retryBudget -- no --> sleep["Wait for the delay"] --> attempt
- What is retried. An exception that is an instance of a
retryOntype (default:Exception) and of noabortOntype (default: none).abortOnwins overretryOn. ACallNotPermittedException(an open circuit breaker) is never retried, whatever the lists say. Anything else is rethrown at once, soErrors (OutOfMemoryError,AssertionError, …) are never retried by default. A method that returns normally is never retried, unless a classifier says its result is a failure. - Counting.
maxAttemptscounts every call, the first one included:maxAttempts = 3means at most two retries. - Delays. Before retry n (after n failed attempts), the base delay is
delaywithFIXEDbackoff, ordelay × multiplier^(n−1)capped atmaxDelaywithEXPONENTIAL. Jitter then randomizes it:FULLpicks a delay between 0 and the base delay,EQUALpicks between half of it and all of it,NONEkeeps it. With the defaults the base delays are 200 ms, 400 ms, 800 ms… up to 10 s. - Time budget. With
maxDuration, the retry gives up instead of waiting when the next attempt would start after the budget (time since the first attempt + the next delay >maxDuration). It never cuts an attempt short: for that, add@Timeout, which applies per attempt. - When it gives up, the last failure is rethrown as is: checked exceptions keep their type, and the stack trace is the one of the last attempt. With
@Fallbackon the same method, the fallback runs instead, once, with the number of attempts made. - Interrupts. A sync retry sleeps on the caller's thread. If that thread is interrupted, the retry stops at once and rethrows the last failure, with the interrupt flag set again. A failure that is an
InterruptedExceptionis not retried either. - Disabled (
enabled = "false", ordefaults.retry.enabled=false): one attempt, as if there were no annotation.
Attributes
| Attribute | Type | Default | Config key | Meaning |
|---|---|---|---|---|
maxAttempts |
int |
3 |
defaults.retry.maxAttempts |
Total attempts, the first one included. At least 1. |
delay |
duration | 200ms |
defaults.retry.delay |
Base delay before the first retry. "0" retries at once. |
backoff |
Backoff |
EXPONENTIAL |
defaults.retry.backoff |
FIXED: the same delay every time. EXPONENTIAL: multiplied by multiplier after each attempt. |
multiplier |
double |
2.0 |
defaults.retry.multiplier |
Growth factor for EXPONENTIAL. Finite, at least 1. |
maxDelay |
duration | 10s |
defaults.retry.maxDelay |
Cap on one exponential delay. Must not be shorter than delay, also with FIXED. |
jitter |
Jitter |
FULL |
defaults.retry.jitter |
NONE, FULL (0 to the delay) or EQUAL (half the delay plus a random half). |
maxDuration |
duration | 0 (no budget) |
defaults.retry.maxDuration |
Time budget for the whole operation, across attempts and delays. |
retryOn |
Class<? extends Throwable>[] |
{Exception.class} |
defaults.retry.retryOn |
Exceptions that are retried, subclasses included. |
abortOn |
Class<? extends Throwable>[] |
{} |
defaults.retry.abortOn |
Exceptions that stop the retries; wins over retryOn and the classifier. An open breaker stops them whether listed or not. |
budgetPercent |
double |
0 (no budget) |
defaults.retry.budgetPercent |
Retry budget: retries allowed, in percent of the instance's calls in the last budgetWindow. Finite, at least 0; above 0 turns the budget on. |
budgetMinRetriesPerSecond |
int |
10 |
defaults.retry.budgetMinRetriesPerSecond |
Retry budget: retries allowed per second of budgetWindow, whatever the traffic, once budgetPercent turns the budget on. At least 0. |
budgetWindow |
duration | 10s |
defaults.retry.budgetWindow |
Retry budget: how far back calls and retries are counted. From 1s to 1h. |
classifier |
Class<?> |
none | annotation only | A Classifier<T> that decides what is retried, results included. Replaces retryOn. See Classifiers. |
onExhaustedResult |
ExhaustedResult |
RETURN |
annotation only | With a classifier: return the last rejected result, or THROW a ResultRejectedException. |
name |
String |
one instance per method | annotation only | Instance id; methods with the same name share one retry policy. |
config |
String |
default |
annotation only | Named configuration to start from. |
locked |
boolean |
false |
annotation only | Keeps the values set in the annotation: instances.<id>.* keys cannot change them. See Locking an instance. |
enabled |
String |
"true" |
defaults.retry.enabled |
"" (inherit), "true" or "false". |
- Durations are strings:
"250ms","2s","1m","PT2S", or"0". They are checked when you compile. - An attribute you do not set keeps the value of the named config, then of the defaults. Every key also exists under
configs.<name>.retry.…andinstances.<id>.retry.…; see Configuration. - In properties files, enums are case-insensitive (
backoff=fixed) and exception lists are comma-separated class names.
The same retry with exception lists and a time budget:
@Retry(maxAttempts = 5, delay = "500ms", maxDelay = "2s", maxDuration = "4s",
retryOn = IOException.class, abortOn = FileNotFoundException.class)
byte[] download(String path) throws IOException;
With jitter = NONE, a download that keeps throwing IOException is called at 0 s, 0.5 s, 1.5 s and 3.5 s, then gives up: one more attempt after a 2 s delay would start after the 4 s budget. A FileNotFoundException is an IOException too, but abortOn wins, so it fails after one call. An IllegalStateException is not in retryOn, so it fails after one call as well.
A list you write in the annotation replaces the default list, it does not add to it.
A call rejected by a circuit breaker is never retried, whatever abortOn, retryOn or a classifier say: this abort is built in, not an entry of abortOn, so a list you write cannot drop it. Here abortOn lists only IllegalArgumentException:
@Retry(maxAttempts = 5, delay = "1s", abortOn = IllegalArgumentException.class)
@CircuitBreaker(windowSize = 2, minimumCalls = 2, waitInOpen = "1m")
String report(String id);
If report keeps failing, the first two attempts open the breaker, the third gets CallNotPermittedException, and the retry gives up there (GaveUp with NOT_RETRYABLE, after 3 attempts) instead of waiting through two more delays. While the breaker stays open, each call makes one attempt and waits for nothing.
Sharing and instance ids
Without name, every method gets its own retry policy, with the id <interface>#<method>(<parameter types>), for example dev.resilience4cata.examples.docs.retry.InventoryApi#stock(java.lang.String). That id is what you use in instances.<id>.retry.… overrides and what events and snapshots report.
Without a retry budget, a retry keeps no state between calls (the attempt count lives in a local variable of the generated method), so sharing one by name only shares its configuration. With a budget, it also shares the budget. Methods that share a name must resolve to the same configuration, or wrapping fails with R4C-REG-008.
There is no handle for a retry: registry has no retry(id) method. registry.snapshot().retries() lists each one as a RetrySnapshot(instance, config, budget), with its resolved configuration and, if it has a retry budget, the budget's current window (null otherwise).
Retry budget
maxAttempts caps the retries of one call. A retry budget caps the retries of every call of one instance together, relative to its traffic: retries in the last budgetWindow may not exceed
budgetMinRetriesPerSecond × budgetWindow in seconds + budgetPercent % of the calls in budgetWindow
Use it when many callers share a dependency that can go down. Without a budget, every caller retries every failure: with maxAttempts = 3 an outage triples the load on a dependency that is already failing (a retry storm). With budgetPercent = 20, it adds at most 20%. While the dependency is healthy, failures are rare and the budget is never reached. The design follows Finagle's RetryBudget (a percentage of the requests plus a per-second reserve over a window); gRPC and Envoy have similar limits.
@Retry(name = "catalog", maxAttempts = 3, delay = "100ms",
budgetPercent = 20, budgetMinRetriesPerSecond = 1)
String product(String id);
Here, in any 10 s (the default budgetWindow), the catalog retry allows 10 retries plus one for every five calls, shared by every method with name = "catalog". Once they are used, a failure is not retried: the last failure is rethrown (or the fallback runs) and GaveUp is published with reason BUDGET_EXHAUSTED. Every other rule still applies first, so a failure that would not be retried anyway takes nothing from the budget.
- Off by default:
budgetPercentabove 0 turns the budget on. It then comes with a reserve of 10 retries per second (budgetMinRetriesPerSecond, the default of Finagle's retry budget), so that a method called rarely can still retry. Setting the reserve to 0 leaves the percentage alone: withbudgetPercent = 20and no reserve, a method called four times in the window cannot retry at all. - What counts: calls, not attempts. A call is counted when it starts, whether it succeeds or not; a retry is counted when it is granted.
- Sharing: one budget per instance, so give the methods that call the same dependency the same
name(and the same attributes, as for any shared instance). Withoutname, each method has its own budget. - Same everywhere: sync and async methods, methods with a classifier (a rejected result that would be retried takes from the budget too) and
Pipeline(.retry(r -> r.budgetPercent(20).budgetMinRetriesPerSecond(1))). - The window moves forward a tenth of
budgetWindowat a time, and only failures move it, so successes read no clock. The time of a call made while nothing failed is therefore not known: it is dated at the last failure before it. After a quiet period longer than the window, those calls have all expired, and the budget starts again from its reserve plus the calls that follow, erring on the side of fewer retries. - Snapshot:
RetrySnapshot.budget()is aBudget(calls, retries, allowedRetries)for the current window.
On a ManualClock, a dependency that is down shows the cap exactly:
ManualClock clock = new ManualClock();
List<RetryEvent.GaveUp> gaveUp = new CopyOnWriteArrayList<>();
ResilienceRegistry registry = ResilienceTesting.registry(clock)
.listen(RetryEvent.GaveUp.class, gaveUp::add)
.build();
AtomicInteger attempts = new AtomicInteger();
CatalogApi catalog = Resilient.of(CatalogApi.class, id -> {
attempts.incrementAndGet();
throw new IllegalStateException("catalog down");
}, registry);
for (int i = 0; i < 100; i++) {
assertThatThrownBy(() -> catalog.product("p-1")).isInstanceOf(IllegalStateException.class);
}
assertThat(attempts).hasValue(130); // 100 calls + 10 (1/s over 10 s) + 20 (20% of 100), not 300
assertThat(gaveUp).filteredOn(e -> e.reason() == RetryEvent.Reason.BUDGET_EXHAUSTED).hasSize(95);
assertThat(registry.snapshot().retries().getFirst().budget())
.isEqualTo(new RetrySnapshot.Budget(100, 30, 30)); // calls, retries, allowed retries
The first five calls get both their retries; from the sixth on, the budget allows one retry for every five calls.
What it costs. Without a budget, nothing changes: the generated code is the same and start() reads one field, as before. With one, each call adds one plain read-and-write on a counter striped by a hash of the thread id, each stripe on its own cache line, so that concurrent callers do not write to a shared line; no clock is read and nothing is allocated. The count is approximate on purpose: two threads that share a stripe and race can lose a call, which only makes the budget stricter. A failure that would be retried reads the clock and sums the stripes, and a granted retry adds one compare-and-set on the shared retry count.
Measured AWS
c7g.xlarge(Graviton3), 1 / 4 threads: a successful call costs 3.5 / 3.6 ns with a budget against 1.3 / 1.3 ns without one; a call retried once, 62 / 301 ns against 6.9 / 7.1 ns. From Retry budget, 0.3.0.
With other policies
Retry is the second layer, inside Fallback and around everything else (see the overview):
- Each attempt goes through the circuit breaker, the rate limiter, the timeout and the bulkhead again. Each attempt is recorded by the breaker and has its own timeout.
- An open breaker throws
CallNotPermittedException, which is never retried, whateverabortOnsays: the retry stops at once instead of waiting for nothing (see above). - Rejections by the inner policies (
RateLimitExceededException,CallTimeoutException,BulkheadFullException) areExceptions, so they are retried by default, after the backoff delay. Add them toabortOnif a rejection should fail fast. - The bulkhead slot is held only while an attempt runs, not during the delay between attempts.
- With
@Fallback, the fallback runs once, after the last attempt, and itsFallbackContext.attempts()tells how many were made.
Async
A method that returns CompletionStage<T> or CompletableFuture<T> gets the non-blocking form:
@Retry(maxAttempts = 3, delay = "100ms")
CompletableFuture<Integer> stock(String sku);
The first attempt runs on the caller's thread. A failed stage is retried after the delay without blocking any thread: the next attempt starts on a new virtual thread. The returned future completes with the first successful result or fails with the last failure, unwrapped from CompletionException. Cancelling the returned future cancels the attempt in flight or the pending backoff, and starts no further attempt (cancellation). See Async methods.
Same thing with Pipeline
For code you cannot annotate, the programmatic API builds the same retry from the registry:
Pipeline<Integer> stock = Pipeline.builder(registry, "inventory", Integer.class)
.retry(r -> r.maxAttempts(3).delay(Duration.ofMillis(100)))
.build();
int units = stock.execute(() -> backend.stock("sku-1"));
The pipeline's id ("inventory") is the instance id. retry() uses the defaults, retry("name") a named config, and retry(r -> …) overrides on top of them.
Events and snapshot
| Event | When | Fields |
|---|---|---|
RetryEvent.Retrying |
A failed attempt will be retried | instance, nanoTime, attempt, delay, failure (null for a rejected result) |
RetryEvent.GaveUp |
No more attempts | instance, nanoTime, attempts, reason, failure |
GaveUp.reason() is MAX_ATTEMPTS, MAX_DURATION, NOT_RETRYABLE (not in retryOn, in abortOn, or an open breaker), INTERRUPTED, CLASSIFIER_FAILED or BUDGET_EXHAUSTED (the retry budget is used up). Successful calls publish nothing. Listeners are registered on the registry builder; see Observability.
Testing it with ManualClock
The testkit replaces the clock and the sleeps, so a retry with real delays runs at once and deterministically:
ManualClock clock = new ManualClock();
ResilienceRegistry registry = ResilienceTesting.registry(clock)
.retryDefaults(r -> r.jitter(RetryConfig.Jitter.NONE)) // exact delays in this test
.build();
AtomicInteger calls = new AtomicInteger();
InventoryApi backend = sku -> {
if (calls.incrementAndGet() <= 2) {
throw new IllegalStateException("inventory down");
}
return 7;
};
InventoryApi inventory = Resilient.of(InventoryApi.class, backend, registry);
assertThat(inventory.stock("sku-1")).isEqualTo(7); // returns at once, in real time
assertThat(calls).hasValue(3);
assertThat(clock.elapsed()).isEqualTo(Duration.ofMillis(300)); // 100 ms, then 200 ms
ResilienceTesting.registry(clock) also seeds the jitter's random generator, so even with jitter on, delays are the same on every run. Turning jitter off in the test's defaults, as here, makes them exact.
Small print
maxDelaymust not be shorter thandelay, also withFIXEDbackoff, where it is otherwise unused.@Retry(delay = "20s", backoff = Backoff.FIXED)alone fails when you wrap the interface (R4C-REG-003), because the defaultmaxDelayis 10 s: setmaxDelay = "20s"too.multiplieris ignored withFIXEDbackoff.maxDurationdecides before each delay; it does not interrupt a running attempt. Combine it with@Timeout.- Only interface annotations count with
Resilient.of, and calls the implementation makes to itself (this.stock(…)) are not retried. With annotated classes they are. - The sync form blocks the caller's thread during delays (cheap on a virtual thread). Use the async form to free it.
- Related errors:
R4C-ATR-001(duration syntax),R4C-ATR-003(maxAttemptsbelow 1),R4C-ATR-004(multiplier),R4C-ATR-006(enabled),R4C-ATR-007(retry budget values),R4C-CLS-011(classifierwithretryOn),R4C-CLS-012(onExhaustedResultwithout a classifier),R4C-REG-001/R4C-REG-003(invalid combination),R4C-REG-004(unknownconfig),R4C-REG-008(sharedname, different config).
What it costs
Measured On AWS Graviton3 (
c7g.xlarge, JDK 21), a successful call through@Retrycosts 1.2 ns and allocates nothing, against 22.9 ns for Resilience4j 2.4.0; a call that fails once and succeeds on the retry costs 6.5 ns, 0 B, against 61.6 ns, 16 B (Resilience4j measured without its unconditionalThread.sleep(0)). Benchmarks: MVP completion