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
+33
View File
@@ -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.