Introduction
Using resilience patterns like @Retryable and @CircuitBreaker are often seen as a best practice. But when you mix Retry with a Circuit Breaker without fully understanding how they work together, your application may behave in ways you never intended.
This isn't just about avoiding errors; it's about avoiding silent failures.
This post is about what happens after you implement both, and what you need to configure to ensure your app is resilient without losing control over logic.
🧠 Subtle Trap: Retry Works Differently with Circuit Breaker
When using Spring Retry alone:
@Retryable(
retryFor = { SocketTimeoutException.class, TemporaryServiceException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public String fetchData() {
return externalApi.call();
}
If all 3 retry attempts fail, the final exception is thrown. You can catch it in the caller or recover with @Recover.
But now add a circuit breaker:
@CircuitBreaker(name = "externalServiceCB", fallbackMethod = "fallback")
@Retryable(
retryFor = { SocketTimeoutException.class, TemporaryServiceException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public String fetchData() {
return externalApi.call();
}
Even when the circuit is closed, and all retry attempts fail, the method doesn’t throw an exception anymore; it calls the fallback method instead.
public String fallback(Throwable t) {
return "fallback response";
}
This means Spring Retry is no longer in control of failure handling; the moment retries are exhausted, Circuit Breaker fallback kicks in, possibly hiding real failures from the business logic.
🧩 Visualizing the Difference
1. 🔄 ❗ Annotation Order Doesn’t Guarantee Execution Order
If you do as below:
@CircuitBreaker(name = "externalServiceCB", fallbackMethod = "fallback")
@Retryable(...)
public String fetchData() { ... }
Or reverse it
@Retryable(...)
@CircuitBreaker(name = "externalServiceCB", fallbackMethod = "fallback")
public String fetchData() { ... }
Or if the @Retryable
is at the class level and @CircuitBreaker
is at the method level.
When combining @Retryable and @CircuitBreaker, the annotation order doesn’t define execution precedence. Spring AOP creates proxies for each behavior, and the order of execution depends on proxy chaining, not annotation placement.
Even if you put @Retryable
first, the circuit breaker’s fallback will still be called if the retry logic fails, regardless of whether the circuit is open or closed.
✅ What matters:
- Knowing that Spring Retry throws after retries fail
- And that Resilience4j CircuitBreaker will catch that failure and invoke the fallback
2. 🎯 Align Retry Attempts and Circuit Thresholds
resilience4j:
circuitbreaker:
instances:
externalServiceCB:
slidingWindowSize: 5
failureRateThreshold: 50
waitDurationInOpenState: 10s
Retries will generate quick bursts of failure, which may cause the circuit breaker to open even when we expect the retry to resolve it.
✅ Fix: Increase sliding window size and allow higher failure rate.
Avoid tight thresholds when retries are in play. Below is an example:
resilience4j:
circuitbreaker:
instances:
externalServiceCB:
slidingWindowSize: 40
failureRateThreshold: 75
waitDurationInOpenState: 10s
3. 🚫 Don’t Retry Non-Transient Errors
Spring Retry lets you control what to retry via noRetryFor property.
@Retryable(
retryFor = { SocketTimeoutException.class },
noRetryFor = { BusinessValidationException.class },
maxAttempts = 3
)
✅ This ensures you're not retrying on logic bugs or validation failures.
4. 🧱 Be Very Careful With Fallbacks
Fallbacks are useful, but they’re risky in flows where real data is required.
✅ Use fallback for non-critical calls (like audit logging).
❌ Avoid fallback for transactional flow.
5. 📊 Track Everything
Could you expose metrics via Spring Boot Actuator, but be careful, as it may expose application details leading to a security vulnerability.
management:
endpoints:
web:
exposure:
include: resilience4j.*
This could help to see how often fallbacks were triggered, the Retry success rate, and when circuits opened.
Conclusion
- Spring Retry and Circuit Breaker can conflict if not coordinated.
- Fallback methods may silently hide real failures — use with care.
- The order of annotations doesn't matter
- Align retry attempts with circuit breaker thresholds.
- Use @Retryable's retryFor/noRetryFor to avoid retrying non-retriable exceptions.
- Track and monitor retry/circuit behavior via metrics.
TL;DR
Resilience patterns are great — until they cause more confusion than clarity.
If you’re combining Retry with Circuit Breaker, don’t stop at just adding annotations. Test, observe, and tune. Otherwise, you might end up with a system that looks resilient on paper but fails silently in production.
If you have reached here, then I have made a satisfactory effort to keep you reading. Please be kind enough to leave any comments or share with corrections.
My Other Blogs:
- Setup GraphQL Mock Server
- Supercharge Your E2E Tests with Playwright and Cucumber Integration
- When Should You Use Server-Side Rendering (SSR)?
- Cracking Software Engineering Interviews
- My firsthand experience with web component - learnings and limitations
- Micro-Frontend Decision Framework
- Test SOAP Web Service using Postman Tool
Top comments (0)