If you run Resilience4j on Spring Boot 3 and you’re upgrading to Boot 4.1, you’ve probably already heard that Spring Framework 7 ships its own @Retryable and @ConcurrencyLimit annotations — no extra dependency, right there in spring-context. The obvious next question is which parts of your resilience4j.yml you get to delete. This post answers that for the parts of Resilience4j that are actually Resilience4j’s job — the circuit breaker, the rate limiter, the bulkhead’s bounded wait, fallback methods, and the dashboards — updated for Boot 4.1, Resilience4j 2.4.0, and JDK 25, with two build breaks you will hit getting there that nothing else has written about yet.
For the deep, invocation-by-invocation measurement of what Spring’s own @Retryable and @ConcurrencyLimit actually do — the off-by-one in maxRetries, the timeout that doesn’t time anything out, jitter that only ever adds — see Spring Framework 7’s Built-in Resilience. This post assumes you’ve read that or don’t need to, and focuses on the half of the picture it doesn’t cover: what’s still Resilience4j’s job, and what breaks getting a Resilience4j-based service running on Boot 4.1 at all.
Versions this was verified against. Spring Boot 4.1.1 (GA, published to Maven Central 20 August 2026), Spring Framework 7.0.9, Resilience4j 2.4.0 via itsresilience4j-spring-boot4module, Eclipse Temurin JDK 25.0.4.1 LTS. Companion code: resilience4j-circuit-breaker — every transcript quoted below is underdocs/output/, regenerated byscripts/run-all.sh.
What actually moved into Spring Framework 7 core
Framework 7 added a real package, org.springframework.resilience, with two annotations: @Retryable (declarative retry with delay, multiplier, jitter, and a max attempt count) and @ConcurrencyLimit (cap concurrent calls to a method, either by queuing or by rejecting). Both are switched on with @EnableResilientMethods — which Spring Boot 4.1 does not add for you automatically. I checked: grepping the listing of spring-boot-autoconfigure-4.1.1.jar for “resilien” returns nothing. No starter, no autoconfiguration class, no default. You add @EnableResilientMethods to a configuration class yourself, or the annotations are inert — no error, no log line, the method just runs unprotected.
One counting gotcha worth flagging even though the linked deep-dive covers it in full: Resilience4j’s @Retry(maxAttempts=3) counts the initial call — three calls total. Core’s @Retryable(maxRetries=3) counts retries after the initial call — four calls total. I verified both independently in this repo’s R4jRetryTest: a permanently-failing downstream gets exactly 3 calls under Resilience4j’s maxAttempts=3 and exactly 4 under core’s maxRetries=3 (03e, 03b). Porting a number from one config to the other by name alone is off by one.
The build break, and the rename most guides still get wrong
Every pre-Boot-4 Resilience4j guide — including the version of this post it replaces — tells you to add spring-boot-starter-aop alongside the Resilience4j starter, because the annotations need Spring AOP proxying to work. On Boot 4.1, doing that breaks the build before you get anywhere near a running application:
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 19, column 17
(reproduced and captured by scripts/reproduce-starter-aop-break.sh against a minimal pom that only adds this one dependency: 00-starter-aop-build-error.txt)
It is tempting to read that error as “the starter is gone, assemble the AOP jars by hand instead” — an earlier draft of this section said exactly that. It’s wrong. Maven Central’s maven-metadata.xml for both artifact IDs, read together, settles it: spring-boot-starter-aop‘s last-ever published version is 4.0.0-M2; spring-boot-starter-aspectj‘s first-ever published version is 4.0.0-M3 — the very next milestone in the same 4.0 line, carrying straight through the 4.0.0 GA and every 4.1.x release since. That’s a rename at a specific milestone boundary, not a starter dropped with nothing replacing it. The renamed starter’s own published POM confirms it: it depends on exactly org.springframework:spring-aop:7.0.9 and org.aspectj:aspectjweaver:1.9.25.1, version-managed for you. The fix is a one-line rename, not a two-jar assembly:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
(from this repo’s pom.xml, which hits and fixes this exact error, and from the sibling resilience/pom.xml module, which already used the renamed starter correctly)
The mechanism behind that trap, for anyone who does hand-assemble instead of using the starter: Resilience4j’s Spring integration (resilience4j-spring6, which is what resilience4j-spring-boot4 actually depends on internally — the “spring-boot4” in the artifact name is about which Boot generation it targets, not an internal module rename, so don’t go looking for a “spring7” module) implements CircuitBreakerAspect, BulkheadAspect, RateLimiterAspect, and RetryAspect as real @Aspect classes — confirmed with unzip -l on the jar. Spring’s AnnotationAwareAspectJAutoProxyCreator needs org.aspectj:aspectjweaver on the classpath just to recognize a bean as an aspect at all. Add spring-aop without aspectjweaver and no proxy gets created for any of them — every Resilience4j annotation in the service silently stops working.
I found this failure mode the way the verification standard on this blog insists on: not by reading, by a failing test, before I’d confirmed the renamed starter was the real fix. CircuitBreakerTripAndRecoverTest failed with the raw DownstreamUnavailableException propagating straight out of R4jPaymentService.pay() — no fallback, no breaker state, nothing between the test and the exception, with only spring-aop on the classpath. Switching to spring-boot-starter-aspectj fixed every failing test in the same run, with no other changes.
If your Resilience4j annotations look like they’re doing nothing after a Boot 4 upgrade, check that you’re usingspring-boot-starter-aspectjand not a hand-assembledspring-aopdependency missingaspectjweaver. There is no startup warning either way. The fastest way to notice is exactly what caught it here: a test that asserts the fallback method ran, not just that the call returned something.
The circuit breaker: still Resilience4j’s job
Nothing in Spring Framework 7 replaces this. A circuit breaker is a state machine that remembers failure history across calls and stops even attempting a downstream call once it’s decided that dependency is down:
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
public String pay(String orderId) {
return downstream.call();
}
private String fallback(String orderId, Throwable ex) {
return "FALLBACK for " + orderId + ": " + ex.getClass().getSimpleName();
}
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 2s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
(application.yml — the config this post’s transcripts were actually run against)
Running six calls against a downstream that’s permanently down, then three more while the breaker is OPEN, then waiting past waitDurationInOpenState for the downstream to recover:
call 5 -> FALLBACK for order-5: DownstreamUnavailableException - payment-gateway rejected call #5 [breaker state=OPEN]
call 6 -> FALLBACK for order-6: CallNotPermittedException - CircuitBreaker 'paymentService' is OPEN and does not permit further calls [breaker state=OPEN]
call 7 -> FALLBACK for order-7: CallNotPermittedException - CircuitBreaker 'paymentService' is OPEN and does not permit further calls [breaker state=OPEN, downstream calls so far=5]
call 8 -> FALLBACK for order-8: CallNotPermittedException - CircuitBreaker 'paymentService' is OPEN and does not permit further calls [breaker state=OPEN, downstream calls so far=5]
call 9 -> FALLBACK for order-9: CallNotPermittedException - CircuitBreaker 'paymentService' is OPEN and does not permit further calls [breaker state=OPEN, downstream calls so far=5]
half-open probe 1 -> OK (call #1) [breaker state=HALF_OPEN]
half-open probe 2 -> OK (call #2) [breaker state=CLOSED]
Read the “downstream calls so far” counter across calls 7, 8 and 9: it does not move. Three calls, zero downstream traffic — the breaker short-circuits them entirely. Full transcript: 01-circuitbreaker-trip.txt, from CircuitBreakerTripAndRecoverTest. Compare that with what core’s @Retryable does in the same situation, measured in the linked deep-dive and in this repo’s own 03b-retryable-no-memory.txt: two consecutive calls against a dead downstream each pay the full retry cost, because nothing remembers the first one failed. That’s the property a circuit breaker adds and @Retryable structurally cannot: memory.
minimumNumberOfCalls=5 exists so a fresh breaker doesn’t trip on pure noise — one deployment-time connection refused shouldn’t open a circuit that’s about to see healthy traffic. Full mechanics, including why Resilience4j’s own defaults (minimumNumberOfCalls=100) won’t even evaluate a low-traffic service for a long time: docs/02-circuit-breaker.md.
Retry, rate limiter, bulkhead, timelimiter: the reference, updated
The rest of Resilience4j’s annotations, current for 2.4.0 on Boot 4.1. These snippets are carried forward from Resilience4j’s own stable, documented API — version-checked against resilience4j-spring-boot4:2.4.0‘s POM, not independently re-executed for every variant in this round, apart from Retry and Bulkhead which this repo’s tests do exercise directly (linked above and below).
resilience4j:
retry:
instances:
inventoryService:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2 # 500ms, 1s, 2s
retryExceptions:
- java.io.IOException
ignoreExceptions:
- com.example.BusinessValidationException
@Retry(name = "inventoryService", fallbackMethod = "inventoryFallback")
public String checkStock(String productId) {
return restClient.get().uri("/stock/{id}", productId).retrieve().body(String.class);
}
private String inventoryFallback(String productId, Throwable ex) {
return "Stock check failed after retries for " + productId;
}
Only retry idempotent operations — retrying a non-idempotent POST that timed out after the downstream processed it is how duplicate payments happen. Live, executed proof of this repo’s own @Retry behaviour, including the counting-convention gotcha from earlier in this post: 03d, 03e.
resilience4j:
ratelimiter:
instances:
publicApi:
limitForPeriod: 100 # 100 calls
limitRefreshPeriod: 1s # per second
timeoutDuration: 0ms # fail immediately when exhausted
This is an in-process limiter — with three replicas behind a load balancer, your effective limit is 300/s, not 100/s. Cluster-wide limiting needs a gateway-level limiter instead. There is still nothing in Spring Framework 7 core that does rate limiting at all, per-second or otherwise.
resilience4j:
bulkhead:
instances:
slowOp:
maxConcurrentCalls: 2
maxWaitDuration: 100ms
(also in application.yml)
@Bulkhead(name = "slowOp", fallbackMethod = "fallback")
public String slowCall(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
(R4jBulkheadService.java — the exact method the 04c transcript above was captured from)
The reason this still earns a place over core’s @ConcurrencyLimit: a bounded wait. Run 4 concurrent callers against a limit of 2, each holding its slot 300ms, under three configurations — core’s BLOCK policy, core’s REJECT policy, and this Bulkhead with maxWaitDuration=100ms:
| Policy | Outcome |
|---|---|
@ConcurrencyLimit BLOCK | all 4 succeed; total wall time 601ms (queues, no timeout) |
@ConcurrencyLimit REJECT | 2 succeed in ~300ms, 2 rejected in ~0ms (never waits at all) |
Resilience4j @Bulkhead | 2 succeed ~300ms, 2 rejected after ~110-120ms (waited, then gave up) |
(summarised from the three transcripts linked below, each run independently)
Neither of core’s two policies offers that middle option — wait, but only briefly. Full transcripts: 04a, 04b, 04c, from ConcurrencyLimitTest. More: docs/04-concurrency-limit.md.
resilience4j:
timelimiter:
instances:
paymentService:
timeoutDuration: 2s
cancelRunningFuture: true
@TimeLimiter(name = "paymentService", fallbackMethod = "timeoutFallback")
@CircuitBreaker(name = "paymentService", fallbackMethod = "timeoutFallback")
public CompletableFuture<String> processPaymentAsync(String orderId) {
return CompletableFuture.supplyAsync(() ->
restClient.get().uri("/pay/{id}", orderId).retrieve().body(String.class));
}
TimeLimiter only works on async return types (CompletableFuture, Mono, Flux) — it cannot interrupt a plain blocking call. For synchronous code, set timeouts on the HTTP client itself and let the breaker’s slowCallRateThreshold handle chronic slowness. Core has nothing like this at all — and per the linked deep-dive’s own measurement, core’s @Retryable(timeout=...) is a retry budget, not a call timeout: a single 1500ms call with timeout=500 still ran the full 1500ms, uninterrupted.
Monitoring with Actuator
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
(application.yml) Real output, captured over HTTP against a running embedded server with the breaker actually OPEN — and it corrects a claim the version of this post from Boot 3 days made. GET /actuator/health in this version does not nest per-breaker detail; the circuitBreakers key in the real response body is just this, verbatim (the rest of the response — disk space, SSL chain checks — is environment detail, not shown here):
"circuitBreakers":{"status":"UNKNOWN"}
The per-breaker detail lives at a different endpoint, GET /actuator/circuitbreakers, not nested under health at all. This is the real, complete, unedited response body:
{"circuitBreakers":{"paymentService":{"bufferedCalls":5,"failedCalls":5,"failureRate":"100.0%","failureRateThreshold":"50.0%","notPermittedCalls":1,"slowCallRate":"0.0%","slowCallRateThreshold":"100.0%","slowCalls":0,"slowFailedCalls":0,"state":"OPEN"}}}
Full transcript with both requests side by side: 05-actuator-health-tripped.txt, from ActuatorHealthTest. If your health-check dashboard or alert rule was written against the old nested shape, it silently stopped seeing per-breaker state on this version and nobody told it.
The resilience4j_circuitbreaker_state and resilience4j_circuitbreaker_calls_seconds Micrometer metrics under /actuator/metrics feed straight into the Prometheus & Grafana stack. Core’s annotations register no metrics at all — the linked deep-dive’s companion app has to hand-roll a counter from MethodRetryEvent to get even a basic retry count, and notes that event fires on every failed attempt including ones that will never be retried, so it counts failures, not retries. If you need a dashboard your on-call engineer can actually read at 3am, that gap alone is a reason to keep Resilience4j.
Hystrix → Resilience4j property mapping
Still relevant if you’re one of the teams that hasn’t finished migrating off Hystrix yet — it’s been in maintenance mode since 2018 and nothing about Boot 4.1 changes that assessment.
| Hystrix property | Resilience4j equivalent |
|---|---|
| circuitBreaker.requestVolumeThreshold | minimumNumberOfCalls |
| circuitBreaker.errorThresholdPercentage | failureRateThreshold |
| circuitBreaker.sleepWindowInMilliseconds | waitDurationInOpenState |
| execution.isolation.thread.timeoutInMilliseconds | TimeLimiter timeoutDuration (async only!) |
| threadpool.coreSize | Bulkhead maxConcurrentCalls (semaphore) or THREADPOOL bulkhead |
Pitfalls that bite every migration
Hand-assembling spring-aop instead of using spring-boot-starter-aspectj. Covered above — the one most guides, including earlier versions of this one, still get wrong. No error, no log line, annotations silently inert.
Wrong fallback signature. The fallback must mirror the protected method’s parameters plus a Throwable (or more specific exception) as the last parameter. Get it wrong and Resilience4j throws NoSuchMethodException at call time, not at startup.
Self-invocation. The annotations work via Spring AOP proxies. Calling an annotated method from within the same class bypasses the proxy entirely — no breaker, no retry, no error. This bites core’s @Retryable exactly the same way; see 03c for it caught live.
Counting business exceptions as failures. Without ignoreExceptions, a burst of validation errors trips the breaker and takes down a perfectly healthy integration.
Should you drop Resilience4j? If all you use is retry and a plain concurrency cap, yes — core’s@Retryableand@ConcurrencyLimitdo both, with one fewer dependency. Recount yourmaxAttemptsasmaxRetrieson the way. If you use a circuit breaker, a rate limiter, the Bulkhead’s bounded wait, afallbackMethod, or the Actuator/Micrometer dashboards — and most services that reach for Resilience4j in the first place do — keep it. Don’t combine core’s@Retryablewith Resilience4j’s@CircuitBreakeron the same method without excludingCallNotPermittedExceptionfrom the retry: the linked deep-dive measured the retry wrapping the open breaker and burning three seconds on an answer it already had.
Frequently Asked Questions
Do I still need spring-boot-starter-aop? That artifact ID was renamed to spring-boot-starter-aspectj starting at Boot 4.0.0-M3 — the old name’s last published version is a Boot 4.0 milestone, 4.0.0-M2. Use spring-boot-starter-aspectj, which bundles spring-aop and aspectjweaver for you. See above.
Can I run Resilience4j and core’s @Retryable in the same service? Yes, on different methods, with no conflict. On the same method alongside @CircuitBreaker, be careful — see the callout above.
Does Resilience4j work with virtual threads? Yes. Semaphore-based bulkheads and circuit breakers are agnostic to the carrier thread; avoid the THREADPOOL bulkhead with virtual threads since it defeats their purpose.
AI Prompts for Resilience4j on Boot 4.1
Audit a Boot 4 Upgrade for the AOP Trap
Here is my pom.xml and my Resilience4j-annotated service classes: [paste here]. Check whether spring-boot-starter-aop is present (it was renamed to spring-boot-starter-aspectj starting at Boot 4.0.0-M3) and whether the renamed starter or an equivalent aspectjweaver dependency is actually on the classpath given my Resilience4j usage. Flag anything that would silently disable the annotations.
What it does: Catches the exact silent failure this post is about before it reaches production.
Decide What to Keep
Given these Resilience4j-annotated methods: [paste here], classify each as (a) replaceable by core Spring Framework 7’s @Retryable/@ConcurrencyLimit with no loss of behaviour, or (b) dependent on a circuit breaker, rate limiter, bounded-wait bulkhead, fallback method, or metrics that only Resilience4j provides.
What it does: Turns the “should you even do this” callout above into a per-method audit of a real codebase.
Convert Hystrix Class
Convert this Hystrix-annotated class to Resilience4j 2.4.0 on Spring Boot 4.1: [paste class here]. Map every hystrix.command property to the resilience4j YAML equivalent, fix fallback signatures to include Throwable, and flag any timeout behaviour that changes.
What it does: Mechanical conversion plus surfacing the semantic timeout/isolation differences explicitly.
Write Breaker Contract Tests
Write JUnit 5 tests for this Resilience4j-protected service: [paste here]. Cover the breaker tripping after the configured failures, the downstream call count staying flat while OPEN, fallback invocation, half-open recovery, and ignored exceptions not affecting the failure rate. Use CircuitBreakerRegistry directly to assert state transitions.
What it does: Generates the state-transition tests most teams skip until the breaker misbehaves in production — the same shape as this repo’s own CircuitBreakerTripAndRecoverTest.
Further reading
- resilience4j-circuit-breaker — companion module (inside the spring-boot-demo container repo), every transcript quoted above, regenerated by
scripts/run-all.sh - Spring Framework 7’s Built-in Resilience: @Retryable, @ConcurrencyLimit, and What’s Left for Resilience4j — the exhaustive, invocation-by-invocation companion piece this post assumes
- Monitoring Spring Boot Microservices with Prometheus & Grafana
- Spring Cloud Netflix to Modern Alternatives: Complete Migration Guide
- Resilience4j official documentation
No Comments yet!