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

Companion module for the rewritten post 'Resilience4j Circuit Breaker in Spring Boot 4.1:
What It's Still For', reworked around Framework 7 now shipping @Retryable/@ConcurrencyLimit
in core. Covers what's still Resilience4j's job (circuit breaker, rate limiter, bulkhead's
bounded wait, fallback methods, Actuator/Micrometer metrics), the off-by-one between
maxAttempts and maxRetries, and two Boot-4.1 build breaks: spring-boot-starter-aop no longer
exists (renamed to spring-boot-starter-aspectj, proven with Maven Central metadata and the
renamed starter's own POM -- see resilience/docs/08-starter-aop-renamed-to-starter-aspectj.md
in this same repo), and the resulting fix uses that renamed starter directly rather than
assembling spring-aop + aspectjweaver by hand. Kept as its own module rather than a new
top-level repository, alongside the existing resilience/ module for the sibling Framework-7
post.
This commit is contained in:
2026-09-18 08:40:50 +00:00
parent 604291067e
commit 320733265f
38 changed files with 1389 additions and 0 deletions
@@ -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"}}}