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:
@@ -0,0 +1,45 @@
|
||||
# 1. What Spring Framework 7 ships
|
||||
|
||||
[Index](../README.md) · Next: [2. Enabling →](02-enabling.md)
|
||||
|
||||
| Piece | Package | Jar |
|
||||
|---|---|---|
|
||||
| `@Retryable`, `@ConcurrencyLimit`, `@EnableResilientMethods` | `org.springframework.resilience.annotation` | spring-context |
|
||||
| `MethodRetryEvent`, `MethodRetryPredicate`, interceptors | `org.springframework.resilience.retry` | spring-context |
|
||||
| `InvocationRejectedException` | `org.springframework.resilience` | spring-context |
|
||||
| `RetryTemplate`, `RetryPolicy`, `RetryListener`, `RetryException` | `org.springframework.core.retry` | spring-core |
|
||||
|
||||
## Defaults, read from the annotations
|
||||
|
||||
Reflection over `Retryable.class.getDeclaredMethods()` on 7.0.9
|
||||
([`ApiSurfaceTest`](../src/test/java/com/ankurm/resilience/ApiSurfaceTest.java)):
|
||||
|
||||
| Attribute | Default |
|
||||
|---|---|
|
||||
| `maxRetries` | `3` - retries *after* the first call |
|
||||
| `delay` | `1000` (ms) |
|
||||
| `multiplier` | `1.0` - no growth |
|
||||
| `maxDelay` | `Long.MAX_VALUE` |
|
||||
| `jitter` | `0` |
|
||||
| `timeout` | `0` - no budget |
|
||||
| `timeUnit` | `MILLISECONDS` |
|
||||
| `includes` / `excludes` | empty - every exception is retryable |
|
||||
|
||||
`@ConcurrencyLimit` has no usable default limit (`Integer.MIN_VALUE` means "not set") and
|
||||
`policy = BLOCK`. `@EnableResilientMethods` has `proxyTargetClass = false` and
|
||||
`order = Integer.MAX_VALUE - 1`.
|
||||
|
||||
## It moved during 7.0.x
|
||||
|
||||
[`api-surface.txt`](output/api-surface.txt), per release:
|
||||
|
||||
| Added in | What |
|
||||
|---|---|
|
||||
| 7.0.0 | `@Retryable` (no `timeout`), `@ConcurrencyLimit` (no `policy`), `RetryTemplate`, `RetryPolicy` |
|
||||
| 7.0.2 | `@Retryable.timeout`/`timeoutString`, `RetryPolicy.Builder.timeout(..)`, `RetryListener.onRetryPolicyTimeout` and `onRetryableExecution` |
|
||||
| 7.0.3 | `@ConcurrencyLimit.policy` with `ThrottlePolicy.BLOCK` / `REJECT` |
|
||||
|
||||
Release dates from the Maven Central `Last-Modified` headers: 7.0.0 on 13 Nov 2025, 7.0.2 on
|
||||
11 Dec 2025, 7.0.3 on 15 Jan 2026. Articles written against the 7.0 GA - including the Spring reference page at the time
|
||||
this was written, which does not mention `timeout` or `policy` - describe a smaller API than the
|
||||
one you have.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2. Switching it on - and the three ways it stays off
|
||||
|
||||
[← 1. What ships](01-whats-in-framework-7.md) · [Index](../README.md) · Next: [3. @Retryable measured →](03-retryable-measured.md)
|
||||
|
||||
## Spring Boot 4.1.1 does not enable it
|
||||
|
||||
No auto-configuration in Boot 4.1.1 registers `RetryAnnotationBeanPostProcessor` or
|
||||
`ConcurrencyLimitBeanPostProcessor` (no Boot 4.1.1 jar in this project's dependency tree mentions
|
||||
either class). You need [`@EnableResilientMethods`](../src/main/java/com/ankurm/resilience/ResilienceConfig.java).
|
||||
|
||||
Without it the annotations are metadata. [`retry-disabled.txt`](output/retry-disabled.txt), same
|
||||
method, `demo.resilience.enabled=false`:
|
||||
|
||||
```
|
||||
"invocations": 1,
|
||||
"caller": "threw: com.ankurm.resilience.support.TransientException: attempt 1 failed"
|
||||
```
|
||||
|
||||
No warning at startup, no log line at call time.
|
||||
|
||||
## Self-invocation
|
||||
|
||||
A call from inside the bean does not go through the proxy
|
||||
([`retry-self-invocation.txt`](output/retry-self-invocation.txt)): one invocation, first exception
|
||||
straight to the caller. Same rule as `@Transactional` and `@Async` - see the
|
||||
[AOP article](https://ankurm.com/spring-aop-pointcuts-advice-types-aspect-not-firing/).
|
||||
|
||||
## `final` methods
|
||||
|
||||
The proxies here are CGLIB subclasses (`/demo/proxy/*` reports the type - the demo beans implement
|
||||
no interface). A subclass cannot override a `final` method, so a `final @Retryable` method is
|
||||
called directly on the target and never retried - the same silent failure measured for `@Async` in
|
||||
the [@Async article](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/).
|
||||
@@ -0,0 +1,61 @@
|
||||
# 3. `@Retryable`, measured
|
||||
|
||||
[← 2. Enabling](02-enabling.md) · [Index](../README.md) · Next: [4. @ConcurrencyLimit →](04-concurrency-limit.md)
|
||||
|
||||
Every number here is from [`FlakyGateway`](../src/main/java/com/ankurm/resilience/retry/FlakyGateway.java),
|
||||
which records each real invocation in [`CallLog`](../src/main/java/com/ankurm/resilience/support/CallLog.java).
|
||||
|
||||
## Counting
|
||||
|
||||
| Scenario | Invocations | Gaps (ms) | Caller got | Transcript |
|
||||
|---|---|---|---|---|
|
||||
| defaults, fails twice | 3 | 1009, 1001 | the value | [`retry-defaults.txt`](output/retry-defaults.txt) |
|
||||
| defaults, always fails | **4** | 1001, 1001, 1002 | `TransientException: attempt 4 failed` | [`retry-exhausted.txt`](output/retry-exhausted.txt) |
|
||||
| `maxRetries=5, delay=100, multiplier=2, maxDelay=500` | 6 | 101, 200, 401, 501, 501 | last exception | [`retry-exponential.txt`](output/retry-exponential.txt) |
|
||||
| `delay=200, jitter=100` | 7 | 254, 211, 296, 294, 273, 293 | last exception | [`retry-jitter.txt`](output/retry-jitter.txt) |
|
||||
|
||||
- The caller receives the **last original exception**, not a wrapper. (`RetryTemplate.execute`
|
||||
throws `RetryException`; the annotation path unwraps it.)
|
||||
- `maxDelay` caps the exponential sequence: 400 would have been 800.
|
||||
|
||||
## Jitter only adds
|
||||
|
||||
Jitter looks symmetric in the documentation. `ExponentialBackOff$ExponentialBackOffExecution.applyJitter`
|
||||
(read with `javap -c` on spring-core 7.0.9) computes the range as
|
||||
`[max(interval - j, initialInterval), min(interval + j, maxInterval)]`, where `j` is the jitter
|
||||
scaled by `interval / initialInterval`. For the first delay - and for every delay when `multiplier`
|
||||
is 1 - the lower bound is clamped to `delay` itself, so jitter can only lengthen the wait. The
|
||||
transcript agrees: six gaps between 211 and 296 ms, none under 200.
|
||||
|
||||
## What is retried
|
||||
|
||||
| Scenario | Invocations | Why |
|
||||
|---|---|---|
|
||||
| `includes = IllegalStateException`, throws `IllegalArgumentException` | 1 | not included |
|
||||
| `includes = IOException`, throws `UncheckedIOException(IOException)` | 3 | **causes are matched** |
|
||||
| returns `CompletableFuture.failedFuture(...)` | **1** | the method returned normally |
|
||||
| returns `Mono.fromCallable(...)` that errors | 3 | retried by re-subscription |
|
||||
|
||||
The `CompletableFuture` row is the one to remember: an async client method that returns a future
|
||||
is not protected by `@Retryable` at all ([`retry-future.txt`](output/retry-future.txt)). Reactive
|
||||
types go through a Reactor `retryWhen`, so a `Mono` works.
|
||||
|
||||
## `timeout` is a budget, not a timeout
|
||||
|
||||
| Scenario | Invocations | Elapsed | |
|
||||
|---|---|---|---|
|
||||
| attempts of 300 ms, `delay=100`, `timeout=1000` | 3 | 1105 ms | stops once the budget is spent - after overrunning it by one attempt |
|
||||
| one attempt of 1500 ms, `timeout=500` | 1 | **1502 ms** | the attempt is not interrupted |
|
||||
|
||||
The budget is checked between attempts. Use a client-level timeout (HTTP client, JDBC query
|
||||
timeout) for the call itself, and treat `timeout` as "stop retrying after roughly this long".
|
||||
|
||||
## Events are not a "retry happened" signal
|
||||
|
||||
`MethodRetryEvent` fires for every failure with `isRetryAborted() == false`, *including the last
|
||||
one*, then once more with a `RetryException` and `isRetryAborted() == true`. For an exception that
|
||||
is not even retryable you get `will retry` followed by `retry aborted`
|
||||
([`retry-includes.txt`](output/retry-includes.txt)). A counter of events with `retryAborted=false`
|
||||
counts failures, not retries. Spring registers no retry metrics of its own
|
||||
([`metrics-names.txt`](output/metrics-names.txt)): `app.retry.failures` exists only because
|
||||
[`RetryEvents`](../src/main/java/com/ankurm/resilience/retry/RetryEvents.java) creates it.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 4. `@ConcurrencyLimit`: BLOCK, REJECT, and nesting with retry
|
||||
|
||||
[← 3. @Retryable measured](03-retryable-measured.md) · [Index](../README.md) · Next: [5. Retry and transactions →](05-retry-and-transactions.md)
|
||||
|
||||
Ten virtual threads call a 200 ms method limited to 2 ([`ReportService`](../src/main/java/com/ankurm/resilience/limit/ReportService.java)):
|
||||
|
||||
| Setup | Succeeded | Failed | Max inside | Elapsed | Transcript |
|
||||
|---|---|---|---|---|---|
|
||||
| no limit | 10 | - | 10 | 203 ms | [`limit-none.txt`](output/limit-none.txt) |
|
||||
| `@ConcurrencyLimit(2)` (BLOCK) | 10 | - | 2 | 1006 ms | [`limit-block.txt`](output/limit-block.txt) |
|
||||
| `policy = REJECT` | 2 | 8 × `InvocationRejectedException` | 2 | 205 ms | [`limit-reject.txt`](output/limit-reject.txt) |
|
||||
| Resilience4j `@Bulkhead`, `max-wait-duration: 0` | 2 | 8 × `BulkheadFullException` | 2 | 231 ms | [`limit-r4j-bulkhead.txt`](output/limit-r4j-bulkhead.txt) |
|
||||
|
||||
BLOCK queues callers indefinitely - there is no wait timeout. With virtual threads that is cheap in
|
||||
memory, but an unbounded queue in front of a slow dependency is still an unbounded latency.
|
||||
`InvocationRejectedException` extends `RejectedExecutionException`, so existing handlers for
|
||||
executor rejection catch it.
|
||||
|
||||
## Both annotations on one method
|
||||
|
||||
`limitedAndRetried()` has `@ConcurrencyLimit(1)` and `@Retryable(maxRetries=1, delay=300)`; every
|
||||
caller's first attempt fails. Two callers took **815 ms** ([`limit-limit-and-retry.txt`](output/limit-limit-and-retry.txt)):
|
||||
about 2 × (50 + 300 + 50). The permit is held through the 300 ms back-off, because the limit is the
|
||||
outer interceptor ([`proxy-reports.txt`](output/proxy-reports.txt)):
|
||||
|
||||
```
|
||||
"org.springframework.resilience.annotation.ConcurrencyLimitBeanPostProcessor$ConcurrencyLimitInterceptor order=2147483647",
|
||||
"org.springframework.resilience.annotation.RetryAnnotationBeanPostProcessor$RetryAnnotationInterceptor order=2147483647"
|
||||
```
|
||||
|
||||
Both post-processors insert at the front of an existing proxy, so whichever runs second ends up
|
||||
outermost. If you want the permit released between attempts, put the two annotations on different
|
||||
beans.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 5. Retry and transactions
|
||||
|
||||
[← 4. @ConcurrencyLimit](04-concurrency-limit.md) · [Index](../README.md) · Next: [6. What is left for Resilience4j →](06-what-is-left-for-resilience4j.md)
|
||||
|
||||
[`StockWriter.record`](../src/main/java/com/ankurm/resilience/tx/StockWriter.java) is
|
||||
`@Retryable @Transactional`, inserts a row, and fails twice.
|
||||
|
||||
## Which interceptor is outside
|
||||
|
||||
`RetryAnnotationBeanPostProcessor`'s constructor calls `setBeforeExistingAdvisors(true)` (`javap -c`
|
||||
on spring-context 7.0.9). The transaction advisor is applied first by the auto-proxy creator; the
|
||||
retry advisor is then inserted at index 0 ([`proxy-stock-writer.txt`](output/proxy-stock-writer.txt)).
|
||||
The `order` attribute of `@EnableResilientMethods` is the post-processor's order, not the
|
||||
advice's position, and does not change this.
|
||||
|
||||
## Alone: each attempt is its own transaction
|
||||
|
||||
[`tx-standalone.txt`](output/tx-standalone.txt) - three connection holders, one row:
|
||||
|
||||
```
|
||||
"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
|
||||
```
|
||||
|
||||
This is what you want: the failed attempts rolled back their inserts.
|
||||
|
||||
## Called from inside a transaction: the retry cannot help
|
||||
|
||||
[`OrderFacade.placeOrder`](../src/main/java/com/ankurm/resilience/tx/OrderFacade.java) is
|
||||
`@Transactional` and calls the same method ([`tx-joined.txt`](output/tx-joined.txt)):
|
||||
|
||||
```
|
||||
"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
|
||||
```
|
||||
|
||||
Every attempt joins the outer transaction (`REQUIRED`). The first failure passes through the inner
|
||||
`TransactionInterceptor`, which marks the *shared* transaction rollback-only. The third attempt
|
||||
succeeds, the retry returns normally, and the outer commit throws. Three attempts, zero rows, an
|
||||
exception the caller did not expect. The retry belongs at the outermost transactional boundary -
|
||||
or the inner method needs `REQUIRES_NEW`. The [@Transactional article](https://ankurm.com/transactional-propagation-isolation-silent-failures/)
|
||||
covers the rollback-only mechanics.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 6. What is left for Resilience4j
|
||||
|
||||
[← 5. Retry and transactions](05-retry-and-transactions.md) · [Index](../README.md) · Next: [7. Migrating from spring-retry →](07-migrating-from-spring-retry.md)
|
||||
|
||||
| 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`](output/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`](output/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`](output/r4j-combo.txt), [`proxy-payments.txt`](output/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`](output/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.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 7. Migrating from spring-retry
|
||||
|
||||
[← 6. What is left for Resilience4j](06-what-is-left-for-resilience4j.md) · [Index](../README.md)
|
||||
|
||||
The spring-retry README now says the project "is no longer maintained as an open-source project"
|
||||
and "has been superseded by Spring Framework 7". Its last release on Maven Central is 2.0.13.
|
||||
|
||||
| spring-retry | Spring Framework 7 | Watch out |
|
||||
|---|---|---|
|
||||
| `@EnableRetry` | `@EnableResilientMethods` | |
|
||||
| `@Retryable(maxAttempts = 3)` | `@Retryable(maxRetries = 2)` | **attempts vs retries** - `maxRetries = 3` is one call more |
|
||||
| `@Retryable(retryFor = X.class)` | `@Retryable(includes = X.class)` (or `value`) | causes are matched too |
|
||||
| `@Retryable(noRetryFor = X.class)` | `@Retryable(excludes = X.class)` | |
|
||||
| `@Backoff(delay, multiplier, maxDelay)` | `delay`, `multiplier`, `maxDelay` attributes | default delay is 1000 ms in both |
|
||||
| `@Backoff(random = true)` | `jitter` | jitter only adds delay (chapter 3) |
|
||||
| `@Recover` | none - catch the exception in the caller | declined upstream |
|
||||
| `RetryTemplate.builder()` | `new RetryTemplate(RetryPolicy.builder()...build())` | `execute` throws `RetryException`; `invoke` throws the original |
|
||||
| stateful retry / `RetryContext` | none | |
|
||||
|
||||
Keep spring-retry on the classpath only as long as something still uses `@Recover` or stateful
|
||||
retry. Both libraries define an annotation named `Retryable` - check the import on every one.
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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]"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user