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.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Spring Framework 7 Retry & ConcurrencyLimit: Avoiding the Traffic Amplifier Trap

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: 80

This 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 — OrderServiceLogisticsGateway (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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

RetryIdempotencyVirtual ThreadsResilienceCircuit BreakerSpring Framework 7ConcurrencyLimitSpring Boot 4
LuTiao Programming
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.