Add resilience4j-circuit-breaker: Resilience4j 2.4.0 vs Spring Framework 7 core, on Boot 4.1

Companion module for the rewritten post 'Resilience4j Circuit Breaker in Spring Boot 4.1:
What It's Still For', reworked around Framework 7 now shipping @Retryable/@ConcurrencyLimit
in core. Covers what's still Resilience4j's job (circuit breaker, rate limiter, bulkhead's
bounded wait, fallback methods, Actuator/Micrometer metrics), the off-by-one between
maxAttempts and maxRetries, and two Boot-4.1 build breaks: spring-boot-starter-aop no longer
exists (renamed to spring-boot-starter-aspectj, proven with Maven Central metadata and the
renamed starter's own POM -- see resilience/docs/08-starter-aop-renamed-to-starter-aspectj.md
in this same repo), and the resulting fix uses that renamed starter directly rather than
assembling spring-aop + aspectjweaver by hand. Kept as its own module rather than a new
top-level repository, alongside the existing resilience/ module for the sibling Framework-7
post.
This commit is contained in:
2026-09-18 08:40:50 +00:00
parent 604291067e
commit 320733265f
38 changed files with 1389 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# resilience4j-circuit-breaker
Companion module for the ankurm.com post [**"Resilience4j Circuit Breaker in Spring Boot 4.1:
What It's Still For"**](https://ankurm.com/resilience4j-circuit-breaker-spring-boot/). Every
claim in the post about the new `@Retryable`/`@ConcurrencyLimit` API in Spring Framework 7, and
about how it does and doesn't overlap with Resilience4j, is backed by a file under
`docs/output/` produced by a real test run against real Maven Central artifacts.
Lives in this container repo (not as its own top-level repository) alongside
[`../resilience`](../resilience), the companion module for the sibling post on Framework 7's
built-in `@Retryable`/`@ConcurrencyLimit`. The two modules overlap in theme by design — this one
is entered from "I want a circuit breaker," that one from "what did Framework 7 just ship" — and
intentionally keep separate demo apps rather than sharing one, since each post's transcripts need
to stay independently reproducible from its own module.
## Versions (verified against `repo1.maven.org` maven-metadata.xml, not aggregators)
| Component | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | latest LTS |
| Spring Boot | 4.1.1 | latest GA at time of writing; 4.2.0-M1 exists but is a milestone |
| Spring Framework | 7.0.9 | latest GA; 7.1.0-M1 exists but is a milestone |
| Resilience4j | 2.4.0 (`resilience4j-spring-boot4`) | depends internally on `resilience4j-spring6`, not a "spring7" module — see [docs/01](docs/01-two-resilience-stacks.md) |
## Quickstart
```bash
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
./scripts/run.sh # starts the app on :8080 to poke at by hand
curl localhost:8080/actuator/health
curl localhost:8080/actuator/circuitbreakers
```
Requires JDK 25 and Maven. First run must be online (Maven needs to fetch plugins into the
local cache); `-o` works for subsequent builds.
## What's demonstrated where
| Area | Source | Test | Transcript |
|---|---|---|---|
| Resilience4j circuit breaker: trip, stay open, half-open, recover | [`R4jPaymentService`](src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java) | [`CircuitBreakerTripAndRecoverTest`](src/test/java/com/ankurm/resilience/r4j/CircuitBreakerTripAndRecoverTest.java) | [`01`](docs/output/01-circuitbreaker-trip.txt) |
| `@Retryable`: recovers from transient failure | [`SpringRetryablePaymentService`](src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java) | [`SpringRetryableTest`](src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java) | [`03a`](docs/output/03a-retryable-recovers.txt) |
| `@Retryable`: no memory between calls | same | same | [`03b`](docs/output/03b-retryable-no-memory.txt) |
| `@Retryable`: self-invocation trap | same | same | [`03c`](docs/output/03c-retryable-self-invocation.txt) |
| Resilience4j `@Retry` side-by-side (`maxAttempts` counts differently than `maxRetries`) | [`R4jRetryService`](src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java) | [`R4jRetryTest`](src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java) | [`03d`](docs/output/03d-r4j-retry.txt), [`03e`](docs/output/03e-r4j-retry-exhaustion.txt) |
| `@ConcurrencyLimit` BLOCK policy | [`ConcurrencyLimitedService`](src/main/java/com/ankurm/resilience/springresilience/ConcurrencyLimitedService.java) | [`ConcurrencyLimitTest`](src/test/java/com/ankurm/resilience/springresilience/ConcurrencyLimitTest.java) | [`04a`](docs/output/04a-concurrencylimit-block.txt) |
| `@ConcurrencyLimit` REJECT policy | same | same | [`04b`](docs/output/04b-concurrencylimit-reject.txt) |
| Resilience4j `@Bulkhead` (bounded wait) for comparison | [`R4jBulkheadService`](src/main/java/com/ankurm/resilience/r4j/R4jBulkheadService.java) | same | [`04c`](docs/output/04c-r4j-bulkhead-comparison.txt) |
| Real `/actuator/health` + `/actuator/circuitbreakers` with a breaker OPEN | [`R4jPaymentService`](src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java) | [`ActuatorHealthTest`](src/test/java/com/ankurm/resilience/r4j/ActuatorHealthTest.java) | [`05`](docs/output/05-actuator-health-tripped.txt) |
## Endpoints (from `scripts/run.sh`)
| Endpoint | Purpose |
|---|---|
| `GET /actuator/health` | includes circuit breaker health when `management.health.circuitbreakers.enabled=true` |
| `GET /actuator/circuitbreakers` | live circuit breaker state |
| `GET /actuator/circuitbreakerevents` | event stream of state transitions |
| `GET /actuator/metrics` | includes `resilience4j.circuitbreaker.*` Micrometer series |
There is no custom diagnostic endpoint in this repo — the standard Actuator set above already
exposes everything the post needed, so nothing has to be deleted before shipping.
**Note on `/actuator/health` in 2.4.0:** with `management.endpoint.health.show-details: always`,
`/actuator/health` reports only an aggregate `"circuitBreakers":{"status":"UNKNOWN"}` — no
per-instance breakdown. The per-breaker detail (`state`, `failureRate`, `bufferedCalls`, etc.)
lives at `/actuator/circuitbreakers` instead. Real captured output of both, side by side, with
the breaker actually OPEN: [docs/output/05-actuator-health-tripped.txt](docs/output/05-actuator-health-tripped.txt).
## Documentation chapters
1. [Two resilience stacks on one classpath](docs/01-two-resilience-stacks.md) — what moved into
Spring Framework 7 core, what didn't, and two build breaks you'll hit getting there
2. [The circuit breaker, verified](docs/02-circuit-breaker.md)
3. [@Retryable: retries, but no memory](docs/03-spring-retryable.md)
4. [@ConcurrencyLimit vs Resilience4j's Bulkhead](docs/04-concurrency-limit.md)
5. [Production checklist: which one, for what](docs/05-production-checklist.md)
## License
MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- NOT part of the main build (no parent module lists this directory). Exists only so
scripts/reproduce-starter-aop-break.sh can run `mvn compile` against it and capture
the real error. See docs/01-two-resilience-stacks.md. -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>break-demo</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- This is the dependency every pre-Boot-4 Resilience4j guide tells you to add.
It no longer exists past 4.0.0-M2. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,93 @@
# 1. Two resilience stacks on one classpath
[README](../README.md) | Next: [02-circuit-breaker.md](02-circuit-breaker.md)
Spring Boot 4.1 sits on Spring Framework 7, and Framework 7 shipped something new under
`org.springframework.resilience`: `@Retryable`, `@ConcurrencyLimit`, and the annotation that
turns them on, `@EnableResilientMethods`. None of this existed in Framework 6. This repo pins
down exactly what moved into core, what didn't, and two build breaks you will hit the moment
you try to wire it up on Boot 4 — both found by running the build, not by reading a changelog.
## What's actually in core (verified by `javap`, not by the reference docs)
`org.springframework.resilience.annotation.Retryable` (an annotation, not the unrelated
`org.springframework.core.retry.Retryable` *interface* that also ships in `spring-core` — two
classes with the same simple name in the same major version, easy to import the wrong one):
```
value(), includes(), excludes(), predicate(),
maxRetries(), maxRetriesString(),
timeout(), timeoutString(),
delay(), delayString(),
jitter(), jitterString(),
multiplier(), multiplierString(),
maxDelay(), maxDelayString(),
timeUnit()
```
`org.springframework.resilience.annotation.ConcurrencyLimit`:
```
value(), limit(), limitString(), policy() // policy: BLOCK (default) or REJECT
```
Neither annotation has a `fallbackMethod` attribute. Retrying exhausted just rethrows.
Rejecting under `REJECT` throws `org.springframework.resilience.InvocationRejectedException`
(a `java.util.concurrent.RejectedExecutionException` subtype) straight at the caller.
There is no circuit breaker class anywhere in `spring-context-7.0.9.jar` (grepped the whole
jar listing for `circuitbreaker`, `ratelimit`, `bulkhead` — zero matches). `@ConcurrencyLimit`
is the closest core has to a Resilience4j Bulkhead, and even that is a decades-old class
repurposed: `ConcurrencyLimitBeanPostProcessor$ResilienceConcurrencyThrottleInterceptor`
extends `org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor`, which has shipped
in Spring since the 1.x era.
## `@EnableResilientMethods` is not automatic
Spring Boot 4.1's autoconfigure jar carries no auto-configuration for the resilience package —
grepping `spring-boot-autoconfigure-4.1.1.jar`'s listing for "resilien" returns nothing. You
must put `@EnableResilientMethods` on a `@Configuration` class yourself (this repo puts it on
the `@SpringBootApplication` class). Skip it and the annotations are inert: no error, no log
line, the method just runs unprotected.
## Build break #1: `spring-boot-starter-aop` no longer exists
Every pre-Boot-4 Resilience4j guide, including the version of this post it replaces, tells you
to add `spring-boot-starter-aop`. On Boot 4 that dependency breaks the build outright:
```
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing.
```
`repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-aop/maven-metadata.xml`
confirms it: the last version ever published is `4.0.0-M2`, a milestone. It never reached
Boot 4.0 GA and was not revived for 4.1. The fix is `org.springframework:spring-aop` directly —
see [pom.xml](../pom.xml).
## Build break #2 (silent): no `aspectjweaver`, no proxy, no error
Swapping in `spring-aop` alone gets you a clean build and a **wrong result**. Resilience4j's
Spring integration (`resilience4j-spring6`, which is what `resilience4j-spring-boot4` actually
depends on — see below) implements `CircuitBreakerAspect`, `BulkheadAspect`, `RateLimiterAspect`
and friends as real `@Aspect` classes (confirmed with `unzip -l` on the jar). Spring's
`AnnotationAwareAspectJAutoProxyCreator` needs `org.aspectj:aspectjweaver` on the classpath to
even recognise a bean as an aspect. Without it, **zero proxies get created, for anything**
Resilience4j's annotations and Spring's own `@Retryable` both go completely inert, silently.
That is exactly what happened building this repo: `CircuitBreakerTripAndRecoverTest` failed
with the raw `DownstreamUnavailableException` propagating straight out of `R4jPaymentService`,
no fallback, no state tracking — because there was no proxy in front of it at all. Adding
`org.aspectj:aspectjweaver` fixed every failing test in the same run. `spring-boot-starter-aop`
used to bundle this for you; its replacement doesn't, and nothing tells you that.
## A naming trap in the dependency itself
`resilience4j-spring-boot4` is a real, separate artifact from `resilience4j-spring-boot3`
(both currently at `2.4.0`) — but its own POM depends on `resilience4j-spring6`, `spring-core
7.0.2`, `spring-context 7.0.2` and `spring-boot-autoconfigure 4.0.0`. The "spring-boot4" in the
artifact name is about which *Boot* generation it targets, not which internal Resilience4j
module version it's built on — that module never got renamed to "spring7". If you're grepping
your dependency tree for "spring7" expecting to find the pieces Boot 4.1 pulls in, you won't.
Next: [02-circuit-breaker.md](02-circuit-breaker.md) — the Resilience4j side, verified against
a real trip/recover run.
@@ -0,0 +1,59 @@
# 2. The circuit breaker, verified
[Previous: 01-two-resilience-stacks.md](01-two-resilience-stacks.md) | [README](../README.md) | Next: [03-spring-retryable.md](03-spring-retryable.md)
Source: [`R4jPaymentService.java`](../src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java).
Config: [`application.yml`](../src/main/resources/application.yml).
Test: [`CircuitBreakerTripAndRecoverTest.java`](../src/test/java/com/ankurm/resilience/r4j/CircuitBreakerTripAndRecoverTest.java).
Transcript: [`docs/output/01-circuitbreaker-trip.txt`](output/01-circuitbreaker-trip.txt).
## The state machine
```
CLOSED --(failure rate >= threshold, over >= minimumNumberOfCalls)--> OPEN
OPEN --(waitDurationInOpenState elapses)--> HALF_OPEN
HALF_OPEN --(permittedNumberOfCallsInHalfOpenState calls all succeed)--> CLOSED
HALF_OPEN --(a probe call fails)--> OPEN
```
This repo's config: `slidingWindowSize=10`, `minimumNumberOfCalls=5`, `failureRateThreshold=50`,
`waitDurationInOpenState=2s`, `permittedNumberOfCallsInHalfOpenState=2`.
## What the transcript actually shows
Calls 1-5 against a dead downstream: each one really calls the downstream, gets a
`DownstreamUnavailableException`, and the fallback method runs. By call 5 the breaker has seen
5 calls (the configured minimum) at a 100% failure rate and flips to `OPEN`. Call 6 is the
first call that does **not** touch the downstream at all — it gets `CallNotPermittedException`
instead, straight from the breaker, before `R4jPaymentService.pay()`'s body ever runs.
Calls 7-9 (phase 2 in the transcript) confirm the downstream call counter is frozen at 5 for
the rest of the OPEN period — three more calls, zero more downstream traffic. This is the
property `@Retryable` cannot offer on its own (see [chapter 3](03-spring-retryable.md)): a
circuit breaker remembers that the dependency is down and stops asking.
After `waitDurationInOpenState` (2s; the test waits 2.2s to be safely past it) the breaker
allows exactly `permittedNumberOfCallsInHalfOpenState` probe calls through. The downstream was
reconfigured to succeed by then, both probes pass, and the breaker closes.
## `minimumNumberOfCalls` and why it exists
A fresh breaker with one failed call and no `minimumNumberOfCalls` floor would trip on pure
noise — one deployment-time connection refused, one cold JVM. `minimumNumberOfCalls=5` here
means the first four failures cannot trip anything by themselves; the breaker needs a real
sample before it judges the dependency.
## Actuator health vs the circuitbreakers endpoint
With `management.endpoint.health.show-details: always`, `GET /actuator/health` reports only an
aggregate `"circuitBreakers":{"status":"UNKNOWN"}` in this version — no per-instance detail.
The full breakdown (`state`, `failureRate`, `bufferedCalls`, `notPermittedCalls`, ...) is at
`GET /actuator/circuitbreakers` instead. Both captured live, breaker actually OPEN, via
[`ActuatorHealthTest.java`](../src/test/java/com/ankurm/resilience/r4j/ActuatorHealthTest.java):
[`05-actuator-health-tripped.txt`](output/05-actuator-health-tripped.txt).
- Resilience4j reference: [CircuitBreaker](https://resilience4j.readme.io/docs/circuitbreaker) (`rel=nofollow`)
- The actual states and transition rules, from the CircuitBreaker interface itself, are worth
reading directly rather than from a diagram: `io.github.resilience4j.circuitbreaker.CircuitBreaker.State`.
Next: [03-spring-retryable.md](03-spring-retryable.md).
@@ -0,0 +1,64 @@
# 3. @Retryable: retries, but no memory
[Previous: 02-circuit-breaker.md](02-circuit-breaker.md) | [README](../README.md) | Next: [04-concurrency-limit.md](04-concurrency-limit.md)
Source: [`SpringRetryablePaymentService.java`](../src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java).
Tests: [`SpringRetryableTest.java`](../src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java).
Transcripts: [`03a`](output/03a-retryable-recovers.txt), [`03b`](output/03b-retryable-no-memory.txt), [`03c`](output/03c-retryable-self-invocation.txt).
## The annotation
```java
@Retryable(maxRetries = 3, delay = 200, multiplier = 2.0, timeUnit = TimeUnit.MILLISECONDS)
public String pay(String orderId) {
return downstream.call();
}
```
`maxRetries` is retries *after* the initial attempt, so `maxRetries=3` means up to 4 total
attempts — confirmed by `03b-retryable-no-memory.txt`, where a permanently-dead downstream is
called exactly 4 times per top-level `pay()` call. `delay`/`multiplier` give exponential
backoff (200ms, then 400ms, matching the ~603ms elapsed time in `03a` for a call that succeeds
on its 3rd attempt); `jitter` and `maxDelay` exist for the same reasons Resilience4j's
`Retry.retryExceptions`-style config has them, but this repo doesn't exercise them — see the
attribute list in [chapter 1](01-two-resilience-stacks.md).
There is no `fallbackMethod`. When retries are exhausted, the original exception is rethrown to
the caller as-is (`03b` asserts the thrown type is `FlakyDownstream.DownstreamUnavailableException`,
not some wrapper).
## The no-memory problem, demonstrated
`03b-retryable-no-memory.txt` calls `pay("order-A")` against a downstream that never recovers,
exhausts all 4 attempts, and throws. It then calls `pay("order-B")` — a completely separate
top-level call — immediately after. The transcript shows **another 4 downstream calls**, not
zero. Compare with [`01-circuitbreaker-trip.txt`](output/01-circuitbreaker-trip.txt): after a
Resilience4j breaker trips, calls 7 through 9 add zero downstream traffic, because the breaker
carries state between calls that `@Retryable` structurally cannot: each invocation gets its own
fresh `RetryPolicy` execution with no memory of the last one.
This is not a bug in `@Retryable` — it is not trying to be a circuit breaker. It is the
specific gap the post is about: retry-with-backoff moved into core, but "stop calling a
dependency you already know is down" did not.
## Same word, different arithmetic
Resilience4j's own `@Retry` sits right next to `@Retryable` in this repo for a direct
comparison: [`R4jRetryService.java`](../src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java),
[`R4jRetryTest.java`](../src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java).
[`03e-r4j-retry-exhaustion.txt`](output/03e-r4j-retry-exhaustion.txt) configures
`maxAttempts=3` against a permanently-dead downstream and counts exactly 3 calls before it
gives up. Core's `@Retryable(maxRetries=3)` against the same permanently-dead downstream
(`03b`) makes 4 calls. Resilience4j's `maxAttempts` counts the initial call; core's `maxRetries`
does not. Porting a number from one config to the other by name alone is off by one.
## The self-invocation trap still applies
`@Retryable` is proxy-based, exactly like Resilience4j's annotations, exactly like Spring's own
`@Transactional` and `@Async`. Calling an annotated method on `this` from inside the same bean
bypasses the proxy: [`03c-retryable-self-invocation.txt`](output/03c-retryable-self-invocation.txt)
shows `payViaSelfInvocation()` making exactly one downstream call before throwing — no retry at
all — because `this.pay(...)` never goes through the advised bean. If you've been burned by this
with Resilience4j before, moving to core Spring buys you nothing here: same proxy model, same trap.
Next: [04-concurrency-limit.md](04-concurrency-limit.md).
@@ -0,0 +1,40 @@
# 4. @ConcurrencyLimit vs Resilience4j's Bulkhead
[Previous: 03-spring-retryable.md](03-spring-retryable.md) | [README](../README.md) | Next: [05-production-checklist.md](05-production-checklist.md)
Source: [`ConcurrencyLimitedService.java`](../src/main/java/com/ankurm/resilience/springresilience/ConcurrencyLimitedService.java),
[`R4jBulkheadService.java`](../src/main/java/com/ankurm/resilience/r4j/R4jBulkheadService.java).
Tests: [`ConcurrencyLimitTest.java`](../src/test/java/com/ankurm/resilience/springresilience/ConcurrencyLimitTest.java).
Transcripts: [`04a`](output/04a-concurrencylimit-block.txt), [`04b`](output/04b-concurrencylimit-reject.txt), [`04c`](output/04c-r4j-bulkhead-comparison.txt).
Both cap concurrent invocations of one method at 2. Both were hit with 4 concurrent callers,
each holding its slot for 300ms, in the same test run.
## BLOCK: queue, no timeout
`04a-concurrencylimit-block.txt`: all 4 callers succeed. Sorted completion times were
`[300, 300, 597, 600]` ms — two callers finish almost immediately, the other two only after a
slot frees, for a total wall time of ~601ms instead of the ~300ms it would take with no limit
at all. There is no attribute on `@ConcurrencyLimit` to bound how long a blocked caller waits;
it queues until a slot opens, however long that takes.
## REJECT: fail fast, no queue
`04b-concurrencylimit-reject.txt`, `policy = ConcurrencyLimit.ThrottlePolicy.REJECT`: 2 callers
succeed in ~300ms, and the other 2 are rejected in ~0ms — not made to wait at all. The exception
is `org.springframework.resilience.InvocationRejectedException`, found with `javap` on
`ConcurrencyLimitBeanPostProcessor$RejectingConcurrencyThrottleInterceptor.onAccessRejected`
it is not documented on the `@ConcurrencyLimit` annotation itself.
## What Resilience4j's Bulkhead adds: a bounded wait
`04c-r4j-bulkhead-comparison.txt` runs the identical 4-caller/300ms scenario against a
Resilience4j `@Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms)`. Two callers finish
normally around 300ms; the other two wait up to `maxWaitDuration` (observed: 111ms and 117ms,
close to the configured 100ms) and are rejected via the fallback method — a third option that
sits between Spring's BLOCK (wait forever) and REJECT (never wait): **wait, but only briefly**.
Neither `@ConcurrencyLimit` policy offers that middle ground.
<blockquote style="background:#f4f5f7;border:1px solid #e2e5ea;border-left:4px solid #b7bec9;border-radius:6px;padding:16px 20px;"><strong>Picking between them.</strong> If you want callers to queue briefly rather than either wait forever or fail instantly, Resilience4j's Bulkhead with a short <code>maxWaitDuration</code> is still the only one of the three that does it. If unlimited blocking is actually fine — e.g. limiting concurrent writers to a single-threaded resource — <code>@ConcurrencyLimit</code>'s BLOCK policy is one annotation and one less dependency.</blockquote>
Next: [05-production-checklist.md](05-production-checklist.md).
@@ -0,0 +1,41 @@
# 5. Production checklist: which one, for what
[Previous: 04-concurrency-limit.md](04-concurrency-limit.md) | [README](../README.md)
A decision list, built from what chapters 1-4 actually demonstrated rather than from either
library's marketing:
- **Need a circuit breaker (state that remembers a dependency is down across calls)?**
Resilience4j. Nothing in Spring Framework 7 core does this — verified by grepping
`spring-context-7.0.9.jar` for `circuitbreaker` (zero matches). See [chapter 1](01-two-resilience-stacks.md).
- **Need declarative retry with backoff, and don't already depend on Resilience4j?**
`@Retryable` from core is genuinely enough — one annotation, no extra dependency, verified
attribute set in [chapter 1](01-two-resilience-stacks.md). Remember it has no fallback and no
memory between calls ([chapter 3](03-spring-retryable.md)).
- **Need a rate limiter (calls per second, not concurrent calls)?** Resilience4j. There is no
rate limiter in Framework 7 core at all.
- **Need to cap concurrency, and unlimited blocking for the overflow is acceptable?**
`@ConcurrencyLimit(policy = BLOCK)` — one annotation.
- **Need to cap concurrency with a bounded wait before giving up?** Resilience4j's Bulkhead
with `maxWaitDuration``@ConcurrencyLimit` has no equivalent ([chapter 4](04-concurrency-limit.md)).
- **Need Actuator health/metrics integration, a dashboard, Micrometer gauges per instance?**
Resilience4j — `management.health.circuitbreakers.enabled=true` and the
`resilience4j_circuitbreaker_*` Micrometer series have no equivalent for the core annotations.
- **Migrating off Resilience4j specifically to cut a dependency?** You can drop it only for the
retry and simple-throttle cases above. Circuit breaking and rate limiting are not replaced;
they're just gone if you remove the dependency.
## Gotchas that apply to both, not just one
- `@EnableResilientMethods` is not auto-configured by Boot 4.1 — add it yourself ([chapter 1](01-two-resilience-stacks.md)).
- `spring-boot-starter-aop` does not exist on Boot 4 — use `spring-aop` directly, and add
`aspectjweaver` explicitly if you're using Resilience4j's `@Aspect`-based integration, or
the annotations will silently do nothing ([chapter 1](01-two-resilience-stacks.md)).
- Self-invocation bypasses both, silently, the same way it always has for `@Transactional` and
`@Async` ([chapter 3](03-spring-retryable.md)).
## Before shipping this repo's app
Nothing here needs removing before production — there's no diagnostic endpoint exposing
internals, just the standard Actuator `health`/`metrics`/`circuitbreakers` set, which is meant
to be exposed (behind auth) in production anyway.
@@ -0,0 +1,13 @@
[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 19, column 17
@
[ERROR] The build could not read 1 project -> [Help 1]
[ERROR]
[ERROR] The project com.ankurm:break-demo:1.0.0 (/home/claude/work/repos/resilience-boot4-demo/broken-example/pom.xml) has 1 error
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 19, column 17
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/ProjectBuildingException
@@ -0,0 +1,24 @@
Resilience4j circuit breaker: trip, stay open, half-open, recover
=================================================================
Config: slidingWindowSize=10, minimumNumberOfCalls=5, failureRateThreshold=50%, waitDurationInOpenState=2s
-- Phase 1: 6 calls against a dead downstream (only 6, to satisfy minimumNumberOfCalls=5) --
call 1 -> FALLBACK for order-1: DownstreamUnavailableException - payment-gateway rejected call #1 [breaker state=CLOSED]
call 2 -> FALLBACK for order-2: DownstreamUnavailableException - payment-gateway rejected call #2 [breaker state=CLOSED]
call 3 -> FALLBACK for order-3: DownstreamUnavailableException - payment-gateway rejected call #3 [breaker state=CLOSED]
call 4 -> FALLBACK for order-4: DownstreamUnavailableException - payment-gateway rejected call #4 [breaker state=CLOSED]
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]
Breaker state after 6 failing calls: OPEN (downstream was actually called 5 times)
-- Phase 2: 3 more calls while OPEN — these must NOT reach the downstream --
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]
Downstream call count unchanged (5) -- the breaker short-circuited all 3 calls without touching the downstream.
-- Phase 3: wait 2.2s for waitDurationInOpenState, downstream now recovers, probe with permittedNumberOfCallsInHalfOpenState=2 --
half-open probe 1 -> OK (call #1) [breaker state=HALF_OPEN]
half-open probe 2 -> OK (call #2) [breaker state=CLOSED]
Breaker state after 2 successful half-open probes: CLOSED
@@ -0,0 +1,6 @@
@Retryable(maxRetries=3, delay=200ms, multiplier=2.0): recovering from 2 transient failures
===========================================================================================
downstream configured to fail its first 2 calls, then succeed
result: OK (call #3)
downstream was actually called 3 times
elapsed: ~601ms (expect >= 200ms delay before the 2nd attempt, plus backoff before the 3rd)
@@ -0,0 +1,12 @@
@Retryable against a permanently-dead downstream: two consecutive calls, no shared state
========================================================================================
-- Call 1: pay("order-A") --
threw FlakyDownstream.DownstreamUnavailableException after exhausting retries
downstream calls so far: 4 (1 initial attempt + 3 retries = 4 expected)
-- Call 2: pay("order-B"), immediately after Call 1 exhausted its retries --
downstream calls so far: 8 (another 4 attempts, not fast-failed)
Contrast with docs/output/01-circuitbreaker-trip.txt: there, calls 7-9 after the trip
added ZERO downstream calls. Here, call 2 pays the same 4-attempt cost as call 1.
@Retryable has no OPEN state -- it cannot tell you 'this dependency is currently down'.
@@ -0,0 +1,4 @@
@Retryable via self-invocation: the AOP proxy trap, same one that bites Resilience4j
====================================================================================
Calling payViaSelfInvocation(...), which calls this.pay(...) from inside the same bean.
downstream calls: 1 (expected 1 -- no retry happened; the proxy was bypassed)
@@ -0,0 +1,6 @@
Resilience4j @Retry(maxAttempts=3, waitDuration=200ms, exponentialBackoffMultiplier=2): recovering from 1 transient failure
===========================================================================================================================
downstream configured to fail its first call, then succeed
result: OK (call #2)
downstream was actually called 2 times
elapsed: ~206ms (expect >= 200ms wait before the 2nd attempt)
@@ -0,0 +1,8 @@
Resilience4j @Retry(maxAttempts=3) against a permanently-dead downstream: counting convention
=============================================================================================
maxAttempts=3, downstream permanently down
downstream calls before giving up: 3
Resilience4j's maxAttempts is the TOTAL call count (initial attempt included): 3, not 4.
Core's @Retryable(maxRetries=3) is 3 retries AFTER the initial attempt: 4 total
(see docs/output/03b-retryable-no-memory.txt). Same-sounding config, different arithmetic --
porting a maxRetries value from one to the other by name alone is off by one.
@@ -0,0 +1,7 @@
@ConcurrencyLimit(limit=2, policy=BLOCK), 4 concurrent callers, each sleeps 300ms
=================================================================================
per-caller completion time (ms), sorted: [300, 300, 598, 600]
total wall time for all 4 callers: 601ms
all 4 calls succeeded (BLOCK never rejects): true
expectation: with limit=2 and 300ms per call, 4 callers must take roughly 2x300=600ms+,
not ~300ms as they would with no limit at all.
@@ -0,0 +1,10 @@
@ConcurrencyLimit(limit=2, policy=REJECT), 4 concurrent callers, each sleeps 300ms
==================================================================================
OK in 301ms
REJECTED (InvocationRejectedException) in 0ms
OK in 300ms
REJECTED (InvocationRejectedException) in 0ms
OK: 2, REJECTED: 2 (expected 2 and 2 with limit=2, 4 callers)
Rejection throws org.springframework.resilience.InvocationRejectedException
(a RejectedExecutionException subtype) -- confirmed by javap, not documented on the annotation itself.
@@ -0,0 +1,9 @@
Resilience4j @Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms), same 4-caller/300ms shape
==============================================================================================
REJECTED:c0 in 112ms
done:c1 in 307ms
done:c2 in 303ms
REJECTED:c3 in 101ms
REJECTED count: 2 -- these callers waited up to maxWaitDuration=100ms for a slot,
then gave up and ran the fallback method, instead of blocking indefinitely like @ConcurrencyLimit's BLOCK policy.
@@ -0,0 +1,7 @@
Real /actuator/health and /actuator/circuitbreakers, captured over HTTP with the breaker actually OPEN
======================================================================================================
-- GET /actuator/health --
{"components":{"circuitBreakers":{"status":"UNKNOWN"},"diskSpace":{"details":{"total":270553174016,"free":31630450688,"threshold":10485760,"path":"/home/claude/work/repos/spring-boot-demo-clone/resilience4j-circuit-breaker/.","exists":true},"status":"UP"},"livenessState":{"status":"UP"},"ping":{"status":"UP"},"readinessState":{"status":"UP"},"ssl":{"details":{"expiringChains":[],"invalidChains":[],"validChains":[]},"status":"UP"}},"groups":["liveness","readiness"],"status":"UP"}
-- GET /actuator/circuitbreakers --
{"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"}}}
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>resilience-boot4-demo</artifactId>
<version>1.0.0</version>
<name>resilience-boot4-demo</name>
<description>Resilience4j vs Spring Framework 7's built-in @Retryable/@ConcurrencyLimit, on Spring Boot 4.1</description>
<properties>
<java.version>25</java.version>
<resilience4j.version>2.4.0</resilience4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- spring-boot-starter-aop was RENAMED, not removed. Maven Central's maven-metadata.xml for
spring-boot-starter-aop stops at 4.0.0-M2; spring-boot-starter-aspectj's starts at
4.0.0-M3 and carries through 4.0.0 GA to 4.1.1 and beyond. Its published POM (verified via
repo1.maven.org/.../spring-boot-starter-aspectj/4.1.1/spring-boot-starter-aspectj-4.1.1.pom)
depends on exactly org.springframework:spring-aop:7.0.9 and org.aspectj:aspectjweaver:1.9.25.1,
the same two jars this file used to add by hand. Adding the old starter name breaks the
build (dependencies.dependency.version ... is missing) because that artifact ID is gone
from spring-boot-dependencies' BOM; the fix is the renamed starter, one line, version-managed
by the parent POM like any other starter. See docs/01-two-resilience-stacks.md. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<!-- Resilience4j, Boot-4-targeted artifact (still built on resilience4j-spring6 internally) -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot4</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Reproduces, and captures, the real build error from adding spring-boot-starter-aop
# on Spring Boot 4.1 -- the dependency every pre-Boot-4 Resilience4j guide tells you to add.
set -uo pipefail
cd "$(dirname "$0")/../broken-example"
mvn -B -q compile 2>&1 | grep -v '^Picked up\|^WARNING' > ../docs/output/00-starter-aop-build-error.txt
echo "captured to docs/output/00-starter-aop-build-error.txt:"
cat ../docs/output/00-starter-aop-build-error.txt
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real test run.
# Requires JDK 25 on PATH (or JAVA_HOME set to a JDK 25 install) and Maven.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B test
echo "docs/output/ regenerated:"
ls -1 docs/output
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Starts the app so you can poke at it by hand: curl localhost:8080/actuator/health,
# curl localhost:8080/actuator/circuitbreakers, etc. Kills any previous instance first.
set -euo pipefail
cd "$(dirname "$0")/.."
for p in $(ps -eo pid,cmd | grep '[R]esilienceBoot4DemoApplication' | awk '{print $1}'); do
kill -9 "$p" || true
done
setsid nohup mvn -B -q org.springframework.boot:spring-boot-maven-plugin:run \
> /tmp/resilience-boot4-demo.log 2>&1 < /dev/null &
echo "starting, waiting for :8080/actuator/health ..."
for i in $(seq 1 60); do
if curl -s -o /dev/null localhost:8080/actuator/health; then
echo "up."
exit 0
fi
sleep 2
done
echo "did not come up in 120s -- check /tmp/resilience-boot4-demo.log"
exit 1
@@ -0,0 +1,50 @@
package com.ankurm.resilience;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A downstream call whose failure pattern is controlled by the test, not by chance.
* A deterministic fake beats a flaky "randomly fails 30% of the time" for teaching:
* the reader can predict exactly which call will fail and check the transcript against it.
*
* See docs/01-two-resilience-stacks.md.
*/
@Component
public class FlakyDownstream {
private final AtomicInteger callCount = new AtomicInteger();
/** Calls 1..failUntilInclusive throw; calls after that succeed. Set failUntilInclusive
* to Integer.MAX_VALUE to simulate a downstream that never recovers. */
private volatile int failUntilInclusive = 0;
public void configure(int failUntilInclusive) {
this.failUntilInclusive = failUntilInclusive;
this.callCount.set(0);
}
public void reset() {
this.callCount.set(0);
}
public int callsSoFar() {
return callCount.get();
}
/** The call every service in this repo ultimately makes. */
public String call() {
int n = callCount.incrementAndGet();
if (n <= failUntilInclusive) {
throw new DownstreamUnavailableException("payment-gateway rejected call #" + n);
}
return "OK (call #" + n + ")";
}
public static class DownstreamUnavailableException extends RuntimeException {
public DownstreamUnavailableException(String message) {
super(message);
}
}
}
@@ -0,0 +1,23 @@
package com.ankurm.resilience;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.resilience.annotation.EnableResilientMethods;
/**
* Companion app for the ankurm.com post "Resilience4j on Spring Boot 4.1: what it's still for".
*
* <p>{@code @EnableResilientMethods} is required explicitly — Spring Boot 4.1's
* autoconfigure jar carries no auto-configuration class for it (verified by grepping
* spring-boot-autoconfigure-4.1.1.jar for "resilien": zero matches). The annotations
* only start intercepting once this is present on a configuration class.
*
* See docs/01-two-resilience-stacks.md.
*/
@SpringBootApplication
@EnableResilientMethods
public class ResilienceBoot4DemoApplication {
public static void main(String[] args) {
SpringApplication.run(ResilienceBoot4DemoApplication.class, args);
}
}
@@ -0,0 +1,25 @@
package com.ankurm.resilience.r4j;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
/**
* Resilience4j's semaphore Bulkhead, for direct comparison with Spring's
* {@code @ConcurrencyLimit}. Config: resilience4j.bulkhead.instances.slowOp in application.yml,
* maxWaitDuration: 100ms — a bounded wait, which {@code @ConcurrencyLimit}'s BLOCK policy does
* not offer. See docs/04-concurrency-limit.md.
*/
@Service
public class R4jBulkheadService {
@Bulkhead(name = "slowOp", fallbackMethod = "fallback")
public String slowCall(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
private String fallback(String id, long sleepMillis, BulkheadFullException ex) {
return "REJECTED:" + id;
}
}
@@ -0,0 +1,31 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
/**
* Resilience4j's circuit breaker: a state machine (CLOSED / OPEN / HALF_OPEN) that
* remembers failure history *across calls*, sitting in front of the annotated method.
*
* Config lives in application.yml under resilience4j.circuitbreaker.instances.paymentService.
* See docs/02-circuit-breaker.md.
*/
@Service
public class R4jPaymentService {
private final FlakyDownstream downstream;
public R4jPaymentService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@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() + " - " + ex.getMessage();
}
}
@@ -0,0 +1,25 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
/**
* Resilience4j's own {@code @Retry}, for direct comparison with core's {@code @Retryable}
* (see {@link com.ankurm.resilience.springresilience.SpringRetryablePaymentService}). Config:
* resilience4j.retry.instances.inventoryService in application.yml. See docs/03-spring-retryable.md.
*/
@Service
public class R4jRetryService {
private final FlakyDownstream downstream;
public R4jRetryService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@Retry(name = "inventoryService")
public String checkStock(String productId) {
return downstream.call();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.resilience.springresilience;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.stereotype.Service;
/**
* {@code @ConcurrencyLimit} is Spring Framework 7's version of what Resilience4j calls a
* semaphore Bulkhead: cap the number of concurrent invocations of a method. Two policies:
*
* <ul>
* <li>{@code BLOCK} (the default) — callers past the limit queue and wait.</li>
* <li>{@code REJECT} — callers past the limit get an
* {@code org.springframework.resilience.InvocationRejectedException} immediately (a
* {@link java.util.concurrent.RejectedExecutionException} subtype — verified with javap,
* not documented in the annotation's Javadoc).</li>
* </ul>
*
* Unlike Resilience4j's Bulkhead, there is no {@code maxWaitDuration} for the BLOCK policy —
* a blocked caller waits until a slot frees, with no timeout of its own. Under the hood BLOCK
* is implemented by extending {@code org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor},
* a class that has shipped in Spring since the Spring 1.x era — repurposed, not reinvented.
* See docs/04-concurrency-limit.md.
*/
@Service
public class ConcurrencyLimitedService {
@ConcurrencyLimit(limit = 2)
public String slowCallBlocking(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public String slowCallRejecting(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
}
@@ -0,0 +1,39 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.FlakyDownstream;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Spring Framework 7's own {@code @Retryable}: declarative retry with delay, multiplier
* and jitter, no separate dependency. There is no {@code fallbackMethod} attribute —
* unlike Resilience4j, exhausting retries just rethrows the last exception to the caller.
*
* And there is no circuit: each call to {@link #pay} starts its own retry loop from zero.
* Nothing here remembers that the last five calls all failed. See docs/03-spring-retryable.md.
*/
@Service
public class SpringRetryablePaymentService {
private final FlakyDownstream downstream;
public SpringRetryablePaymentService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@Retryable(maxRetries = 3, delay = 200, multiplier = 2.0, timeUnit = TimeUnit.MILLISECONDS)
public String pay(String orderId) {
return downstream.call();
}
/**
* Self-invocation trap: calling {@code pay(...)} from inside this same bean bypasses
* the AOP proxy entirely, so no retry happens — the exact same trap Resilience4j's
* annotations have via Spring AOP proxies (both are proxy-based interceptors).
*/
public String payViaSelfInvocation(String orderId) {
return this.pay(orderId);
}
}
@@ -0,0 +1,48 @@
spring:
application:
name: resilience-boot4-demo
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 2s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- com.ankurm.resilience.FlakyDownstream$DownstreamUnavailableException
bulkhead:
instances:
slowOp:
maxConcurrentCalls: 2
maxWaitDuration: 100ms
retry:
instances:
inventoryService:
maxAttempts: 3
waitDuration: 200ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
logging:
level:
com.ankurm.resilience: INFO
@@ -0,0 +1,50 @@
package com.ankurm.resilience;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
* is backed by a file produced by an assertion that would fail the build if it stopped
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
*/
public final class Transcript {
private final StringBuilder buf = new StringBuilder();
private final Path outFile;
private Transcript(String fileName) {
this.outFile = Paths.get("docs/output", fileName);
}
public static Transcript start(String fileName, String header) {
Transcript t = new Transcript(fileName);
t.line(header);
t.line("=".repeat(header.length()));
return t;
}
public Transcript line(String s) {
buf.append(s).append('\n');
return this;
}
public Transcript blank() {
buf.append('\n');
return this;
}
public void save() {
try {
Files.createDirectories(outFile.getParent());
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,54 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Captures real /actuator/health and /actuator/circuitbreakers output with a breaker
* actually OPEN, over real HTTP against a running embedded server. Output:
* docs/output/05-actuator-health-tripped.txt. See docs/05-production-checklist.md.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
class ActuatorHealthTest {
@Autowired
R4jPaymentService service;
@Autowired
FlakyDownstream downstream;
@LocalServerPort
int port;
@Test
void actuatorReportsCircuitOpenAfterRealTrip() {
Transcript t = Transcript.start("05-actuator-health-tripped.txt",
"Real /actuator/health and /actuator/circuitbreakers, captured over HTTP with the breaker actually OPEN");
downstream.configure(Integer.MAX_VALUE);
for (int i = 1; i <= 6; i++) {
service.pay("order-" + i);
}
RestClient rest = RestClient.create("http://localhost:" + port);
String health = rest.get().uri("/actuator/health").retrieve().body(String.class);
String breakers = rest.get().uri("/actuator/circuitbreakers").retrieve().body(String.class);
t.line("-- GET /actuator/health --");
t.line(health);
t.blank();
t.line("-- GET /actuator/circuitbreakers --");
t.line(breakers);
assertThat(health).contains("\"circuitBreakers\"");
assertThat(breakers).contains("\"state\":\"OPEN\"");
t.save();
}
}
@@ -0,0 +1,72 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves the claim the post makes about circuit breakers: the breaker *remembers* failure
* history across separate top-level calls, and while OPEN it does not even attempt the
* downstream call. Output: docs/output/01-circuitbreaker-trip.txt.
*/
@SpringBootTest
@DirtiesContext
class CircuitBreakerTripAndRecoverTest {
@Autowired
R4jPaymentService service;
@Autowired
FlakyDownstream downstream;
@Autowired
CircuitBreakerRegistry registry;
@Test
void tripsOpenStaysOpenThenRecoversThroughHalfOpen() throws InterruptedException {
Transcript t = Transcript.start("01-circuitbreaker-trip.txt",
"Resilience4j circuit breaker: trip, stay open, half-open, recover");
CircuitBreaker cb = registry.circuitBreaker("paymentService");
downstream.configure(Integer.MAX_VALUE); // downstream permanently down
t.line("Config: slidingWindowSize=10, minimumNumberOfCalls=5, failureRateThreshold=50%, waitDurationInOpenState=2s");
t.blank();
t.line("-- Phase 1: 6 calls against a dead downstream (only 6, to satisfy minimumNumberOfCalls=5) --");
for (int i = 1; i <= 6; i++) {
String result = service.pay("order-" + i);
t.line("call " + i + " -> " + result + " [breaker state=" + cb.getState() + "]");
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
int downstreamCallsAtTrip = downstream.callsSoFar();
t.blank();
t.line("Breaker state after 6 failing calls: " + cb.getState() + " (downstream was actually called " + downstreamCallsAtTrip + " times)");
t.blank();
t.line("-- Phase 2: 3 more calls while OPEN — these must NOT reach the downstream --");
for (int i = 7; i <= 9; i++) {
String result = service.pay("order-" + i);
t.line("call " + i + " -> " + result + " [breaker state=" + cb.getState() + ", downstream calls so far=" + downstream.callsSoFar() + "]");
}
assertThat(downstream.callsSoFar()).isEqualTo(downstreamCallsAtTrip);
t.line("Downstream call count unchanged (" + downstream.callsSoFar() + ") -- the breaker short-circuited all 3 calls without touching the downstream.");
t.blank();
t.line("-- Phase 3: wait 2.2s for waitDurationInOpenState, downstream now recovers, probe with permittedNumberOfCallsInHalfOpenState=2 --");
downstream.configure(0); // downstream now healthy
Thread.sleep(2200);
for (int i = 1; i <= 2; i++) {
String result = service.pay("probe-" + i);
t.line("half-open probe " + i + " -> " + result + " [breaker state=" + cb.getState() + "]");
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
t.line("Breaker state after 2 successful half-open probes: " + cb.getState());
t.save();
}
}
@@ -0,0 +1,60 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Resilience4j's own {@code @Retry}, same shape of test as {@code SpringRetryableTest}, for a
* direct side-by-side. Output: docs/output/03d-r4j-retry.txt.
*/
@SpringBootTest
@DirtiesContext
class R4jRetryTest {
@Autowired
R4jRetryService service;
@Autowired
FlakyDownstream downstream;
@Test
void retriesThreeTimesWithExponentialBackoffThenSucceeds() {
Transcript t = Transcript.start("03d-r4j-retry.txt",
"Resilience4j @Retry(maxAttempts=3, waitDuration=200ms, exponentialBackoffMultiplier=2): recovering from 1 transient failure");
downstream.configure(1); // first call fails, 2nd succeeds
long start = System.nanoTime();
String result = service.checkStock("sku-1");
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("downstream configured to fail its first call, then succeed");
t.line("result: " + result);
t.line("downstream was actually called " + downstream.callsSoFar() + " times");
t.line("elapsed: ~" + elapsedMs + "ms (expect >= 200ms wait before the 2nd attempt)");
assertThat(downstream.callsSoFar()).isEqualTo(2);
assertThat(result).startsWith("OK");
t.save();
}
@Test
void maxAttemptsCountsTotalAttemptsNotRetriesAfterTheFirst() {
Transcript t = Transcript.start("03e-r4j-retry-exhaustion.txt",
"Resilience4j @Retry(maxAttempts=3) against a permanently-dead downstream: counting convention");
downstream.configure(Integer.MAX_VALUE);
assertThatThrownBy(() -> service.checkStock("sku-2"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int calls = downstream.callsSoFar();
t.line("maxAttempts=3, downstream permanently down");
t.line("downstream calls before giving up: " + calls);
t.line("Resilience4j's maxAttempts is the TOTAL call count (initial attempt included): 3, not 4.");
t.line("Core's @Retryable(maxRetries=3) is 3 retries AFTER the initial attempt: 4 total");
t.line("(see docs/output/03b-retryable-no-memory.txt). Same-sounding config, different arithmetic --");
t.line("porting a maxRetries value from one to the other by name alone is off by one.");
assertThat(calls).isEqualTo(3);
t.save();
}
}
@@ -0,0 +1,150 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.Transcript;
import com.ankurm.resilience.r4j.R4jBulkheadService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.resilience.InvocationRejectedException;
import org.springframework.test.annotation.DirtiesContext;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code @ConcurrencyLimit} vs Resilience4j's Bulkhead, both capped at 2 concurrent callers,
* both hit with 4 concurrent callers each sleeping 300ms. Output: docs/output/04-concurrency-limit.txt.
*/
@SpringBootTest
@DirtiesContext
class ConcurrencyLimitTest {
@Autowired
ConcurrencyLimitedService blockingService;
@Autowired
R4jBulkheadService bulkheadService;
private static final long SLEEP_MS = 300;
private static final int CALLERS = 4;
@Test
void blockPolicyQueuesInsteadOfRejecting() throws Exception {
Transcript t = Transcript.start("04a-concurrencylimit-block.txt",
"@ConcurrencyLimit(limit=2, policy=BLOCK), 4 concurrent callers, each sleeps 300ms");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<Long>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
blockingService.slowCallBlocking("c" + id, SLEEP_MS);
return (System.nanoTime() - start) / 1_000_000;
});
}
long wallStart = System.nanoTime();
startGate.countDown();
List<Future<Long>> futures = pool.invokeAll(tasks);
long totalWallMs = (System.nanoTime() - wallStart) / 1_000_000;
pool.shutdown();
List<Long> perCallMs = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).sorted().toList();
t.line("per-caller completion time (ms), sorted: " + perCallMs);
t.line("total wall time for all 4 callers: " + totalWallMs + "ms");
t.line("all 4 calls succeeded (BLOCK never rejects): true");
t.line("expectation: with limit=2 and 300ms per call, 4 callers must take roughly 2x300=600ms+,");
t.line("not ~300ms as they would with no limit at all.");
assertThat(totalWallMs).isGreaterThanOrEqualTo(550);
t.save();
}
@Test
void rejectPolicyFailsFastInsteadOfQueuing() throws Exception {
Transcript t = Transcript.start("04b-concurrencylimit-reject.txt",
"@ConcurrencyLimit(limit=2, policy=REJECT), 4 concurrent callers, each sleeps 300ms");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<String>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
try {
blockingService.slowCallRejecting("c" + id, SLEEP_MS);
long ms = (System.nanoTime() - start) / 1_000_000;
return "OK in " + ms + "ms";
} catch (InvocationRejectedException ex) {
long ms = (System.nanoTime() - start) / 1_000_000;
return "REJECTED (" + ex.getClass().getSimpleName() + ") in " + ms + "ms";
}
});
}
startGate.countDown();
List<Future<String>> futures = pool.invokeAll(tasks);
pool.shutdown();
List<String> outcomes = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).toList();
outcomes.forEach(o -> t.line(o));
long okCount = outcomes.stream().filter(o -> o.startsWith("OK")).count();
long rejectedCount = outcomes.stream().filter(o -> o.startsWith("REJECTED")).count();
t.blank();
t.line("OK: " + okCount + ", REJECTED: " + rejectedCount + " (expected 2 and 2 with limit=2, 4 callers)");
t.line("Rejection throws org.springframework.resilience.InvocationRejectedException");
t.line("(a RejectedExecutionException subtype) -- confirmed by javap, not documented on the annotation itself.");
assertThat(okCount).isEqualTo(2);
assertThat(rejectedCount).isEqualTo(2);
t.save();
}
@Test
void resilience4jBulkheadHasABoundedWaitThatConcurrencyLimitLacks() throws Exception {
Transcript t = Transcript.start("04c-r4j-bulkhead-comparison.txt",
"Resilience4j @Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms), same 4-caller/300ms shape");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<String>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
String result = bulkheadService.slowCall("c" + id, SLEEP_MS);
long ms = (System.nanoTime() - start) / 1_000_000;
return result + " in " + ms + "ms";
});
}
startGate.countDown();
List<Future<String>> futures = pool.invokeAll(tasks, 5, TimeUnit.SECONDS);
pool.shutdown();
List<String> outcomes = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).toList();
outcomes.forEach(o -> t.line(o));
long rejected = outcomes.stream().filter(o -> o.startsWith("REJECTED")).count();
t.blank();
t.line("REJECTED count: " + rejected + " -- these callers waited up to maxWaitDuration=100ms for a slot,");
t.line("then gave up and ran the fallback method, instead of blocking indefinitely like @ConcurrencyLimit's BLOCK policy.");
assertThat(rejected).isGreaterThan(0);
t.save();
}
}
@@ -0,0 +1,86 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Proves the post's central claim about {@code @Retryable}: it retries, but it does not
* remember. Two back-to-back calls against a permanently-dead downstream each pay the full
* retry cost, unlike a tripped circuit breaker which fails fast on the very next call.
* Output: docs/output/03-spring-retryable.txt.
*/
@SpringBootTest
@DirtiesContext
class SpringRetryableTest {
@Autowired
SpringRetryablePaymentService service;
@Autowired
FlakyDownstream downstream;
@Test
void retriesThroughTransientFailureThenSucceeds() {
Transcript t = Transcript.start("03a-retryable-recovers.txt",
"@Retryable(maxRetries=3, delay=200ms, multiplier=2.0): recovering from 2 transient failures");
downstream.configure(2); // first 2 calls fail, 3rd succeeds
long start = System.nanoTime();
String result = service.pay("order-1");
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("downstream configured to fail its first 2 calls, then succeed");
t.line("result: " + result);
t.line("downstream was actually called " + downstream.callsSoFar() + " times");
t.line("elapsed: ~" + elapsedMs + "ms (expect >= 200ms delay before the 2nd attempt, plus backoff before the 3rd)");
assertThat(downstream.callsSoFar()).isEqualTo(3);
assertThat(result).startsWith("OK");
t.save();
}
@Test
void exhaustsRetriesAndHasNoMemoryOfIt() {
Transcript t = Transcript.start("03b-retryable-no-memory.txt",
"@Retryable against a permanently-dead downstream: two consecutive calls, no shared state");
downstream.configure(Integer.MAX_VALUE);
t.line("-- Call 1: pay(\"order-A\") --");
assertThatThrownBy(() -> service.pay("order-A"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int callsAfterFirst = downstream.callsSoFar();
t.line("threw FlakyDownstream.DownstreamUnavailableException after exhausting retries");
t.line("downstream calls so far: " + callsAfterFirst + " (1 initial attempt + 3 retries = 4 expected)");
assertThat(callsAfterFirst).isEqualTo(4);
t.blank();
t.line("-- Call 2: pay(\"order-B\"), immediately after Call 1 exhausted its retries --");
assertThatThrownBy(() -> service.pay("order-B"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int callsAfterSecond = downstream.callsSoFar();
t.line("downstream calls so far: " + callsAfterSecond + " (another 4 attempts, not fast-failed)");
assertThat(callsAfterSecond).isEqualTo(8);
t.blank();
t.line("Contrast with docs/output/01-circuitbreaker-trip.txt: there, calls 7-9 after the trip");
t.line("added ZERO downstream calls. Here, call 2 pays the same 4-attempt cost as call 1.");
t.line("@Retryable has no OPEN state -- it cannot tell you 'this dependency is currently down'.");
t.save();
}
@Test
void selfInvocationBypassesTheProxySilently() {
Transcript t = Transcript.start("03c-retryable-self-invocation.txt",
"@Retryable via self-invocation: the AOP proxy trap, same one that bites Resilience4j");
downstream.configure(Integer.MAX_VALUE);
t.line("Calling payViaSelfInvocation(...), which calls this.pay(...) from inside the same bean.");
assertThatThrownBy(() -> service.payViaSelfInvocation("order-Z"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int calls = downstream.callsSoFar();
t.line("downstream calls: " + calls + " (expected 1 -- no retry happened; the proxy was bypassed)");
assertThat(calls).isEqualTo(1);
t.save();
}
}