Files
spring-boot-demo/resilience4j-circuit-breaker/docs/04-concurrency-limit.md
T
asmhatre 320733265f Add resilience4j-circuit-breaker: Resilience4j 2.4.0 vs Spring Framework 7 core, on Boot 4.1
Companion module for the rewritten post 'Resilience4j Circuit Breaker in Spring Boot 4.1:
What It's Still For', reworked around Framework 7 now shipping @Retryable/@ConcurrencyLimit
in core. Covers what's still Resilience4j's job (circuit breaker, rate limiter, bulkhead's
bounded wait, fallback methods, Actuator/Micrometer metrics), the off-by-one between
maxAttempts and maxRetries, and two Boot-4.1 build breaks: spring-boot-starter-aop no longer
exists (renamed to spring-boot-starter-aspectj, proven with Maven Central metadata and the
renamed starter's own POM -- see resilience/docs/08-starter-aop-renamed-to-starter-aspectj.md
in this same repo), and the resulting fix uses that renamed starter directly rather than
assembling spring-aop + aspectjweaver by hand. Kept as its own module rather than a new
top-level repository, alongside the existing resilience/ module for the sibling Framework-7
post.
2026-09-18 08:40:50 +00:00

3.0 KiB

4. @ConcurrencyLimit vs Resilience4j's Bulkhead

Previous: 03-spring-retryable.md | README | Next: 05-production-checklist.md

Source: ConcurrencyLimitedService.java, R4jBulkheadService.java. Tests: ConcurrencyLimitTest.java. Transcripts: 04a, 04b, 04c.

Both cap concurrent invocations of one method at 2. Both were hit with 4 concurrent callers, each holding its slot for 300ms, in the same test run.

BLOCK: queue, no timeout

04a-concurrencylimit-block.txt: all 4 callers succeed. Sorted completion times were [300, 300, 597, 600] ms — two callers finish almost immediately, the other two only after a slot frees, for a total wall time of ~601ms instead of the ~300ms it would take with no limit at all. There is no attribute on @ConcurrencyLimit to bound how long a blocked caller waits; it queues until a slot opens, however long that takes.

REJECT: fail fast, no queue

04b-concurrencylimit-reject.txt, policy = ConcurrencyLimit.ThrottlePolicy.REJECT: 2 callers succeed in ~300ms, and the other 2 are rejected in ~0ms — not made to wait at all. The exception is org.springframework.resilience.InvocationRejectedException, found with javap on ConcurrencyLimitBeanPostProcessor$RejectingConcurrencyThrottleInterceptor.onAccessRejected — it is not documented on the @ConcurrencyLimit annotation itself.

What Resilience4j's Bulkhead adds: a bounded wait

04c-r4j-bulkhead-comparison.txt runs the identical 4-caller/300ms scenario against a Resilience4j @Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms). Two callers finish normally around 300ms; the other two wait up to maxWaitDuration (observed: 111ms and 117ms, close to the configured 100ms) and are rejected via the fallback method — a third option that sits between Spring's BLOCK (wait forever) and REJECT (never wait): wait, but only briefly. Neither @ConcurrencyLimit policy offers that middle ground.

Picking between them. If you want callers to queue briefly rather than either wait forever or fail instantly, Resilience4j's Bulkhead with a short maxWaitDuration is still the only one of the three that does it. If unlimited blocking is actually fine — e.g. limiting concurrent writers to a single-threaded resource — @ConcurrencyLimit's BLOCK policy is one annotation and one less dependency.

Next: 05-production-checklist.md.