Files
spring-boot-demo/resilience/docs/06-what-is-left-for-resilience4j.md
asmhatreandClaude Opus 5 7e1676c763 Add resilience: Spring Framework 7 @Retryable and @ConcurrencyLimit
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
2026-09-11 17:12:25 +00:00

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: 33 invocations. Spring's maxRetries = 34. 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.