Companion code for "Spring Framework 7's Built-in Resilience: @Retryable, @ConcurrencyLimit, and What's Left for Resilience4j". Every retry counted by recording real invocations: defaults, backoff and jitter, timeout, reactive and CompletableFuture returns, the concurrency limit's BLOCK and REJECT policies, retries around transactions, composition with Resilience4j 2.4.0, and the annotation API across 7.0.0-7.0.9. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
3.5 KiB
6. What is left for Resilience4j
← 5. Retry and transactions · Index · Next: 7. Migrating from spring-retry →
| Need | Spring Framework 7.0.9 | Resilience4j 2.4.0 |
|---|---|---|
| Retry with back-off and jitter | @Retryable, RetryTemplate |
@Retry |
| Limit concurrent calls | @ConcurrencyLimit (BLOCK/REJECT) |
@Bulkhead (semaphore or thread pool, with a wait timeout) |
| Circuit breaker | - | @CircuitBreaker |
| Rate limiter (calls per period) | - | @RateLimiter |
| Time limit on a call | - (timeout is a retry budget) |
@TimeLimiter (async return types) |
| Declarative fallback / recovery | - (@Recover request declined, spring-framework#35685) |
fallbackMethod |
| Metrics | none built in | resilience4j.* meters, Actuator endpoints, health |
| Per-instance config in properties | placeholders in *String attributes |
resilience4j.<module>.instances.<name>.* |
The breaker, measured
sliding-window-size: 10, minimum-number-of-calls: 5, failure-rate-threshold: 50, 2 s open
(r4j-breaker.txt): five failures reach the downstream, the breaker
opens, calls 6-8 fail in microseconds with CallNotPermittedException, and after 2.1 s the first
call goes through (HALF_OPEN), the second closes it.
Resilience4j's own defaults are slidingWindowSize=100 and minimumNumberOfCalls=100 (read
from CircuitBreakerConfig.ofDefaults()), with 60 s open. A breaker left on defaults cannot open
until it has seen a hundred calls - in a low-traffic service, effectively never.
maxAttempts vs maxRetries
r4j-retry.txt: max-attempts: 3 → 3 invocations. Spring's
maxRetries = 3 → 4. Same number, different meaning.
Both libraries on one method
chargeWithBoth() carries @CircuitBreaker(name="combo") and Spring's @Retryable(maxRetries=3, delay=10)
(r4j-combo.txt, proxy-payments.txt):
"call 1": {
"caller got": "TransientException: payment gateway 503 (call 4)",
"method body ran": 4,
"elapsed (ms)": 35,
"breaker: buffered / failed / not permitted": "4 / 4 / 0",
"breaker state": "CLOSED"
},
"call 2": {
"caller got": "CallNotPermittedException: CircuitBreaker 'combo' is OPEN and does not permit further calls",
"method body ran": 1,
"elapsed (ms)": 33,
"breaker: buffered / failed / not permitted": "5 / 5 / 3",
"breaker state": "OPEN"
},
Spring's retry interceptor is the first advisor on the proxy - ahead of every Resilience4j aspect
(orders 2147483642-2147483646) - so it retries around the breaker. The breaker counts each attempt
as a separate call, and once it opens, the retry spends its remaining attempts on
CallNotPermittedException. With the default 1 s delay that is three seconds of waiting to be told
the circuit is open.
If you combine them, add excludes = CallNotPermittedException.class to the @Retryable - or use
Resilience4j's @Retry, whose aspect order puts it outside the breaker deliberately and which you
can configure to ignore CallNotPermittedException.
TimeLimiter
r4j-timelimiter.txt: a 2 s CompletableFuture under a 500 ms
limit fails with TimeoutException after 512 ms. It only applies to asynchronous return types -
there is no equivalent for a blocking method in either library.