Combining Resilience4j Patterns in Spring Boot
In a previous article, we explored how to use the Circuit Breaker pattern with Resilience4j in a Spring Boot application.
Combining Resilience4j Patterns in Spring Boot

In a previous article, we explored how to use the Circuit Breaker pattern with Resilience4j in a Spring Boot application.
Circuit Breaker is an important fault-tolerance pattern, but it does not solve every problem that may occur while communicating with an external service.
A downstream service may:
- Receive too many requests
- Process too many concurrent operations
- Respond too slowly
- Fail temporarily
- Become completely unavailable
In this article, we will combine the following Resilience4j patterns in a single Spring Boot application:
Rate Limiter
→ Bulkhead
→ TimeLimiter
→ Circuit Breaker
→ Retry
→ Payment Provider
→ Fallback
The Payment Provider in this project is only a simulated downstream service. The main purpose of the project is to demonstrate how different fault-tolerance patterns can work together.
What Does Each Pattern Do?
Before starting the implementation, let’s briefly look at the responsibility of each pattern.
Rate Limiter
Rate Limiter controls how many requests can be processed during a specific period.
It protects the downstream service from receiving more traffic than it can handle.
Bulkhead
Bulkhead limits the number of concurrent calls.
It prevents one slow or overloaded downstream service from consuming all application resources.
TimeLimiter
TimeLimiter defines how long the application should wait for an asynchronous operation.
If the operation does not finish within the configured duration, a TimeoutException is generated.
Circuit Breaker
Circuit Breaker monitors successful and failed calls. When the failure rate exceeds the configured threshold, it opens the circuit and rejects new calls without executing the downstream operation.
Retry
Retry repeats a failed operation for a configured number of attempts. It is useful for temporary network or service errors that may disappear after a short period.
Fallback
Fallback returns an alternative response when the protected operation cannot be completed.
Prerequisites
- Java 17 or later
- Maven 3.9+
- Spring Boot 3
- Basic knowledge of Spring Boot
- Basic knowledge of Resilience4j
Project Dependencies
We will use Spring Web, Spring Boot Actuator, Spring AOP and the Resilience4j Spring Boot starter.
Add the following dependencies to pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
Spring AOP is required because Resilience4j annotations are applied through aspects.
Payment Response
First, create a response model:
public record PaymentResponse(
String orderId,
String status,
String message,
String source
) {
}
This response will be returned for both successful provider calls and fallback operations.
Simulating the Payment Provider
The PaymentProviderClient simulates an external service.
It supports four different scenarios:
- success
- failure
- slow
- random
@Component
public class PaymentProviderClient {
public PaymentResponse charge(String orderId, String scenario) {
return switch (scenario.toLowerCase()) {
case "success" -> success(orderId);
case "failure" ->
throw new IllegalStateException(
"Payment provider returned an error"
);
case "slow" -> slowResponse(orderId);
case "random" -> randomResponse(orderId);
default -> throw new IllegalArgumentException(
"Unknown scenario. Supported values: " +
"success, failure, slow, random"
);
};
}
private PaymentResponse success(String orderId) {
return new PaymentResponse(
orderId,
"PAID",
"Payment completed",
"payment-provider"
);
}
private PaymentResponse slowResponse(String orderId) {
sleep(Duration.ofSeconds(3));
return success(orderId);
}
private PaymentResponse randomResponse(String orderId) {
if (ThreadLocalRandom.current().nextBoolean()) {
throw new IllegalStateException(
"Random payment provider failure"
);
}
return success(orderId);
}
private void sleep(Duration duration) {
try {
Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Payment provider call was interrupted",
exception
);
}
}
}
The slow scenario waits for three seconds. Later, we will configure TimeLimiter with a two-second timeout so that this call produces a fallback response.
Combining Resilience4j Annotations
The service method returns a CompletableFuture because TimeLimiter works with asynchronous return types.
@Service
public class PaymentService {
private static final String BACKEND = "paymentProvider";
private final PaymentProviderClient paymentProviderClient;
public PaymentService(
PaymentProviderClient paymentProviderClient
) {
this.paymentProviderClient = paymentProviderClient;
}
@RateLimiter(
name = BACKEND,
fallbackMethod = "fallbackPayment"
)
@Bulkhead(
name = BACKEND,
type = Bulkhead.Type.SEMAPHORE,
fallbackMethod = "fallbackPayment"
)
@TimeLimiter(
name = BACKEND,
fallbackMethod = "fallbackPayment"
)
@CircuitBreaker(
name = BACKEND,
fallbackMethod = "fallbackPayment"
)
@Retry(
name = BACKEND,
fallbackMethod = "fallbackPayment"
)
public CompletableFuture<PaymentResponse> processPayment(
String orderId,
String scenario
) {
return CompletableFuture.supplyAsync(
() -> paymentProviderClient.charge(orderId, scenario)
);
}
private CompletableFuture<PaymentResponse> fallbackPayment(
String orderId,
String scenario,
Throwable throwable
) {
PaymentResponse response = new PaymentResponse(
orderId,
"PENDING_PAYMENT",
"Fallback response: "
+ throwable.getClass().getSimpleName(),
"fallback"
);
return CompletableFuture.completedFuture(response);
}
}
All annotations use the same instance name:
paymentProvider
This name connects the annotations to their corresponding configuration in application.yml.
The fallback method must:
- Accept the original method parameters
- Accept a Throwable as the final parameter
- Return a compatible return type
Because processPayment returns CompletableFuture<PaymentResponse>, the fallback method must return the same type.
Resilience4j Configuration
The complete configuration is shown below:
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents,retries,retryevents,ratelimiters,ratelimiterevents,bulkheads,bulkheadevents
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
ratelimiters:
enabled: true
resilience4j:
ratelimiter:
instances:
paymentProvider:
limitForPeriod: 5
limitRefreshPeriod: 10s
timeoutDuration: 0
registerHealthIndicator: true
bulkhead:
instances:
paymentProvider:
maxConcurrentCalls: 3
maxWaitDuration: 0
timelimiter:
instances:
paymentProvider:
timeoutDuration: 2s
cancelRunningFuture: true
circuitbreaker:
instances:
paymentProvider:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
slidingWindowSize: 5
minimumNumberOfCalls: 5
failureRateThreshold: 50
slowCallDurationThreshold: 1s
slowCallRateThreshold: 50
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
retry:
instances:
paymentProvider:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
Let’s examine each configuration separately.
Rate Limiter Configuration
limitForPeriod: 5
limitRefreshPeriod: 10s
timeoutDuration: 0
The application permits five calls every ten seconds.
When the limit is exceeded, the request is rejected immediately because timeoutDuration is set to zero.
Bulkhead Configuration
maxConcurrentCalls: 3
maxWaitDuration: 0
Only three payment operations can run concurrently.
Additional calls are rejected immediately instead of waiting for an available slot.
This protects the application from resource exhaustion when the downstream service becomes slow.
TimeLimiter Configuration
timeoutDuration: 2s
cancelRunningFuture: true
The application waits for the asynchronous operation for a maximum of two seconds.
The simulated slow provider waits for three seconds, so TimeLimiter generates a TimeoutException.
Circuit Breaker Configuration
slidingWindowType: COUNT_BASED
slidingWindowSize: 5
minimumNumberOfCalls: 5
failureRateThreshold: 50
Circuit Breaker evaluates the latest five calls.
After at least five calls are recorded, the circuit opens if 50 percent or more of those calls fail.
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
After remaining open for ten seconds, Circuit Breaker moves to the HALF_OPEN state.
It permits two test calls to determine whether the downstream service has recovered.
Retry Configuration
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
A failed operation can be attempted up to three times.
The waiting duration increases using exponential backoff:
First retry → 500 ms
Second retry → 1000 ms
Retry should be used carefully for operations that may create side effects. In real payment or order systems, retry operations should normally be combined with an idempotency mechanism.
Creating the Controller
The controller accepts the order ID as a path variable and the simulation scenario as a request parameter.
@RestController
@RequestMapping("/orders")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/{orderId}/payment")
public CompletableFuture<PaymentResponse> processPayment(
@PathVariable String orderId,
@RequestParam(defaultValue = "success") String scenario
) {
return paymentService.processPayment(
orderId,
scenario
);
}
}
Running the Application
Start the application:
mvn spring-boot:run
The application will run on port 8080.
Successful Call
Send a successful request:
curl -X POST \
"http://localhost:8080/orders/order-100/payment?scenario=success"
Response:
{
"orderId": "order-100",
"status": "PAID",
"message": "Payment completed",
"source": "payment-provider"
}
Provider Failure
Send a request that generates a provider error:
curl -X POST \
"http://localhost:8080/orders/order-101/payment?scenario=failure"
After the configured retry attempts are completed, the fallback response is returned:
{
"orderId": "order-101",
"status": "PENDING_PAYMENT",
"message": "Fallback response: IllegalStateException",
"source": "fallback"
}
Slow Provider and TimeLimiter
Send a slow request:
curl -X POST \
"http://localhost:8080/orders/order-102/payment?scenario=slow"
The provider waits for three seconds, but TimeLimiter allows only two seconds.
Response:
{
"orderId": "order-102",
"status": "PENDING_PAYMENT",
"message": "Fallback response: TimeoutException",
"source": "fallback"
}
Random Failure Scenario
The random scenario succeeds or fails with an approximately equal probability:
curl -X POST \
"http://localhost:8080/orders/order-103/payment?scenario=random"
Calling this endpoint several times helps demonstrate Retry and Circuit Breaker transitions.
Testing the Rate Limiter
Run the request more than five times within ten seconds:
for i in {1..7}
do
curl -X POST \
"http://localhost:8080/orders/order-$i/payment?scenario=success"
echo
done
After the configured limit is reached, Rate Limiter rejects additional requests and fallback handles the result.
Testing the Bulkhead
The slow scenario can be used to keep concurrent operations busy.
Send more than three slow requests simultaneously:
for i in {1..5}
do
curl -X POST \
"http://localhost:8080/orders/order-$i/payment?scenario=slow" &
done
wait
Because maxConcurrentCalls is three, additional operations are rejected by Bulkhead.
Monitoring with Spring Boot Actuator
The project exposes several Actuator endpoints.
Check the overall health:
curl "http://localhost:8080/actuator/health"
Check the Circuit Breaker instances:
curl "http://localhost:8080/actuator/circuitbreakers"
Check Circuit Breaker events:
curl "http://localhost:8080/actuator/circuitbreakerevents"
Check Retry events:
curl "http://localhost:8080/actuator/retryevents"
Check Rate Limiter events:
curl "http://localhost:8080/actuator/ratelimiterevents"
Check Bulkhead events:
curl "http://localhost:8080/actuator/bulkheadevents"
These endpoints make it possible to observe successful calls, failed calls, rejected requests, retry attempts and Circuit Breaker state transitions.
Why Do We Need Multiple Patterns?
Each pattern protects the application from a different type of failure.
Rate Limiter → Protects against excessive request rates
Bulkhead → Protects application resources
TimeLimiter → Protects against slow operations
Retry → Handles temporary failures
Circuit Breaker → Prevents repeated calls to an unhealthy service
Fallback → Provides an alternative response
Circuit Breaker alone cannot restrict traffic, limit concurrent operations or stop an individual slow call.
Similarly, Retry alone may make an overloaded service even less stable by sending additional requests.
The patterns should therefore be selected and configured according to the behavior of the downstream service.
Important Considerations
Using every resilience pattern does not automatically make an application resilient.
Some important points should be considered:
- Do not retry validation or business errors.
- Use timeout values based on the expected service latency.
- Avoid aggressive retry configurations.
- Use idempotency for operations with side effects.
- Configure separate Resilience4j instances for separate downstream services.
- Monitor fallback and rejected call rates.
- Test the configuration under concurrent load.
- Keep fallback operations simple and reliable.
Conclusion
In this article, we combined Rate Limiter, Bulkhead, TimeLimiter, Circuit Breaker, Retry and Fallback in a Spring Boot application.
Together, these patterns provide protection against:
- Excessive traffic
- Too many concurrent operations
- Slow downstream services
- Temporary failures
- Repeated calls to unavailable services
Resilience4j provides these patterns as independent modules, allowing us to select and combine only the mechanisms required by our application.
The complete source code is available on GitHub:
resilience4j-fault-tolerance-patterns-demo
You can also read the previous Circuit Breaker article:
Using Resilience4j Circuit Breaker in Spring Boot Microservices
Thanks for reading.
Happy coding!
메타데이터
- post_id
- a3a392b199f2
- slug
- combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
- url
- https://medium.com/@erkndmrl/combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
- canonical_url
- https://medium.com/@erkndmrl/combining-resilience4j-patterns-in-spring-boot-a3a392b199f2
- author_url
- https://medium.com/@erkndmrl
- status
- ok
- fetched_at
- 2026-08-03 13:33:41