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:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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.
|
||||
@@ -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"}}}
|
||||
@@ -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
@@ -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
|
||||
Executable
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+150
@@ -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();
|
||||
}
|
||||
}
|
||||
+86
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user