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
This commit is contained in:
2026-09-11 17:12:25 +00:00
co-authored by Claude Opus 5
parent 926250e1a9
commit 7e1676c763
61 changed files with 2155 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Public API of the resilience support, per Spring Framework release
## 7.0.0
@Retryable [delay, delayString, excludes, includes, jitter, jitterString, maxDelay, maxDelayString, maxRetries, maxRetriesString, multiplier, multiplierString, predicate, timeUnit, value]
@ConcurrencyLimit [limit, limitString, value]
RetryPolicy.Builder [backOff, build, delay, excludes, includes, jitter, maxDelay, maxRetries, multiplier, predicate]
RetryListener [beforeRetry, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyInterruption, onRetrySuccess]
## 7.0.1
@Retryable [delay, delayString, excludes, includes, jitter, jitterString, maxDelay, maxDelayString, maxRetries, maxRetriesString, multiplier, multiplierString, predicate, timeUnit, value]
@ConcurrencyLimit [limit, limitString, value]
RetryPolicy.Builder [backOff, build, delay, excludes, includes, jitter, maxDelay, maxRetries, multiplier, predicate]
RetryListener [beforeRetry, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyInterruption, onRetrySuccess]
## 7.0.2
@Retryable [delay, delayString, excludes, includes, jitter, jitterString, maxDelay, maxDelayString, maxRetries, maxRetriesString, multiplier, multiplierString, predicate, timeUnit, timeout, timeoutString, value]
@ConcurrencyLimit [limit, limitString, value]
RetryPolicy.Builder [backOff, build, delay, excludes, includes, jitter, maxDelay, maxRetries, multiplier, predicate, timeout]
RetryListener [beforeRetry, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyInterruption, onRetryPolicyTimeout, onRetrySuccess, onRetryableExecution]
## 7.0.3
@Retryable [delay, delayString, excludes, includes, jitter, jitterString, maxDelay, maxDelayString, maxRetries, maxRetriesString, multiplier, multiplierString, predicate, timeUnit, timeout, timeoutString, value]
@ConcurrencyLimit [limit, limitString, policy, value]
RetryPolicy.Builder [backOff, build, delay, excludes, includes, jitter, maxDelay, maxRetries, multiplier, predicate, timeout]
RetryListener [beforeRetry, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyInterruption, onRetryPolicyTimeout, onRetrySuccess, onRetryableExecution]
## 7.0.9
@Retryable [delay, delayString, excludes, includes, jitter, jitterString, maxDelay, maxDelayString, maxRetries, maxRetriesString, multiplier, multiplierString, predicate, timeUnit, timeout, timeoutString, value]
@ConcurrencyLimit [limit, limitString, policy, value]
RetryPolicy.Builder [backOff, build, delay, excludes, includes, jitter, maxDelay, maxRetries, multiplier, predicate, timeout]
RetryListener [beforeRetry, onRetryFailure, onRetryPolicyExhaustion, onRetryPolicyInterruption, onRetryPolicyTimeout, onRetrySuccess, onRetryableExecution]
+10
View File
@@ -0,0 +1,10 @@
# GET /demo/limit/block (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"callers": 10,
"succeeded": 10,
"failed": {},
"max concurrently inside the method": 2,
"elapsed (ms)": 1006
}
@@ -0,0 +1,10 @@
# GET /demo/limit/limit-and-retry (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"callers": 2,
"succeeded": 2,
"failed": {},
"max concurrently inside the method": 1,
"elapsed (ms)": 815
}
+10
View File
@@ -0,0 +1,10 @@
# GET /demo/limit/none (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"callers": 10,
"succeeded": 10,
"failed": {},
"max concurrently inside the method": 10,
"elapsed (ms)": 203
}
@@ -0,0 +1,12 @@
# GET /demo/limit/r4j-bulkhead (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"callers": 10,
"succeeded": 2,
"failed": {
"io.github.resilience4j.bulkhead.BulkheadFullException": 8
},
"max concurrently inside the method": 2,
"elapsed (ms)": 231
}
+12
View File
@@ -0,0 +1,12 @@
# GET /demo/limit/reject (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"callers": 10,
"succeeded": 2,
"failed": {
"org.springframework.resilience.InvocationRejectedException": 8
},
"max concurrently inside the method": 2,
"elapsed (ms)": 205
}
+14
View File
@@ -0,0 +1,14 @@
# Spring retry has no metrics of its own; the only meter is the one RetryEvents registers.
$ curl /actuator/metrics | grep -iE 'retry|resilience4j'
app.retry.failures
resilience4j.bulkhead.available.concurrent.calls
resilience4j.bulkhead.max.allowed.concurrent.calls
resilience4j.circuitbreaker.buffered.calls
resilience4j.circuitbreaker.calls
resilience4j.circuitbreaker.failure.rate
resilience4j.circuitbreaker.not.permitted.calls
resilience4j.circuitbreaker.slow.call.rate
resilience4j.circuitbreaker.slow.calls
resilience4j.circuitbreaker.state
resilience4j.retry.calls
resilience4j.timelimiter.calls
+7
View File
@@ -0,0 +1,7 @@
# GET /demo/proxy/gateway (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
[
"proxy type: CGLIB",
"org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor$RetryAnnotationInterceptor order=2147483647"
]
+12
View File
@@ -0,0 +1,12 @@
# GET /demo/proxy/payments (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
[
"proxy type: CGLIB",
"org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor$RetryAnnotationInterceptor order=2147483647",
"org.springframework.aop.interceptor.ExposeInvocationInterceptor order=-2147483647",
"org.springframework.aop.aspectj.AspectJAroundAdvice order=2147483642",
"org.springframework.aop.aspectj.AspectJAroundAdvice order=2147483643",
"org.springframework.aop.aspectj.AspectJAroundAdvice order=2147483645",
"org.springframework.aop.aspectj.AspectJAroundAdvice order=2147483646"
]
+8
View File
@@ -0,0 +1,8 @@
# GET /demo/proxy/reports (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
[
"proxy type: CGLIB",
"org.springframework.resilience.annotation.ConcurrencyLimitBeanPostProcessor$ConcurrencyLimitInterceptor order=2147483647",
"org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor$RetryAnnotationInterceptor order=2147483647"
]
@@ -0,0 +1,8 @@
# GET /demo/proxy/stock-writer (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
[
"proxy type: CGLIB",
"org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor$RetryAnnotationInterceptor order=2147483647",
"org.springframework.transaction.interceptor.TransactionInterceptor order=2147483647"
]
+22
View File
@@ -0,0 +1,22 @@
# GET /demo/r4j/breaker (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"calls made": 11,
"calls that reached the downstream": 8,
"of which before the breaker opened": 5,
"outcomes": [
"1: TransientException: payment gateway 503 (call 1) [state after: CLOSED]",
"2: TransientException: payment gateway 503 (call 2) [state after: CLOSED]",
"3: TransientException: payment gateway 503 (call 3) [state after: CLOSED]",
"4: TransientException: payment gateway 503 (call 4) [state after: CLOSED]",
"5: TransientException: payment gateway 503 (call 5) [state after: OPEN]",
"6: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"7: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"8: CallNotPermittedException: CircuitBreaker 'payments' is OPEN and does not permit further calls [state after: OPEN]",
"-- 2.1 s later, downstream recovered --",
"9: returned charged [state after: HALF_OPEN]",
"10: returned charged [state after: CLOSED]",
"11: returned charged [state after: CLOSED]"
]
}
+31
View File
@@ -0,0 +1,31 @@
# GET /demo/r4j/combo (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"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"
},
"MethodRetryEvents": [
"chargeWithBoth failed with TransientException -> will retry",
"chargeWithBoth failed with TransientException -> will retry",
"chargeWithBoth failed with TransientException -> will retry",
"chargeWithBoth failed with TransientException -> will retry",
"chargeWithBoth failed with RetryException -> retry aborted",
"chargeWithBoth failed with TransientException -> will retry",
"chargeWithBoth failed with CallNotPermittedException -> will retry",
"chargeWithBoth failed with CallNotPermittedException -> will retry",
"chargeWithBoth failed with CallNotPermittedException -> will retry",
"chargeWithBoth failed with RetryException -> retry aborted"
]
}
+17
View File
@@ -0,0 +1,17 @@
# GET /demo/r4j/retry (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 3,
"started at (ms)": [
7,
26,
36
],
"gaps (ms)": [
19,
10
],
"elapsed (ms)": 37,
"caller": "threw: com.ankurm.resilience.support.TransientException: payment gateway 503 (call 3)"
}
@@ -0,0 +1,12 @@
# GET /demo/r4j/timelimiter (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 1,
"started at (ms)": [
11
],
"gaps (ms)": [],
"elapsed (ms)": 512,
"caller": "threw: java.util.concurrent.TimeoutException: TimeLimiter 'slow' recorded a timeout exception."
}
+23
View File
@@ -0,0 +1,23 @@
# GET /demo/retry/cause (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 3,
"started at (ms)": [
1,
11,
23
],
"gaps (ms)": [
10,
12
],
"elapsed (ms)": 26,
"caller": "threw: java.io.UncheckedIOException: java.io.IOException: socket reset on attempt 3",
"MethodRetryEvents": [
"wrappedCause failed with UncheckedIOException -> will retry",
"wrappedCause failed with UncheckedIOException -> will retry",
"wrappedCause failed with UncheckedIOException -> will retry",
"wrappedCause failed with RetryException -> retry aborted"
]
}
+21
View File
@@ -0,0 +1,21 @@
# GET /demo/retry/defaults (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 3,
"started at (ms)": [
27,
1036,
2037
],
"gaps (ms)": [
1009,
1001
],
"elapsed (ms)": 2037,
"caller": "returned: ok after 3 invocation(s)",
"MethodRetryEvents": [
"defaults failed with TransientException -> will retry",
"defaults failed with TransientException -> will retry"
]
}
+14
View File
@@ -0,0 +1,14 @@
# The same @Retryable method with @EnableResilientMethods switched off (demo.resilience.enabled=false)
{
"invocations": 1,
"started at (ms)": [
0
],
"gaps (ms)": [],
"elapsed (ms)": 0,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 1 failed"
}
# Startup log lines mentioning retry or resilience:
(none)
@@ -0,0 +1,26 @@
# GET /demo/retry/exhausted (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 4,
"started at (ms)": [
0,
1001,
2002,
3004
],
"gaps (ms)": [
1001,
1001,
1002
],
"elapsed (ms)": 3008,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 4 failed",
"MethodRetryEvents": [
"defaults failed with TransientException -> will retry",
"defaults failed with TransientException -> will retry",
"defaults failed with TransientException -> will retry",
"defaults failed with TransientException -> will retry",
"defaults failed with RetryException -> retry aborted"
]
}
@@ -0,0 +1,32 @@
# GET /demo/retry/exponential (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 6,
"started at (ms)": [
3,
104,
304,
705,
1206,
1707
],
"gaps (ms)": [
101,
200,
401,
501,
501
],
"elapsed (ms)": 1707,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 6 failed",
"MethodRetryEvents": [
"exponential failed with TransientException -> will retry",
"exponential failed with TransientException -> will retry",
"exponential failed with TransientException -> will retry",
"exponential failed with TransientException -> will retry",
"exponential failed with TransientException -> will retry",
"exponential failed with TransientException -> will retry",
"exponential failed with RetryException -> retry aborted"
]
}
+12
View File
@@ -0,0 +1,12 @@
# GET /demo/retry/future (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 1,
"started at (ms)": [
0
],
"gaps (ms)": [],
"elapsed (ms)": 1,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 1 failed"
}
+16
View File
@@ -0,0 +1,16 @@
# GET /demo/retry/hang (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 1,
"started at (ms)": [
1
],
"gaps (ms)": [],
"elapsed (ms)": 1502,
"caller": "threw: com.ankurm.resilience.support.TransientException: slow failure",
"MethodRetryEvents": [
"hangingWithTimeout failed with TransientException -> will retry",
"hangingWithTimeout failed with RetryException -> retry aborted"
]
}
+16
View File
@@ -0,0 +1,16 @@
# GET /demo/retry/includes (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 1,
"started at (ms)": [
1
],
"gaps (ms)": [],
"elapsed (ms)": 0,
"caller": "threw: java.lang.IllegalArgumentException: not in includes",
"MethodRetryEvents": [
"onlyIllegalState failed with IllegalArgumentException -> will retry",
"onlyIllegalState failed with RetryException -> retry aborted"
]
}
+35
View File
@@ -0,0 +1,35 @@
# GET /demo/retry/jitter (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 7,
"started at (ms)": [
0,
254,
465,
761,
1055,
1328,
1621
],
"gaps (ms)": [
254,
211,
296,
294,
273,
293
],
"elapsed (ms)": 1622,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 7 failed",
"MethodRetryEvents": [
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with TransientException -> will retry",
"jittered failed with RetryException -> retry aborted"
]
}
+21
View File
@@ -0,0 +1,21 @@
# GET /demo/retry/mono (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 3,
"started at (ms)": [
37,
65,
75
],
"gaps (ms)": [
28,
10
],
"elapsed (ms)": 75,
"caller": "returned: ok after 3 invocation(s)",
"MethodRetryEvents": [
"mono failed with TransientException -> will retry",
"mono failed with TransientException -> will retry"
]
}
@@ -0,0 +1,12 @@
# GET /demo/retry/self-invocation (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 1,
"started at (ms)": [
0
],
"gaps (ms)": [],
"elapsed (ms)": 0,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 1 failed"
}
+23
View File
@@ -0,0 +1,23 @@
# GET /demo/retry/timeout (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"invocations": 3,
"started at (ms)": [
303,
705,
1106
],
"gaps (ms)": [
402,
401
],
"elapsed (ms)": 1105,
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 3 failed",
"MethodRetryEvents": [
"slowWithTimeout failed with TransientException -> will retry",
"slowWithTimeout failed with TransientException -> will retry",
"slowWithTimeout failed with TransientException -> will retry",
"slowWithTimeout failed with RetryException -> retry aborted"
]
}
+12
View File
@@ -0,0 +1,12 @@
# GET /demo/tx/joined (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"caller got": "org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only",
"attempts": [
"attempt 1: transaction active=true, connection holder @6135f416",
"attempt 2: transaction active=true, connection holder @6135f416",
"attempt 3: transaction active=true, connection holder @6135f416"
],
"rows in stock_movement": 0
}
+12
View File
@@ -0,0 +1,12 @@
# GET /demo/tx/standalone (Spring Framework 7.0.9, Resilience4j 2.4.0, JDK 25)
# Source: src/main/java/com/ankurm/resilience/web/DemoController.java
{
"caller got": "success",
"attempts": [
"attempt 1: transaction active=true, connection holder @6aa73704",
"attempt 2: transaction active=true, connection holder @1978b580",
"attempt 3: transaction active=true, connection holder @7d5534c2"
],
"rows in stock_movement": 1
}