diff --git a/resilience4j-circuit-breaker/.gitignore b/resilience4j-circuit-breaker/.gitignore
new file mode 100644
index 0000000..b2e433c
--- /dev/null
+++ b/resilience4j-circuit-breaker/.gitignore
@@ -0,0 +1,4 @@
+target/
+*.class
+.idea/
+*.iml
diff --git a/resilience4j-circuit-breaker/LICENSE b/resilience4j-circuit-breaker/LICENSE
new file mode 100644
index 0000000..aa5473f
--- /dev/null
+++ b/resilience4j-circuit-breaker/LICENSE
@@ -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.
diff --git a/resilience4j-circuit-breaker/README.md b/resilience4j-circuit-breaker/README.md
new file mode 100644
index 0000000..8d165f8
--- /dev/null
+++ b/resilience4j-circuit-breaker/README.md
@@ -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).
diff --git a/resilience4j-circuit-breaker/broken-example/pom.xml b/resilience4j-circuit-breaker/broken-example/pom.xml
new file mode 100644
index 0000000..66c9afa
--- /dev/null
+++ b/resilience4j-circuit-breaker/broken-example/pom.xml
@@ -0,0 +1,24 @@
+
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+ com.ankurm
+ break-demo
+ 1.0.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+
+
+
diff --git a/resilience4j-circuit-breaker/docs/01-two-resilience-stacks.md b/resilience4j-circuit-breaker/docs/01-two-resilience-stacks.md
new file mode 100644
index 0000000..c85993a
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/01-two-resilience-stacks.md
@@ -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.
diff --git a/resilience4j-circuit-breaker/docs/02-circuit-breaker.md b/resilience4j-circuit-breaker/docs/02-circuit-breaker.md
new file mode 100644
index 0000000..d2833d3
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/02-circuit-breaker.md
@@ -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).
diff --git a/resilience4j-circuit-breaker/docs/03-spring-retryable.md b/resilience4j-circuit-breaker/docs/03-spring-retryable.md
new file mode 100644
index 0000000..5a78bcc
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/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).
diff --git a/resilience4j-circuit-breaker/docs/04-concurrency-limit.md b/resilience4j-circuit-breaker/docs/04-concurrency-limit.md
new file mode 100644
index 0000000..5a04450
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/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.
+
+
Picking between them. If you want callers to queue briefly rather than either wait forever or fail instantly, Resilience4j's Bulkhead with a short maxWaitDuration 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 — @ConcurrencyLimit's BLOCK policy is one annotation and one less dependency.
+
+Next: [05-production-checklist.md](05-production-checklist.md).
diff --git a/resilience4j-circuit-breaker/docs/05-production-checklist.md b/resilience4j-circuit-breaker/docs/05-production-checklist.md
new file mode 100644
index 0000000..b46aa00
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/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.
diff --git a/resilience4j-circuit-breaker/docs/output/00-starter-aop-build-error.txt b/resilience4j-circuit-breaker/docs/output/00-starter-aop-build-error.txt
new file mode 100644
index 0000000..4582045
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/00-starter-aop-build-error.txt
@@ -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
diff --git a/resilience4j-circuit-breaker/docs/output/01-circuitbreaker-trip.txt b/resilience4j-circuit-breaker/docs/output/01-circuitbreaker-trip.txt
new file mode 100644
index 0000000..bc2d17f
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/01-circuitbreaker-trip.txt
@@ -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
diff --git a/resilience4j-circuit-breaker/docs/output/03a-retryable-recovers.txt b/resilience4j-circuit-breaker/docs/output/03a-retryable-recovers.txt
new file mode 100644
index 0000000..12040b3
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/03a-retryable-recovers.txt
@@ -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)
diff --git a/resilience4j-circuit-breaker/docs/output/03b-retryable-no-memory.txt b/resilience4j-circuit-breaker/docs/output/03b-retryable-no-memory.txt
new file mode 100644
index 0000000..cde47b8
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/03b-retryable-no-memory.txt
@@ -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'.
diff --git a/resilience4j-circuit-breaker/docs/output/03c-retryable-self-invocation.txt b/resilience4j-circuit-breaker/docs/output/03c-retryable-self-invocation.txt
new file mode 100644
index 0000000..41deeed
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/03c-retryable-self-invocation.txt
@@ -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)
diff --git a/resilience4j-circuit-breaker/docs/output/03d-r4j-retry.txt b/resilience4j-circuit-breaker/docs/output/03d-r4j-retry.txt
new file mode 100644
index 0000000..047332f
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/03d-r4j-retry.txt
@@ -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)
diff --git a/resilience4j-circuit-breaker/docs/output/03e-r4j-retry-exhaustion.txt b/resilience4j-circuit-breaker/docs/output/03e-r4j-retry-exhaustion.txt
new file mode 100644
index 0000000..2a6d614
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/03e-r4j-retry-exhaustion.txt
@@ -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.
diff --git a/resilience4j-circuit-breaker/docs/output/04a-concurrencylimit-block.txt b/resilience4j-circuit-breaker/docs/output/04a-concurrencylimit-block.txt
new file mode 100644
index 0000000..b1bd862
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/04a-concurrencylimit-block.txt
@@ -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.
diff --git a/resilience4j-circuit-breaker/docs/output/04b-concurrencylimit-reject.txt b/resilience4j-circuit-breaker/docs/output/04b-concurrencylimit-reject.txt
new file mode 100644
index 0000000..5fcdadf
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/04b-concurrencylimit-reject.txt
@@ -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.
diff --git a/resilience4j-circuit-breaker/docs/output/04c-r4j-bulkhead-comparison.txt b/resilience4j-circuit-breaker/docs/output/04c-r4j-bulkhead-comparison.txt
new file mode 100644
index 0000000..7584c64
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/04c-r4j-bulkhead-comparison.txt
@@ -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.
diff --git a/resilience4j-circuit-breaker/docs/output/05-actuator-health-tripped.txt b/resilience4j-circuit-breaker/docs/output/05-actuator-health-tripped.txt
new file mode 100644
index 0000000..0ce8f2a
--- /dev/null
+++ b/resilience4j-circuit-breaker/docs/output/05-actuator-health-tripped.txt
@@ -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"}}}
diff --git a/resilience4j-circuit-breaker/pom.xml b/resilience4j-circuit-breaker/pom.xml
new file mode 100644
index 0000000..f6b2dd9
--- /dev/null
+++ b/resilience4j-circuit-breaker/pom.xml
@@ -0,0 +1,70 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ resilience-boot4-demo
+ 1.0.0
+ resilience-boot4-demo
+ Resilience4j vs Spring Framework 7's built-in @Retryable/@ConcurrencyLimit, on Spring Boot 4.1
+
+
+ 25
+ 2.4.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-aspectj
+
+
+
+
+ io.github.resilience4j
+ resilience4j-spring-boot4
+ ${resilience4j.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/resilience4j-circuit-breaker/scripts/reproduce-starter-aop-break.sh b/resilience4j-circuit-breaker/scripts/reproduce-starter-aop-break.sh
new file mode 100755
index 0000000..aa3043c
--- /dev/null
+++ b/resilience4j-circuit-breaker/scripts/reproduce-starter-aop-break.sh
@@ -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
diff --git a/resilience4j-circuit-breaker/scripts/run-all.sh b/resilience4j-circuit-breaker/scripts/run-all.sh
new file mode 100755
index 0000000..0b040ee
--- /dev/null
+++ b/resilience4j-circuit-breaker/scripts/run-all.sh
@@ -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
diff --git a/resilience4j-circuit-breaker/scripts/run.sh b/resilience4j-circuit-breaker/scripts/run.sh
new file mode 100755
index 0000000..35baf5d
--- /dev/null
+++ b/resilience4j-circuit-breaker/scripts/run.sh
@@ -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
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/FlakyDownstream.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/FlakyDownstream.java
new file mode 100644
index 0000000..861693f
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/FlakyDownstream.java
@@ -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);
+ }
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/ResilienceBoot4DemoApplication.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/ResilienceBoot4DemoApplication.java
new file mode 100644
index 0000000..cd543ae
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/ResilienceBoot4DemoApplication.java
@@ -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".
+ *
+ * {@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);
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jBulkheadService.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jBulkheadService.java
new file mode 100644
index 0000000..a6e09ff
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jBulkheadService.java
@@ -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;
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java
new file mode 100644
index 0000000..35834fa
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jPaymentService.java
@@ -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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java
new file mode 100644
index 0000000..2b9caf1
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java
@@ -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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/ConcurrencyLimitedService.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/ConcurrencyLimitedService.java
new file mode 100644
index 0000000..5b7e4ab
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/ConcurrencyLimitedService.java
@@ -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:
+ *
+ *
+ * - {@code BLOCK} (the default) — callers past the limit queue and wait.
+ * - {@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).
+ *
+ *
+ * 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;
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java
new file mode 100644
index 0000000..6c7121c
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java
@@ -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);
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/main/resources/application.yml b/resilience4j-circuit-breaker/src/main/resources/application.yml
new file mode 100644
index 0000000..419a0a7
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/main/resources/application.yml
@@ -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
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/Transcript.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/Transcript.java
new file mode 100644
index 0000000..5b40fda
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/Transcript.java
@@ -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);
+ }
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/ActuatorHealthTest.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/ActuatorHealthTest.java
new file mode 100644
index 0000000..232c6d5
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/ActuatorHealthTest.java
@@ -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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/CircuitBreakerTripAndRecoverTest.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/CircuitBreakerTripAndRecoverTest.java
new file mode 100644
index 0000000..e520663
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/CircuitBreakerTripAndRecoverTest.java
@@ -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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java
new file mode 100644
index 0000000..f12fd51
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java
@@ -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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/ConcurrencyLimitTest.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/ConcurrencyLimitTest.java
new file mode 100644
index 0000000..d4eb581
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/ConcurrencyLimitTest.java
@@ -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> 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> futures = pool.invokeAll(tasks);
+ long totalWallMs = (System.nanoTime() - wallStart) / 1_000_000;
+ pool.shutdown();
+
+ List 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> 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> futures = pool.invokeAll(tasks);
+ pool.shutdown();
+
+ List 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> 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> futures = pool.invokeAll(tasks, 5, TimeUnit.SECONDS);
+ pool.shutdown();
+
+ List 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();
+ }
+}
diff --git a/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java
new file mode 100644
index 0000000..b194c87
--- /dev/null
+++ b/resilience4j-circuit-breaker/src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java
@@ -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();
+ }
+}