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,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"}}}
|
||||
Reference in New Issue
Block a user