skip to content

When you stack @Retry, @CircuitBreaker, @RateLimiter, @TimeLimiter and @Bulkhead on one method, what is their execution order and why does it matter?

level: principalimportance: should knowfreq 33%

answer

  1. Retry -> CircuitBreaker -> RateLimiter -> TimeLimiter -> Bulkhead
  2. higher aspect-order = more outer
  3. Retry outside = each attempt hits the breaker
  4. don't retry CallNotPermittedException
  5. TimeLimiter must wrap the async future

basics

~20 s

Resilience4j runs the aspects in a fixed nesting order (outer to inner): Retry -> CircuitBreaker -> RateLimiter -> TimeLimiter -> Bulkhead. Order matters because, e.g., Retry outside CircuitBreaker means retries are recorded by the breaker, and TimeLimiter must sit around the async call it limits.

solid answer

~40 s

The Spring Boot Resilience4j aspects each have an order, and by default they nest outer-to-inner as **Retry(outermost) -> CircuitBreaker -> RateLimiter -> TimeLimiter -> Bulkhead(innermost)**. Each aspect's order is configurable (retry-aspect-order, circuit-breaker-aspect-order, etc.), higher order = more outer. Why it matters: with Retry outside CircuitBreaker, every retry attempt is recorded by the breaker's sliding window and can trip it; a retry attempt blocked by an OPEN breaker throws CallNotPermittedException and won't be retried further. RateLimiter inside Retry means each retry consumes a permit. TimeLimiter must wrap the actual async invocation (bulkhead) to cancel it, so it sits just outside the bulkhead. Getting the order wrong changes semantics — e.g., putting Retry inside the breaker would hide retries from the breaker's statistics.

code

java · 28 lines
java
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import java.util.concurrent.CompletableFuture;
import org.springframework.stereotype.Service;

@Service
public class OrdersClient {

    // Effective nesting (outer -> inner):
    //   Retry -> CircuitBreaker -> RateLimiter -> TimeLimiter -> Bulkhead
    @Retry(name = "orders")
    @CircuitBreaker(name = "orders")
    @RateLimiter(name = "orders")
    @TimeLimiter(name = "orders")
    @Bulkhead(name = "orders", type = Bulkhead.Type.THREADPOOL)
    public CompletableFuture<String> placeOrder(String cartId) {
        return CompletableFuture.supplyAsync(() -> callOrderService(cartId));
    }

    private String callOrderService(String cartId) { /* remote call */ return "OK"; }
}

// application.yml note:
// resilience4j.retry.instances.orders.ignore-exceptions:
//   - io.github.resilience4j.circuitbreaker.CallNotPermittedException

go deeper

for a junior

Aware that multiple resilience annotations can be combined on one method.

for a middle

Recall the default order and that Retry wraps the circuit breaker.

for a senior

Explain how ordering changes what the breaker records and why CallNotPermittedException shouldn't be retried.

for a principal

Design coherent record/ignore exception sets across patterns, justify custom aspect-order overrides, and reason about async composition and fallback placement across the whole chain.

**The stack.** Resilience4j's Spring integration implements each pattern as a separate AOP aspect (`RetryAspect`, `CircuitBreakerAspect`, `RateLimiterAspect`, `TimeLimiterAspect`, `BulkheadAspect`). When several annotations decorate the same method, the aspects compose by their Spring `@Order` value. **Default ordering (outer -> inner):** 1. **Retry** (highest order, outermost) 2. **CircuitBreaker** 3. **RateLimiter** 4. **TimeLimiter** 5. **Bulkhead** (lowest order, innermost, closest to your code) A higher aspect-order number means the aspect executes *further out*. All five are configurable via properties like `resilience4j.retry.retry-aspect-order`, `resilience4j.circuitbreaker.circuit-breaker-aspect-order`, etc. **Why the default order is sensible.** - **Retry outermost:** A retry re-invokes the whole inner chain. So each attempt passes through the circuit breaker and is recorded in its window. If the breaker opens mid-retry, the next attempt gets `CallNotPermittedException` and retry stops fast rather than hammering an open breaker. This is usually what you want: the breaker sees real attempt outcomes. - **CircuitBreaker before RateLimiter:** an OPEN breaker rejects before you even spend a rate-limit permit — you don't waste quota on calls you won't make. - **TimeLimiter just outside Bulkhead:** TimeLimiter needs the asynchronous `CompletableFuture` produced by the thread-pool bulkhead (or the method) to cancel it on timeout. It must wrap the actual execution. - **Bulkhead innermost:** it gates entry to the real invocation, bounding concurrency at the resource itself. **Consequences of reordering.** - Put **Retry *inside* CircuitBreaker** and the breaker sees one logical call (all retries collapsed) — retries no longer feed the failure window individually, so the breaker reacts to fully-retried outcomes only. Sometimes desirable (don't let transient blips inflate the failure count), but it changes tripping behavior significantly. - Put **RateLimiter outside Retry** and a burst of retries counts as one permit acquisition rather than one-per-attempt. **Combining Retry + CircuitBreaker gotcha.** With the default order, retries happen on top of the breaker. But retrying a call that the breaker keeps rejecting is wasteful — configure Retry to *not* retry `CallNotPermittedException` (via `retry-exceptions`/`ignore-exceptions`) so retries stop once the breaker opens. Also ensure Retry and CircuitBreaker use consistent `record`/`ignore` exception sets to avoid contradictory behavior. **TimeLimiter + Retry.** Since TimeLimiter throws `TimeLimiterException` on timeout, and it sits inside Retry, a timed-out attempt can be retried — good for slow, occasionally-hanging calls. Make sure `TimeLimiterException` is in Retry's `retry-exceptions`. **Async requirement.** TimeLimiter and thread-pool Bulkhead require the method to return `CompletableFuture`. When mixing with CircuitBreaker/Retry on an async method, all aspects operate on the future — CircuitBreaker records the future's completion outcome. **Fallback placement.** A `fallbackMethod` on any of the annotations catches whatever propagates out of the aspects below it. Typically you put the fallback on the outermost meaningful annotation so it catches exhausted retries, open-breaker rejections, rate-limit rejections, timeouts, and bulkhead-full — all of them. **Practical guidance.** - Default order works for the common 'protect a remote call' case; don't reorder without a reason. - Document any custom aspect-order because it silently changes failure semantics. - Keep the exception record/ignore sets coherent across patterns. - For async isolation, use TimeLimiter + THREADPOOL Bulkhead + CircuitBreaker + Retry, returning `CompletableFuture`. **When to use all five.** Rarely all at once — pick the patterns matching real failure modes: CircuitBreaker for a flaky dependency, Retry for transient errors, TimeLimiter for hangs, Bulkhead for concurrency isolation, RateLimiter to respect a downstream quota.

  • Why is it important that Retry does NOT retry CallNotPermittedException?
    Once the circuit breaker is OPEN it rejects every call with CallNotPermittedException. If Retry treated that as retryable, it would keep re-attempting a call the breaker is deliberately refusing — wasting time and adding no value. You put CallNotPermittedException in Retry's ignore-exceptions so retries stop immediately when the breaker opens.
  • How would moving CircuitBreaker outside Retry change the breaker's statistics?
    With CircuitBreaker outermost and Retry inside it, the breaker records only the final outcome after all retries are exhausted — one data point per logical call. Transient failures that a retry recovers from never reach the breaker's window, so the breaker reacts only to failures that survive retrying.

saying these in an interview costs you the question

  • Claiming aspect order is fixed and non-configurable
  • Thinking the innermost aspect runs first in the 'outer' sense — confusing nesting direction
  • Retrying CallNotPermittedException, hammering an open breaker
  • Believing you can @TimeLimiter a synchronous, non-Future-returning method in this stack
  • Assuming all five annotations should always be combined

context