Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 66

Resilient Systems: Fallbacks, Retries, and Exponential Backoff

Recommended resources

Hello! Let's dive into our next lesson in the Microservices: Resilience & Observability module.

In our last session, we implemented the Circuit Breaker pattern to prevent cascading failures by "failing fast" when a downstream service is struggling. We used a simple fallback method to return a static message. However, not all failures are created equal. Some are temporary hiccups that might resolve on a second try.

Today, we will build on that foundation. Your learning outcome is to implement fallback strategies and retry mechanisms with exponential backoff. This will make our client service more resilient to transient failures. Understanding the nuances of retry patterns, especially how they interact with circuit breakers, is a critical topic in senior-level interviews for distributed systems roles.

1. The Rationale for Retries: Transient vs. Permanent Failures

Before implementing a retry mechanism, it's crucial to understand when to retry. Blindly retrying every failed call can make a bad situation worse.

  • Transient Failures: These are temporary and unpredictable. The operation might succeed if attempted again. Good candidates for retries include:

    • Temporary network interruptions.
    • A service instance being briefly unavailable due to a restart or deployment.
    • A request timeout where the downstream service might just be momentarily slow.
    • A temporary database deadlock that will be resolved.
  • Permanent Failures: These failures will not succeed no matter how many times you retry. Retrying these is wasteful and just adds unnecessary load to the system. Examples include:

    • 4xx HTTP errors: 400 Bad Request, 401 Unauthorized, 404 Not Found. These indicate a client-side problem or a non-existent resource. Retrying the same request will yield the same result.
    • Business logic validation errors: An order with an invalid product ID.
    • Unrecoverable bugs in the downstream service.

In an interview, demonstrating that you can distinguish between these two types of failures shows a mature understanding of system design.


2. From Naive Retries to Exponential Backoff with Jitter

Let's consider how we might implement retries for transient failures.

The Problem with Simple Retries

A naive approach would be to immediately retry the call in a loop. This is dangerous because if a service is struggling, thousands of clients hitting it with immediate retries will create a "retry storm," almost certainly causing it to crash. This is a classic distributed systems anti-pattern.

A slightly better approach is a fixed delay retry, where the client waits for a constant period (e.g., 2 seconds) between attempts. This is an improvement, but it can still cause problems at scale.

Exponential Backoff and the Thundering Herd

A much more robust strategy is exponential backoff. The idea is simple: progressively increase the wait time between each retry. For example, wait 1 second, then 2 seconds, then 4 seconds, and so on. This gives a recovering service an increasing amount of "breathing room."

However, even this has a subtle flaw. If many client instances encounter a failure at the same time, their exponential backoff schedules will be synchronized. They will all retry simultaneously in coordinated waves, causing periodic spikes of traffic. This is known as the Thundering Herd Problem.

The solution is to introduce jitter, which adds a small amount of randomness to each wait interval. This desynchronizes the retry attempts from different clients, spreading the load smoothly over time and giving the downstream service the best chance to recover.

To get a deep conceptual understanding of these patterns, watch the following video. It breaks down the theory from the ground up.

Retries & Exponential Backoff - Deep Dive

The video 'Retries & Exponential Backoff - Deep Dive' from the glich.stream channel provides an excellent theoretical explanation of why and how these retry strategies work.

Please watch these segments: Naive Retries (13:25 - 19:07): Understand why simple, constant-delay retries can worsen system load during an outage. Exponential Backoff (19:07 - 23:02): See how the exponential backoff algorithm works and how it helps a recovering service. Jitter (23:02 - 30:07): This is the key part for a senior-level understanding. Focus on how jitter solves the 'thundering herd problem' by desynchronizing clients.


3. Implementing Retries with Resilience4j in Spring Boot

Now, let's translate this theory into a practical Spring Boot implementation using Resilience4j's @Retry annotation.

The process is very similar to how we implemented the Circuit Breaker.

Step 1: Dependencies

You'll need the same dependencies as the last lesson: resilience4j-spring-boot3, spring-boot-starter-aop, and spring-boot-starter-actuator. The @Retry functionality is part of the core Resilience4j module.

Step 2: Annotate the Method and Define a Fallback

You apply the @Retry annotation to the method you want to protect. A crucial element is the fallbackMethod, which is executed only after all retry attempts have been exhausted.

@Service
public class ExternalApiService {

    private final RestTemplate restTemplate;

    // ... constructor ...

    @Retry(name = "downstreamServiceRetry", fallbackMethod = "getFallbackAfterRetry")
    public String callDownstreamService() {
        System.out.println("Attempting to call downstream service...");
        // This call might fail with a transient error
        return restTemplate.getForObject("http://downstream-service/api/data", String.class);
    }

    // This fallback is called ONLY after all retries have failed.
    public String getFallbackAfterRetry(Throwable t) {
        System.out.println("All retry attempts failed. Executing fallback.");
        // In a real app, you might return data from a cache or an empty list.
        return "Service is unavailable. Returning cached/default data.";
    }
}

Step 3: Configure the Retry Behavior

The real power comes from the configuration in your application.yml. This is where you define the exponential backoff strategy.

The following video demonstrates this implementation step-by-step.

How To Integrate Circuit Breaker And Retry In A Spring Boot Application Using Resilience4J

The video 'How To Integrate Circuit Breaker And Retry' by Refactor First shows a concise, hands-on implementation of the @Retry annotation and its configuration.

Focus on these parts: Annotation and Fallback (01:17 - 02:23): See how the @Retry annotation is applied and linked to a fallback method. Configuration (02:23 - 03:28): Pay close attention to how max-attempts, wait-duration, and enable-exponential-backoff are configured in application.yml. Controlling Retryable Exceptions (03:28 - 03:51): Note the use of retry-exceptions and ignore-exceptions. This is how you implement the transient vs. permanent failure logic we discussed. Live Demo (04:30 - 05:21): Watch the console logs to see the increasing delay between retries, which is exponential backoff in action.

Here is a sample application.yml configuration incorporating exponential backoff and jitter.

resilience4j:
  retry:
    instances:
      downstreamServiceRetry: # Must match the 'name' in @Retry
        max-attempts: 4
        # Initial wait duration before the first retry
        wait-duration: 500ms
        # Enable exponential backoff
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        # Which exceptions should trigger a retry
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
          - java.net.ConnectException
          - java.util.concurrent.TimeoutException
        # Which exceptions should NOT trigger a retry
        ignore-exceptions:
          - com.myapp.BusinessValidationException
          - org.springframework.web.client.HttpClientErrorException$NotFound

management: # Expose retry metrics via actuator
  endpoints:
    web:
      exposure:
        include: health,metrics,retry

This configuration specifies:

  • A total of 4 attempts (1 initial call + 3 retries).
  • An initial wait of 500ms.
  • The wait time will be multiplied by 2 for each subsequent retry (500ms, 1s, 2s).
  • It will only retry on specific transient exceptions, while ignoring business-related or 4xx errors.
Test your understanding!

You are designing a client for a weather API. The API is generally reliable but sometimes times out during peak hours. You decide to implement a retry mechanism. Would you retry on a TimeoutException? What about an HTTP 400 Bad Request error that occurs because you sent a malformed city name?

Show answer
  • TimeoutException: Yes, this is a classic transient error. The service is likely just busy. A retry with exponential backoff is the perfect strategy here.
  • HTTP 400 Bad Request: No, this is a permanent error for this specific request. The city name is malformed, and retrying with the same malformed name will always result in the same error. You should configure your retry mechanism to ignore this type of exception, log the error, and fix the client-side data.

4. Interview Focus: Combining Retry and Circuit Breaker

In a real system, you'll likely use both @Retry and @CircuitBreaker. How they interact is a complex and common interview topic.

The default behavior can be surprising. When both annotations are on a method, the Circuit Breaker "wraps" the Retry. This means a single incoming call that fails and retries 3 times will register as 3 consecutive failures to the circuit breaker. This can cause your circuit to trip much faster than you intended, even for transient issues that the retry mechanism could have resolved.

The desired behavior is often:

  1. Attempt to resolve transient failures using the retry mechanism first.
  2. Only if all retry attempts fail should the failure be counted against the circuit breaker's threshold.

You can achieve this by explicitly controlling the execution order of the Spring AOP aspects.

resilience4j:
  # ... your retry and circuitbreaker configs ...
  # Explicitly set the order of execution for the aspects
  # Higher number = higher precedence (executes first)
  retry:
    aspect-order: 2
  circuitbreaker:
    aspect-order: 1

By giving Retry a higher aspect-order, you ensure the retry logic runs to completion before the circuit breaker evaluates the final outcome.

The following resources provide excellent details on this interaction.

How To Integrate Circuit Breaker And Retry In A Spring Boot Application Using Resilience4J

The video 'How To Integrate Circuit Breaker And Retry' has a crucial section that explains and demonstrates how to configure the aspect order.

Watch the segment on Aspect Order (09:10 - 10:55). It shows exactly how to set the aspect-order properties in application.yml to ensure retries are attempted before the circuit breaker trips.

When Resilience Backfires: Retry and Circuit Breaker in Spring Boot

For a deeper dive into the potential pitfalls, read the article 'When Resilience Backfires' from dev.to. It clearly explains the subtle but critical interactions between these two patterns.

Focus on these sections: 'Subtle Trap': Understand how adding a CircuitBreaker changes Retry's failure handling. 'Annotation Order Doesn’t Guarantee Execution Order': This confirms that you must use explicit configuration, not just the order of annotations on the method. 'Align Retry Attempts and Circuit Thresholds': This offers a practical tip—if you have 3 retries, your circuit breaker's slidingWindowSize should be larger than 3 to avoid it tripping from a single failed operation.

Test your understanding! (Interview Question)

An interviewer asks: "You've added both @Retry and @CircuitBreaker to a service method. What are the key configuration considerations to make them work together effectively?"

Show answer

A strong answer would include these three points:

  1. Control the Execution Order: "By default, the interaction can be problematic. I would explicitly set the resilience4j.retry.aspect-order to a higher value than resilience4j.circuitbreaker.aspect-order. This ensures the retry mechanism attempts to handle transient failures first. Only if all retries fail will the final exception be propagated to the circuit breaker, which then correctly records it as a single, hard failure."

  2. Align Thresholds: "I would ensure the circuit breaker's configuration is aligned with the retry policy. For instance, if max-attempts for the retry is 4, setting the circuit breaker's sliding-window-size to something small like 5 would be risky, as a single failed operation could almost trip the circuit. I would set the sliding-window-size to a much larger value, like 20 or 50, so it measures the failure rate across multiple distinct operations, not just the retries within one."

  3. Differentiate Fallbacks: "The fallbacks for each serve different purposes. The @Retry fallback is invoked after exhausting all attempts for a single operation. The @CircuitBreaker fallback is invoked instantly when the circuit is OPEN, signifying a wider, ongoing problem. The logic in these fallbacks should reflect this; for example, a retry fallback might log the specific failure, while a circuit breaker fallback might serve cached data because the downstream service is known to be down."


Conclusion

Today you've moved beyond simple failure handling to implementing sophisticated, production-grade resilience strategies. You can now build services that can gracefully handle transient errors without overwhelming downstream dependencies.

Key Takeaways:

  • Retry mechanisms should only be used for transient failures.
  • Exponential backoff with jitter is the standard for preventing "retry storms" and the "thundering herd problem."
  • Resilience4j provides powerful implementation tools via the @Retry annotation and application.yml configuration.
  • Combining Retry and CircuitBreaker requires careful configuration of aspect order and thresholds to ensure they work together harmoniously.
  • A fallback method is your last line of defense, executed only after all retry attempts for an operation have failed.

Preview of the Next Lesson:
So far, we've assumed our client knows the exact address of the downstream service (e.g., http://downstream-service). But in a dynamic, cloud-native environment, service instances are constantly being created and destroyed. How does our client find a healthy instance to talk to? In the next lesson, we will explore this by learning to implement client-side service discovery and load balancing using Spring Cloud.