Fallbacks & Composition

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A fallback answers when a protected call fails, and composition decides which patterns see that failure first. Both are easy to get subtly wrong: a fallback in the wrong place hides failures from the pattern outside it, and an order nobody chose changes what the circuit breaker counts. This page covers the exact rules that resilience4j-spring6 applies to fallbackMethod, fallbacks for reactive and asynchronous return types, what a good fallback looks like, and how to combine Retry, CircuitBreaker, RateLimiter, TimeLimiter and Bulkhead either explicitly with Decorators or through the annotations and their aspect order. It was written against Resilience4j 2.4.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026: every rule below was run, not only read. The fallback and aspect classes live in resilience4j-spring6, which both resilience4j-spring-boot4 and resilience4j-spring-boot3 use, so everything here behaves identically on Spring Boot 3.5.x (the same tests were run there); only the starter artifact and its Java package differ. The individual patterns are in Circuit Breaker and Retry; the annotation mechanics (proxies, self-invocation) are in Annotations & Aspect Order.

The examples were run in a test project where the remote client is replaced by a stub that counts its invocations and throws whatever the test asks for; the snippets show the Bookshelf client (pricing.fetch(isbn)) where the stub stands, and nothing else was changed.

In the running Bookshelf scenario the price of a book comes from the slow and flaky pricing-service. The call is protected by a circuit breaker and a retry, and when the price cannot be fetched the page shows the last known price, marked as stale, instead of an error.

For a rate limiter whose state lives in a shared store, the fallback question becomes whether to fail open or fail closed when that store is down; that choice is covered for Bucket4j in Resilience4j versus Bucket4j.

Fallback semantics

The fallback mechanism works like a try/catch block around one protected call. When a fallbackMethod is configured, every exception thrown inside that annotation’s execution is handed to a fallback executor, which looks for the method that best matches the exception, much as a catch clause is chosen. Three consequences follow from the source (FallbackExecutor and DefaultFallbackDecorator) and were confirmed by running them:

  • The breaker state does not matter. An exception thrown by your method and a CallNotPermittedException thrown by an open circuit breaker take the same path: both reach the fallback. After the breaker has opened, the fallback answers without the remote call being made (in the run, the backend invocation count did not change while the circuit was open).

  • Failures are recorded first. The fallback runs outside the pattern’s own execution, so the circuit breaker still records the failure that triggered it. A fallback hides the failure from the caller, not from the metrics.

  • Everything is caught, including exceptions the pattern ignores. An exception listed under the breaker’s ignoreExceptions is neither a success nor a failure for the breaker (in the run, the window stayed empty), but it still reaches the fallback. With a fallback parameter typed Throwable, even an Error such as AssertionError is caught; with Exception-typed parameters an Error passes through untouched.

The Bookshelf price with a circuit breaker and fallbacks
@Service
public class PricingService {
    private final PricingClient pricing;

    public PricingService(PricingClient pricing) { this.pricing = pricing; }

    @CircuitBreaker(name = "pricing", fallbackMethod = "lastKnownPrice")
    public Money price(String isbn) {
        return pricing.fetch(isbn);
    }

    // Same class, same parameters as price(String), plus one trailing exception parameter.
    private Money lastKnownPrice(String isbn, CallNotPermittedException e) {
        return new Money(isbn, 1999, true);            // breaker open: serve the cached price
    }

    private Money lastKnownPrice(String isbn, IOException e) {
        return new Money(isbn, 1999, true);            // I/O problem: same degraded answer
    }

    private Money lastKnownPrice(String isbn, Exception e) {
        return new Money(isbn, 0, true);               // anything else: no price known, flagged stale
    }
}

Source: Resilience4j: Spring Boot 2 & 3 (the "Fallback methods" section) and the FallbackExecutor source at v2.4.0. Run on Resilience4j 2.4.0: a healthy call returns the live price, an IOException returns the cached price, any other exception returns the unknown price, and once the breaker is open CallNotPermittedException selects the first method.

All six annotations (@CircuitBreaker, @Retry, @RateLimiter, @Bulkhead, @TimeLimiter and @Timer) have a fallbackMethod attribute, and each one resolves it independently of the others. The attribute is also resolved by the SpEL resolver, so a placeholder such as ${bookshelf.fallback.name} works (the run resolved it from a property). It is the position of the annotation that carries the fallback that decides what the other patterns see; the composition section below returns to it.

Method-resolution rules

FallbackMethod finds the candidates by reflection once per annotated method and target class and caches them. The Javadoc of the class and readme.io both describe the intent, "same signature plus an exception"; the source is stricter and richer than either. The table lists each rule with the outcome observed when running it against Resilience4j 2.4.0.

Rule What the source does Observed result

The fallback lives in the same class as the annotated method, or in one of its superclasses

ReflectionUtils.doWithMethods walks the target class and its superclasses; visibility does not matter

A private method and a method inherited from an abstract base class were both found. A fallback in an unrelated class is never searched (from the source).

The name matches fallbackMethod

method.getName().equals(name), after the attribute has been through the SpEL resolver

A placeholder was resolved from the environment.

The parameters are those of the annotated method, in the same order, plus one trailing Throwable subtype

Each original parameter type is compared with !=: it must be exactly the same type, not a supertype

A fallback taking Integer or CharSequence instead of String was ignored.

A fallback with only the exception parameter matches any annotated method

A single parameter that is a Throwable subtype passes the filter whatever the original parameters are

One fbShared(Throwable) served a method with one parameter and a method with two. This is the "global fallback" of the readme.io page: global to the class, not to the application.

The return type is assignable to the original return type

originalReturnType.isAssignableFrom(fallbackReturnType)

A fallback returning String for a CharSequence method works; one returning Object for a String method was ignored.

The closest exception type wins

The thrown exception’s class is looked up, then its superclass, and so on up to Object, among the methods' last parameter types

NumberFormatException chose the NumberFormatException method, IllegalArgumentException its own, any other RuntimeException the Exception method, and the open breaker’s CallNotPermittedException its own.

With several candidates only superclasses are searched

Only getSuperclass() is followed, so a candidate whose parameter is an interface type is never chosen by closeness (a lone candidate is tested with isAssignableFrom, so any assignable type matches)

From the source; not run.

With a single candidate that does not match the exception, the original exception is rethrown

fallback compares the one parameter type with isAssignableFrom and rethrows otherwise

A lone NumberFormatException fallback let an IllegalStateException through unchanged.

No matching method at all disables the fallback

NoSuchMethodException is caught, a warning No fallback method match found is logged, and the call runs without a fallback

The original exception reached the caller; the only trace is a WARN log line.

Two methods for the same exception type with different parameter lists is an error

merge throws IllegalStateException: You have more that one fallback method that cover the same exception type

It was thrown on every call, even with a healthy backend and before the backend was called.

An exception thrown by the fallback replaces the original

InvocationTargetException is unwrapped and its cause rethrown

The caller saw the fallback’s IllegalStateException, and the original exception was lost (keep it as the cause).

A non-private fallback is invoked on the proxy

getTarget returns the target only for private methods or JDK proxies, otherwise the Spring proxy

A package-private fallback annotated with @CircuitBreaker created its own breaker, so its annotations apply.

Two practical lessons come out of the table. First, the signature rule is exact, so an almost-right fallback fails silently into a log warning: check the warning, and prefer testing the fallback with the breaker forced open (see Testing). Second, the ambiguity error is thrown while resolving, so a single mistaken overload breaks every call of the method, including the successful ones.

A fallback chosen by the closest exception type, and an exception-only fallback
@CircuitBreaker(name = "closest", fallbackMethod = "fbClosest")
public String closest(String isbn) { return backend.call(); }

String fbClosest(String isbn, NumberFormatException e) { return "NumberFormatException"; }
String fbClosest(String isbn, IllegalArgumentException e) { return "IllegalArgumentException"; }
String fbClosest(String isbn, Exception e) { return "Exception"; }
String fbClosest(String isbn, CallNotPermittedException e) { return "CallNotPermittedException"; }

// one fallback for two methods with different parameters
@CircuitBreaker(name = "shared1", fallbackMethod = "fbShared")
public String shared1(String isbn) { return backend.call(); }

@CircuitBreaker(name = "shared2", fallbackMethod = "fbShared")
public String shared2(String isbn, int page) { return backend.call(); }

private String fbShared(Throwable t) { return "shared:" + t.getClass().getSimpleName(); }

A cross-cutting fallback for several classes does not exist: there is no application-wide fallback registry. Share one with a common superclass (the inherited-method case above), or move to the explicit style of the next sections.

Fallbacks for reactive and asynchronous types

The fallback executor picks a FallbackDecorator from the annotated method’s return type. resilience4j-spring6 ships decorators for CompletionStage, for Reactor’s Mono and Flux (registered when Reactor is on the classpath) and for the RxJava 2 and RxJava 3 types (registered when RxJava is on the classpath); any other return type uses the plain try/catch decorator. In every case the fallback must return the same kind of type as the annotated method: a Mono fallback for a Mono method, a CompletableFuture for a CompletableFuture method.

Fallbacks for Mono, Flux and CompletableFuture
@CircuitBreaker(name = "mono", fallbackMethod = "monoFallback")
public Mono<Money> monoPrice(String isbn) {
    return pricing.fetchReactive(isbn);
}
private Mono<Money> monoFallback(String isbn, CallNotPermittedException e) { return Mono.just(new Money(isbn, 1999, true)); }
private Mono<Money> monoFallback(String isbn, Exception e) { return Mono.just(new Money(isbn, 0, true)); }

@CircuitBreaker(name = "flux", fallbackMethod = "fluxFallback")
public Flux<String> reviews(String isbn) { return reviewsClient.stream(isbn); }
private Flux<String> fluxFallback(String isbn, Exception e) { return Flux.empty(); }

@CircuitBreaker(name = "future", fallbackMethod = "futureFallback")
public CompletableFuture<Money> futurePrice(String isbn) { return pricing.fetchAsync(isbn); }

private CompletableFuture<Money> futureFallback(String isbn, CallNotPermittedException e) {
    return CompletableFuture.completedFuture(new Money(isbn, 1999, true));
}
private CompletableFuture<Money> futureFallback(String isbn, Exception e) {
    // e is the cause of the failure, not the CompletionException that wraps it
    return CompletableFuture.completedFuture(new Money(isbn, 0, true));
}

Source: Resilience4j: Spring Boot 2 & 3 and the fallback decorators at v2.4.0. Run on Resilience4j 2.4.0 with resilience4j-reactor on the classpath: each fallback answered when the call failed and again, without a backend call, once the breaker was open.

The decorators differ in one detail that matters when a method fails before it returns its reactive or asynchronous value:

Return type Failure inside the returned value Exception thrown while the method itself runs

Mono, Flux

onErrorResume calls the fallback

The fallback is not called. The decorator only wraps the value that the method returned, so a synchronous throw escapes as the original exception (observed: IllegalStateException reached the caller).

CompletableFuture and other CompletionStage

The decorator unwraps CompletionException and ExecutionException and passes the cause to the fallback

The aspect turns the throw into a failed stage, so the fallback is called (observed: fallback answered).

RxJava 2 and 3 types

onErrorResumeNext calls the fallback (from the source; not run for this page)

As for Reactor, wrapped value only (from the source)

So with Reactor, keep the method free of work that can throw before it returns the Mono or Flux (build the pipeline lazily with Mono.defer or Mono.fromSupplier), or catch the synchronous error yourself. Reactor and RxJava specifics, including the operators that apply the patterns without annotations, are in Reactive, Kotlin & RxJava; the programming model behind Mono and Flux is in Reactive Programming.

What a good fallback is

A fallback is a promise that the caller gets something useful when the real answer is not available. Four kinds cover almost every case; pick by how much a wrong or missing answer costs.

Kind Use it when Bookshelf example

Cached value

A slightly old answer is better than none

The last known price, flagged stale, from a local cache or the last successful response.

Default

The feature is optional and a neutral value is correct

An empty list of recommendations.

Degraded response

Part of the answer is still available

The average rating without the review texts.

Fail fast

There is no honest answer and a wrong one is worse than none

A clear PriceUnavailableException that the controller maps to 503 with a Retry-After.

The four kinds of fallback in the Bookshelf
// cached value, or fail fast when there is nothing to serve
@CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice")
public Money price(String isbn) {
    Money fresh = pricing.fetch(isbn);
    lastKnown.put(isbn, fresh);
    return fresh;
}

private Money cachedPrice(String isbn, Exception e) {
    Money cached = lastKnown.get(isbn);
    if (cached == null) {
        throw new PriceUnavailableException(isbn, e);       // fail fast: a clear, mappable error
    }
    return new Money(isbn, cached.cents(), true);           // flagged as stale
}

// default for an optional feature
@CircuitBreaker(name = "recommendations", fallbackMethod = "noRecommendations")
public List<String> recommendations(String isbn) { return recommendationsClient.similar(isbn); }

private List<String> noRecommendations(String isbn, Exception e) { return List.of(); }

// degraded response: the rating without the review texts
@CircuitBreaker(name = "reviews", fallbackMethod = "ratingOnly")
public Reviews reviews(String isbn) { return reviewsClient.fetch(isbn); }

private Reviews ratingOnly(String isbn, Exception e) { return new Reviews(4.5, List.of(), true); }

Source: Resilience4j: Spring Boot 2 & 3. Run on Resilience4j 2.4.0: the unseen ISBN failed fast with the cause attached, a cached price came back flagged stale, and the optional features degraded to an empty list and a rating-only response.

Rules that keep a fallback from becoming the next outage:

  • Cheap and local. A fallback must not call the dependency that just failed, nor another remote service that can fail the same way. Cache reads, constants and in-memory state are safe; if the fallback itself throws, its exception replaces the original.

  • Honest. Mark degraded answers (stale, degraded) so the caller and the user can tell. A fallback that returns a plausible but invented value (a price of zero shown as free) is worse than an error.

  • Observable. A fallback hides a failure from the caller, so it must leave a trace: a log line with the cause, a counter, or a field in the response. A dashboard that only watches errors reports a healthy system while every request is degraded.

  • Not a retry. A fallback answers once. Repeating the call is the job of Retry, and the order between them decides whether the retry ever sees a failure (see below).

  • Safe to repeat. A fallback that writes (queues an order, charges a card) needs the same idempotency as a retry, because the caller may call again.

Composing patterns: Decorators or annotations

The patterns are independent; composing them means nesting them. Resilience4j offers two ways, and they differ in who chooses the order.

With Decorators you choose the order explicitly. Every with…​ call wraps everything before it, so the last one added is the outermost. Decorators documents the result as Fallback(Retry(CircuitBreaker(Supplier))) for a chain that adds the circuit breaker, then the retry, then the fallback.

The recommended chain, built explicitly
Supplier<Money> guarded = Decorators.ofSupplier(call)
    .withBulkhead(bulkhead)                  // innermost: holds a permit only while the call runs
    .withRateLimiter(rateLimiter)            // every attempt takes a permit
    .withCircuitBreaker(circuitBreaker)      // every attempt is recorded
    .withRetry(retry)                        // repeats the whole inner stack
    .withFallback(
        Arrays.asList(IllegalStateException.class, CallNotPermittedException.class),
        e -> new Money("x", 1999, true))     // outermost: answers after the retries are exhausted
    .decorate();

Source: the Decorators source at v2.4.0 and Registries, Configuration & Decorators. Run on Resilience4j 2.4.0 against a call that always fails: three backend invocations, three failures recorded by the breaker, then the fallback value.

Two details of the builder were found while running it. The TimeLimiter and the thread-pool bulkhead exist only on the CompletionStage builder, so a synchronous chain has no time limiter. And the Javadoc of Decorators shows ofSupplier(…​).withFallback(CallNotPermittedException.class, e → …​), which does not compile: DecorateSupplier has the overloads for a function, a list of exception types, a result predicate and a BiFunction, but not a single exception class (that overload exists on ofCallable, ofFunction and the checked builders). Use the list form with ofSupplier.

With annotations, Spring chooses the order through aspect ordering. The aspects of the starter are ordinary Spring AOP aspects, and each has a numeric order. Spring’s Ordered contract is that a lower value means higher precedence, which for an @Around advice means the outer one. The defaults are chosen so that the annotations nest as Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(function))))).

flowchart TB CALLER([Caller]) --> RT subgraph RT["Retry -- order LOWEST_PRECEDENCE - 5, outermost"] subgraph CBK["CircuitBreaker -- order LOWEST_PRECEDENCE - 4"] subgraph RL["RateLimiter -- order LOWEST_PRECEDENCE - 3"] subgraph TL["TimeLimiter -- order LOWEST_PRECEDENCE - 2"] subgraph BH["Bulkhead -- order LOWEST_PRECEDENCE - 1, innermost"] FN["Bookshelf method body: the remote call"] end end end end end
Figure 1. The default aspect nesting

Each annotation’s fallbackMethod is not a layer of this onion. It is executed by the aspect of the annotation that declares it, around that aspect’s own execution. Putting the fallback on @CircuitBreaker therefore places it inside the Retry, and the retry then sees an answer instead of a failure:

Where the fallback is declared decides whether the retry ever runs
// fallback on the INNER annotation: the retry never sees a failure
@Retry(name = "inner")
@CircuitBreaker(name = "inner", fallbackMethod = "degraded")
public Money fallbackOnBreaker(String isbn) { return pricing.fetch(isbn); }

// fallback on the OUTER annotation: the attempts are exhausted first
@Retry(name = "outer", fallbackMethod = "degraded")
@CircuitBreaker(name = "outer")
public Money fallbackOnRetry(String isbn) { return pricing.fetch(isbn); }

private Money degraded(String isbn, Exception e) { return new Money(isbn, 1999, true); }

Source: Resilience4j: Spring Boot 2 & 3 and the CircuitBreakerAspect source at v2.4.0. Run on Resilience4j 2.4.0 with a backend that always fails and maxAttempts: 3: the first method called the backend once and returned the degraded value (the retry saw a success); the second called it three times and then returned the degraded value.

The same rule decides the choice between the two styles. If the fallback must run after the retries, declare it on the outermost annotation (the @Retry), or use Decorators, where withFallback goes last by construction.

The default order is also the recommended one: Retry outside CircuitBreaker, Bulkhead innermost, with the fallback outside everything. Each position has a reason, and the measurements come from running the chains with Decorators and the annotations.

Position Reason Measured

Retry outside the CircuitBreaker

Every attempt is a separate decision for the breaker. When the breaker opens in the middle of a retry sequence, the next attempts are rejected without calling the backend.

With the breaker window of 2 and maxAttempts: 4, the backend was called twice, not four times.

CircuitBreaker outside the RateLimiter

A rejection by your own limiter is then seen (and can be ignored) by the breaker, and an open breaker does not consume permits.

See the RequestNotPermitted caveat in Rate Limiter.

RateLimiter inside the Retry

The limiter protects the dependency, so every real attempt must take a permit.

Retry outside: three permits for one failing request. Limiter outside the retry: one permit for the same three attempts.

TimeLimiter inside the retry

Each attempt gets its own deadline, and a timeout is a retryable failure. Only available for asynchronous return types.

See Time Limiter.

Bulkhead innermost

The permit is held only while the call runs, not while the retry sleeps between attempts.

Retry outside: the single permit was free during both backoff waits. Bulkhead outside the retry: the permit was held during both waits (0 free).

Fallback outermost

It answers only when everything inside has given up.

Fallback outermost: three backend calls, then the fallback. Fallback inside the retry: one backend call.

Bulkhead inside versus outside the retry
IntervalFunction probe = attempt -> {
    availableDuringBackoff.add(bulkhead.getMetrics().getAvailableConcurrentCalls());
    return 1L;                                   // wait 1 ms
};
Retry retry = Retry.of("r", RetryConfig.custom().maxAttempts(3).intervalFunction(probe).build());

// Retry(Bulkhead(fn)):  availableDuringBackoff == [1, 1]
Supplier<Money> bulkheadInside = Decorators.ofSupplier(call).withBulkhead(bulkhead).withRetry(retry).decorate();

// Bulkhead(Retry(fn)):  availableDuringBackoff == [0, 0]
Supplier<Money> bulkheadOutside = Decorators.ofSupplier(call).withRetry(retry).withBulkhead(bulkhead).decorate();

Source: Resilience4j: Bulkhead and Resilience4j: Retry. Run on Resilience4j 2.4.0 with a bulkhead of one concurrent call, as printed in the comments.

Metrics inflation

The order also changes what the circuit breaker’s numbers mean. With the default order the breaker sits inside the retry, so every attempt is a recorded call: one failing request with three attempts adds three failures to the sliding window. With the order reversed, the breaker records one outcome per request, but the duration of that outcome includes the retry waits.

One failing request with three attempts, observed through the breaker
// Retry(CircuitBreaker(fn)): the default order
Supplier<Money> retryOutside = Decorators.ofSupplier(call)
    .withCircuitBreaker(cb).withRetry(retry).decorate();
// -> backend invoked 3 times, breaker recorded 3 failed calls

// CircuitBreaker(Retry(fn)): reversed
Supplier<Money> breakerOutside = Decorators.ofSupplier(call)
    .withRetry(retry).withCircuitBreaker(cb).decorate();
// -> backend invoked 3 times, breaker recorded 1 failed call

Source: Resilience4j: CircuitBreaker and the CircuitBreaker source at v2.4.0. The annotated form was run as well: @Retry plus @CircuitBreaker on one method with a failing backend invoked it three times and the breaker recorded three ERROR events.

Aspect Retry(CircuitBreaker(fn)), the default CircuitBreaker(Retry(fn)), reversed

Calls recorded per failing request

One per attempt (3 with maxAttempts: 3)

One

Failure rate over the window

Counts attempts: a window of 10 holds the failures of about three requests, so the breaker opens after fewer failing requests than the window suggests

Counts requests, as the sizing of the window usually assumes

Slow-call rate

Each attempt is measured alone (0 slow calls with instant failures and a 30 ms backoff)

The whole sequence is one call: the same instant failures plus the 30 ms backoff recorded 1 slow call with a 40 ms threshold

Open breaker mid-sequence

The remaining attempts are rejected at once, without a backend call

The retry continues to call the backend until its attempts are used up; the breaker opens only after the request ends

Retry waits

Not part of the breaker’s timings

Part of the breaker’s timings

Neither order is wrong, but only the first one protects the backend while a request is still retrying, which is why it is the default and the recommendation. The practical rule is to size minimumNumberOfCalls and slidingWindowSize for attempts, not requests, when the retry is outside, and to read the breaker’s failureRate accordingly.

Retrying through an open breaker

The most instructive interaction is a retried call that meets a breaker which opens while the retry is still running. Retry retries every exception by default, and CallNotPermittedException is an exception like any other, so the retry keeps knocking on the closed door until its attempts are used up; the backend is not called, but each attempt costs a wait. The run below used maxAttempts: 4, a breaker window of 2 and a fallback on the @Retry.

sequenceDiagram participant C as Caller participant R as Retry aspect, outer participant B as CircuitBreaker aspect, inner participant P as pricing-service C->>R: price(isbn) with the fallback on @Retry R->>B: attempt 1 B->>P: call, breaker CLOSED P-->>B: error B-->>R: IllegalStateException, failure 1 of 2 Note over R: wait, then retry R->>B: attempt 2 B->>P: call, breaker CLOSED P-->>B: error Note over B: failure rate 100 percent, CLOSED to OPEN B-->>R: IllegalStateException, failure 2 of 2 Note over R: wait, then retry R->>B: attempt 3 B--xR: CallNotPermittedException, no call made Note over R: retried by default R->>B: attempt 4 B--xR: CallNotPermittedException, no call made Note over R: attempts exhausted, the fallback receives CallNotPermittedException R-->>C: Money 1999, stale
Figure 2. A retried call through a breaker that opens after two failed attempts

The run confirmed every step: the backend was invoked twice, the breaker rejected two attempts, the fallback received the CallNotPermittedException and answered with the cached price, and a second request found the breaker still open and made no backend call at all (all four of its attempts were rejected). Retrying a rejection is wasted time, so tell the retry to stop:

A retry that does not retry a rejection by the open breaker
resilience4j:
  retry:
    instances:
      pricing:
        maxAttempts: 4
        waitDuration: 1ms
        ignoreExceptions:
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException

Source: Resilience4j: Retry and Resilience4j: Spring Boot 2 & 3. Run on Resilience4j 2.4.0 with the annotations: the backend was invoked twice, only one attempt was rejected by the breaker, and the fallback answered immediately after it.

The AspectOrder properties

Each aspect reads its order from a property, so the global nesting can be changed without leaving the annotations. The properties are plain integers; the defaults are expressed relative to Ordered.LOWEST_PRECEDENCE (2147483647), which is why they look like large numbers.

Aspect Property Default offset Default value Position in the default nesting

@Retry

resilience4j.retry.retryAspectOrder

LOWEST_PRECEDENCE - 5

2147483642

Outermost

@CircuitBreaker

resilience4j.circuitbreaker.circuitBreakerAspectOrder

LOWEST_PRECEDENCE - 4

2147483643

Inside Retry

@RateLimiter

resilience4j.ratelimiter.rateLimiterAspectOrder

LOWEST_PRECEDENCE - 3

2147483644

Inside the breaker

@TimeLimiter

resilience4j.timelimiter.timeLimiterAspectOrder

LOWEST_PRECEDENCE - 2

2147483645

Inside the rate limiter

@Bulkhead (both bulkhead types)

resilience4j.bulkhead.bulkheadAspectOrder

LOWEST_PRECEDENCE - 1

2147483646

Innermost

@Timer

resilience4j.micrometer.timer.timerAspectOrder

LOWEST_PRECEDENCE

2147483647

Inside everything, nearest the method

The values were read from the running aspect beans (getOrder() on RetryAspect, CircuitBreakerAspect, RateLimiterAspect, TimeLimiterAspect and BulkheadAspect): -5, -4, -3, -2 and -1 relative to LOWEST_PRECEDENCE. The bulkheadAspectOrder setter is new in 2.4.0, so that property can be set from YAML, which was not possible before. The properties are global to the application: one order for every annotated method. To nest differently for one call, use Decorators for that call.

The readme.io wording discrepancy

readme.io describes the properties this way: to make the circuit breaker start after the retry has finished its work, set retryAspectOrder to a greater value than circuitBreakerAspectOrder (its words: "the higher value = the higher priority"), and it gives an example with the breaker at 1 and the retry at 2. That is the opposite of what Spring does. In Spring AOP a lower value is the higher precedence, hence the outer aspect, so the example makes the breaker the outer aspect and the retry the inner one.

The readme.io example: circuitBreakerAspectOrder 1, retryAspectOrder 2
resilience4j:
  circuitbreaker:
    circuitBreakerAspectOrder: 1
  retry:
    retryAspectOrder: 2

Source: Resilience4j: Spring Boot 2 & 3 (the "Aspect order" section) and the Spring Ordered Javadoc. Run on Resilience4j 2.4.0: with these two properties the backend was still invoked three times but the breaker recorded one failed call, which is the behaviour of CircuitBreaker(Retry(fn)).

To get the order the readme text intends (the retry outside the breaker) leave the defaults alone, or give the retry the lower number. Two cautions apply whenever you set these values:

  • The numbers are absolute. In the run above the other aspects kept LOWEST_PRECEDENCE - 3, - 2 and - 1, so setting the breaker to 1 and the retry to 2 moves both of them outside every aspect that keeps its default, including the rate limiter, the time limiter and the bulkhead. Set all the properties you rely on, or none.

  • A smaller number also moves an aspect relative to every other aspect in the application, according to Ordered. When the order matters for a mix of Resilience4j and other aspects, give every aspect an explicit order.

For the proxy mechanics behind this, see the Spring documentation of advice ordering and Annotations & Aspect Order.

Pitfalls

Pitfall Fix

Fallback declared on the inner annotation

The retry sees a success and never retries. Declare the fallback on the outermost annotation or use Decorators.

A fallback whose signature is almost right

It is silently ignored with one WARN log line and the original exception propagates. Match the parameters exactly (no supertypes) and put the exception last.

Two overloads for the same exception type with different parameter lists

IllegalStateException on every call. Keep one method per exception type.

A Mono method that throws before returning the Mono

The fallback is skipped. Build the pipeline lazily or catch synchronous errors.

A fallback that calls the failing dependency or another remote service

It fails the same way and replaces the original exception. Use a cache, a default or a fail-fast exception.

A fallback that invents data

Mark degraded answers and prefer failing fast when no honest answer exists.

Retry retrying CallNotPermittedException

Add it to the retry’s ignoreExceptions.

Reading the breaker’s failure rate per request

With the default order it is per attempt: size the window and minimumNumberOfCalls for attempts.

Copying the readme.io AspectOrder example to put Retry outside

Lower value is outer. Verify the order with the metrics or by reading getOrder() of the aspect beans.

Setting one AspectOrder property and leaving the others

The numbers are absolute, so the aspect jumps outside every default-ordered aspect.

Using withFallback(SomeException.class, …​) on Decorators.ofSupplier

It does not exist on that builder (the Javadoc example is wrong). Use the list overload or ofCallable.

What changed recently

  • 2.4.0 added the bulkheadAspectOrder setter, so the order of the bulkhead aspect can be set from YAML, and clarified the aspect-order defaults (Retry -5 to Bulkhead -1 relative to LOWEST_PRECEDENCE) in the starter. The release notes are in the GitHub release; RELEASENOTES.adoc at the tag stops at 2.3.0.

  • 2.4.0 adds resilience4j-spring-boot4. The fallback executor, the decorators and the aspects are the same resilience4j-spring6 classes as with Spring Boot 3, so fallbacks and ordering behave identically.

  • The readme.io Spring Boot page still describes the aspect order as higher value meaning higher priority, with an example that produces the opposite nesting; the source and the run above follow Spring’s Ordered.

  • Spring Framework 7 resilience annotations (@Retryable, @ConcurrencyLimit) have no fallback method; composing them with Resilience4j is covered in Spring Framework Native Resilience.