Spring Framework 7 Retry & ConcurrencyLimit: Avoiding the Traffic Amplifier Trap
The author migrates from hand-written retry code and Spring Retry to Spring Framework 7's built-in @Retryable and @ConcurrencyLimit, discovering that retry logic can amplify downstream traffic under virtual threads, and shares lessons on selective exception handling, jitter, idempotency for write operations, and the difference between local concurrency limits and distributed rate limiting.
The article documents the author's experience replacing custom retry logic and Spring Retry with Spring Framework 7's native resilience annotations ( @Retryable and @ConcurrencyLimit) in a Spring Boot 4 project.
From Hand-Written Retry to Spring Retry
The project originally used a simple while(true) loop with Thread.sleep(500) for a logistics query interface that occasionally returned connection timeouts, 502, or 503 errors. Later, Spring Retry was introduced with
@Retryable(retryFor={ResourceAccessException.class, LogisticsServerException.class}, maxAttempts=3, backoff=@Backoff(delay=200, multiplier=2)), which was more reliable than manual loops.
Spring Framework 7's Built-in Retry
Spring Framework 7 (bundled with Spring Boot 4) includes a new resilience module. To enable it, add a configuration class annotated with @EnableResilientMethods. The new annotation is org.springframework.resilience.annotation.Retryable (not the Spring Retry one). Example usage:
@Service
@RequiredArgsConstructor
public class LogisticsGateway {
private final LogisticsClient logisticsClient;
@Retryable(
includes = { ResourceAccessException.class, LogisticsServerException.class },
maxRetries = 2,
delay = 200,
multiplier = 2,
maxDelay = 1000,
jitter = 100
)
public LogisticsDTO query(String orderNo) {
return logisticsClient.query(orderNo);
}
}Key parameter clarification : maxRetries = 2 means up to two retries after the first failure, totaling three attempts. multiplier = 2 increases wait time exponentially; jitter = 100 adds randomness to avoid thundering herd.
Why Jitter Matters
Without jitter, if a third-party service hiccups for 2 seconds and 20 instances each have hundreds of failing requests, all retries fire simultaneously after the same delay, potentially overwhelming the downstream again. Jitter spreads retries over time.
Selective Exception Handling
The author emphasizes retrying only transient errors. The old catch (Exception e) retried everything, including 400 Bad Request, 401 Unauthorized, and 404 Not Found — which never succeed on retry. The solution: map HTTP status codes to custom exceptions at the client layer ( RestClient.Builder with defaultStatusHandler), then configure @Retryable with includes for retryable exceptions ( ResourceAccessException, LogisticsServerException) and excludes for non-retryable business exceptions ( LogisticsBadRequestException, LogisticsNotFoundException).
Load Test Reveals Traffic Amplification
Under virtual threads (Java 25), the service can handle thousands of concurrent requests. A load test showed that 500 initial requests, with 300 failing and retrying (200 failing again), could generate ~1000 downstream calls — a traffic amplifier. Previously, thread pools ( corePoolSize=20, maxPoolSize=100, queueCapacity=500) inadvertently limited concurrency. Virtual threads remove that bottleneck.
Adding @ConcurrencyLimit
To protect the downstream, the author added
@ConcurrencyLimit(limitString = "${integration.logistics.max-concurrency:80}")on the same method. This caps concurrent executions of the method to 80 per instance, regardless of how many virtual threads are waiting. Configuration:
integration:
logistics:
max-concurrency: 80This is more precise than thread pools because it expresses the real requirement: "don't send more than 80 simultaneous requests to this third-party API," not "use a thread pool of size 80."
Local vs. Distributed Concurrency Control
@ConcurrencyLimit(80)applies per JVM instance. With 10 pods, the cluster could still send 800 concurrent requests. If the third party enforces a global 100 QPS limit, a distributed rate limiter (gateway, Redis-based) is still needed. The annotation solves single-instance method concurrency, not distributed traffic governance.
Retry Is Not a Circuit Breaker
If a downstream is down for ten minutes, retries just waste resources. Long-lasting failures require a Circuit Breaker (e.g., Resilience4j). The author keeps Resilience4j for Circuit Breaker, Time Limiter, complex Rate Limiter, and distributed governance, while using Spring's built-in annotations for simple internal calls needing a few retries and local concurrency limits.
Idempotency for Write Operations
Retrying GET /logistics/orders/123456 is safe. Retrying POST /payment/pay is dangerous: the first request may have succeeded but the response timed out. A second POST without idempotency causes duplicate charges. The author only enables retry on write endpoints when a full idempotency mechanism exists: client-generated Idempotency-Key (UUID), sent as header, with a unique database constraint on the payment service to return the original result on duplicate keys.
Proxy-Based AOP Limitation
Spring's annotations rely on proxies. A self-invocation like queryOrder() calling queryRemote() within the same class bypasses the proxy, so @Retryable on queryRemote() does not trigger. The fix: separate beans — OrderService → LogisticsGateway (with annotations) → LogisticsClient. This also clarifies architecture: Client handles HTTP, Gateway handles retry/concurrency/exception translation, Service handles business logic.
Migration Strategy
The author does not do a wholesale rewrite. Stable code using Spring Retry's advanced features stays. New code and simple retry cases adopt the Framework's native support. The guiding principle: when touching a business problem, check if the infrastructure can be simplified.
Four Questions Before Adding Retry
The author concludes that before adding @Retryable, one must answer: (1) Which exceptions are worth retrying? (2) How many retries before stopping? (3) How to avoid overwhelming the downstream during failure? (4) Is the operation idempotent? Only after these are clear does the annotation become useful.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
