Create your own
Lesson illustration

Implementing Retry with Resilience4j

Hello! Welcome back to our module on Inter-Service Communication Patterns.

In the last lesson, we focused on the Circuit Breaker pattern, a powerful mechanism for handling persistent faults by isolating a failing service to prevent cascading failures. This is your defense against a dependency that is down for an extended period.

However, not all failures are persistent. In distributed systems, transient faults—temporary, short-lived issues like a network hiccup, a brief database deadlock, or a momentary service overload—are common. Opening a circuit for every one of these minor blips would be overly aggressive and could reduce system availability unnecessarily. This is where the Retry pattern comes in.

Today, you will learn how to implement the Retry pattern with exponential backoff and jitter using Resilience4j to handle transient faults. Mastering this pattern is crucial for building robust applications that can gracefully handle the intermittent failures inherent in microservice architectures. This is a common topic in system design interviews, where you'll be expected to discuss not just the "what" but the "why" and "how" of building resilient clients.

1. The Case for Smart Retries

When a service call fails due to a transient error, the simplest solution is to try again. But how and when you retry makes all the difference between a resilient system and one that accidentally makes a bad situation worse.

Before we dive in, it's essential to remember a key prerequisite for retries: idempotency. An operation is idempotent if it can be called multiple times with the same input and produce the same result without additional side effects. For example, retrieving a user's data is idempotent, but charging a credit card is not (unless designed with specific idempotency keys). You should only retry operations that are safe to repeat.

Let's explore why a naive retry strategy is dangerous and how we can evolve it into a robust, production-ready pattern.

Retries & Exponential Backoff - Deep Dive

The video 'Retries & Exponential Backoff - Deep Dive' from the glich.stream channel provides an excellent conceptual journey from the problems of simple retries to the more advanced solutions.

Watch the video from 13:34 to 22:42. As you watch, focus on the evolution of the retry strategy: Naive Retry with Constant Delay (13:34 - 19:07): Understand why simply retrying with a fixed delay (e.g., every 5 seconds) can overwhelm a recovering service, especially under heavy load. This is often called a 'thundering herd' problem. Exponential Backoff (19:07 - 22:42): Grasp how increasing the delay exponentially between retries gives the downstream service more breathing room to recover.

As the video explains, exponential backoff is a significant improvement. By progressively increasing the wait time, you reduce the pressure on the struggling service, increasing the chances that one of the later retry attempts will find the service healthy again.

2. The Final Touch: Adding Jitter

Exponential backoff solves a big part of the problem, but it can still lead to issues at scale. If multiple clients experience a failure at the same time, they will all retry on the same exponentially increasing schedule, leading to synchronized waves of traffic that can still overload the downstream service.

The solution is to add jitter—a small amount of randomness—to the backoff delay. This desynchronizes the retry attempts from different clients, spreading the load more evenly over time.

Comparison of Exponential Backoff Strategies with and without Jitter
This image from an AWS blog post perfectly illustrates the concept. On the left, pure exponential backoff leads to retry attempts clustering at specific moments. On the right, adding jitter spreads those attempts out, resulting in a much smoother load on the target service.

Let's continue with the video to see a discussion of this critical enhancement.

Retries & Exponential Backoff - Deep Dive

The same video now explains the final piece of the puzzle: jitter.

Watch from 22:42 to 29:54. The presenter does a great job of explaining the 'clustering' problem with pure exponential backoff and how adding jitter solves it by randomizing the wait intervals.

For senior-level interviews, being able to articulate the progression from naive retry -> exponential backoff -> exponential backoff with jitter demonstrates a deep understanding of building resilient systems at scale.

3. Implementation with Resilience4j in Spring Boot

Now, let's translate this theory into a practical implementation using Resilience4j and Spring Boot. The process is very similar to what we did for the Circuit Breaker.

Step 1: Dependencies

The @Retry annotation, like @CircuitBreaker, relies on AOP. Ensure your pom.xml includes resilience4j-spring-boot2 and spring-boot-starter-aop.

Step 2: Configuration in application.yml

This is where you define the behavior of your retry mechanism. You can configure the number of attempts, the initial wait duration, the exponential multiplier, and the randomization factor for jitter.

Here is an example configuration for a retry instance named orderService:

resilience4j:
  retry:
    instances:
      orderService:
        max-attempts: 5  # Initial attempt + 4 retries
        wait-duration: 500ms # Initial wait duration
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2 # wait_duration * (multiplier ^ (attempt - 1))
        # Example calculation: 500ms, 1s, 2s, 4s
        enable-randomized-wait: true
        randomization-factor: 0.5 # Adds +/- 50% jitter to the wait time
        retry-exceptions: # Only retry for specific transient exceptions
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.ResourceAccessException
        ignore-exceptions: # Never retry for these exceptions
          - com.example.exceptions.InvalidOrderException # Business logic error

Key Configuration Properties:

  • max-attempts: The total number of attempts (including the first one).
  • wait-duration: The initial interval before the first retry.
  • enable-exponential-backoff & exponential-backoff-multiplier: These enable and control the exponential increase in wait time.
  • enable-randomized-wait & randomization-factor: These enable and control jitter. A factor of 0.5 on a 1000ms delay means the actual wait will be a random value between 500ms and 1500ms.
  • retry-exceptions & ignore-exceptions: Crucial for production. You should only retry on specific, known transient errors and explicitly ignore permanent or business-logic errors.

Step 3: Applying the @Retry Annotation

You apply the @Retry annotation to the method you want to protect. Similar to @CircuitBreaker, you can specify a fallback method to be executed if all retry attempts fail.

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;

@Service
public class OrderClient {

    // ... RestTemplate or FeignClient injected ...

    @Retry(name = "orderService", fallbackMethod = "getOrderDetailsFallback")
    public Order getOrderDetails(String orderId) {
        log.info("Attempting to fetch order details for orderId: {}", orderId);
        // This is the remote call that might fail transiently
        return restTemplate.getForObject("http://order-service/orders/" + orderId, Order.class);
    }

    public Order getOrderDetailsFallback(String orderId, Throwable t) {
        log.error("All retry attempts failed for orderId: {}. Error: {}", orderId, t.getMessage());
        // Return a default response or re-throw a custom "service unavailable" exception
        throw new ServiceUnavailableException("Order service is currently unavailable. Please try again later.");
    }
}

Let's watch a quick, practical demonstration of this setup.

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

The video 'How To Integrate Circuit Breaker And Retry In A Spring Boot Application Using Resilience4J' by Refactor First shows a concise implementation.

Watch the segments from 01:17-03:51 and 04:30-05:32. This will walk you through: Applying the @Retry annotation with a fallback. Configuring the retry properties in application.properties (the same applies to .yml). Seeing the exponential backoff in action via the application logs.

4. Combining Retry with Circuit Breaker

Retry and Circuit Breaker are not mutually exclusive; they are powerful partners. A common pattern is to wrap a retry mechanism inside a circuit breaker.

Execution Flow:

  1. A call is made to a method protected by both patterns.
  2. The Retry aspect intercepts the call first.
  3. If the call fails, the Retry mechanism attempts to re-execute it according to its backoff policy.
  4. If all retry attempts fail, the entire retry sequence is considered a single failure by the Circuit Breaker.
  5. The Circuit Breaker then increments its failure counter. If the failure rate threshold is crossed, the circuit opens.

This combination ensures you handle transient glitches with retries while still protecting your system from persistent faults with the circuit breaker.

By default, Resilience4j may not execute them in this order. You must explicitly configure the aspect order to ensure Retry has higher priority (a lower order number) than Circuit Breaker.

resilience4j:
  retry:
    # Your retry configs...
    configs:
      default:
        #...
  circuitbreaker:
    # Your circuit breaker configs...
    configs:
      default:
        #...
  # Set the execution order
  # Lower number = higher priority = executes first
  retry:
    aspect-order: 1
  circuitbreaker:
    aspect-order: 2

This is a critical detail for production systems and a fantastic point to bring up in an interview to showcase your advanced knowledge.

Test your understanding!

You are building a notification-service that calls an external email-provider to send emails. The call can sometimes fail due to transient network issues.

Requirements:

  1. Protect the sendEmail method with a retry mechanism named emailProviderRetry.
  2. The operation should be attempted a maximum of 4 times.
  3. The initial wait time before the first retry should be 200ms.
  4. Use exponential backoff with a multiplier of 2.
  5. Add jitter with a randomization factor of 0.25 to prevent synchronized retries.
  6. Retries should only happen for java.io.IOException and org.springframework.web.client.HttpServerErrorException.
  7. If all retries fail, execute a fallback method named logFailedEmail that logs the failure and the original request details.

Write the application.yml configuration and the Java code snippet for the EmailClient class.

Show answer

application.yml configuration:

resilience4j:
  retry:
    instances:
      emailProviderRetry:
        max-attempts: 4
        wait-duration: 200ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        enable-randomized-wait: true
        randomization-factor: 0.25
        retry-exceptions:
          - java.io.IOException
          - org.springframework.web.client.HttpServerErrorException
        ignore-exceptions:
          - org.springframework.web.client.HttpClientErrorException # e.g., 4xx errors

Java Code (EmailClient.java):

import io.github.resilience4j.retry.annotation.Retry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Component
public class EmailClient {

    private static final Logger log = LoggerFactory.getLogger(EmailClient.class);
    // Assume RestTemplate is injected here

    @Retry(name = "emailProviderRetry", fallbackMethod = "logFailedEmail")
    public void sendEmail(String to, String subject, String body) {
        log.info("Attempting to send email to {}", to);
        // Logic to call the external email-provider via RestTemplate
        // This call might throw IOException or HttpServerErrorException
        // ... restTemplate.postForObject(...)
        log.info("Successfully sent email to {}", to);
    }

    public void logFailedEmail(String to, String subject, String body, Throwable throwable) {
        log.error("CRITICAL: Final attempt to send email to {} failed. Storing for manual retry. Subject: {}. Error: {}",
                 to, subject, throwable.getMessage());
        // In a real system, you might save this failed email to a database or a Dead Letter Queue
        // for later processing. For this example, we just log it.
    }
}

Conclusion

You've now added another critical resilience pattern to your microservices toolkit. By understanding and implementing smart retries, you can build systems that are robust against the common, temporary failures of distributed environments.

Key Takeaways:

  • Purpose: The Retry pattern handles transient, temporary faults, whereas the Circuit Breaker pattern handles persistent, longer-lasting faults.
  • Best Practice: Always use exponential backoff with jitter instead of naive or constant-delay retries to avoid overwhelming downstream services.
  • Exponential Backoff: Progressively increases the wait time between retries to give a struggling service time to recover.
  • Jitter: Adds randomness to the wait time to desynchronize retries from multiple clients, preventing load spikes.
  • Resilience4j Implementation: Use the @Retry annotation and configure its behavior (max-attempts, wait-duration, exponential-backoff-multiplier, randomization-factor) in application.yml.
  • Combining Patterns: Retry and Circuit Breaker work best together. Ensure Retry executes first by setting a higher priority (aspect-order: 1) than the Circuit Breaker (aspect-order: 2).

Next Up

We've protected our services from failures in their dependencies using Circuit Breakers and Retries. But what about protecting the services themselves from being overwhelmed by too many concurrent requests? A sudden spike in traffic could exhaust a service's thread pool, causing it to become unresponsive to all requests, even simple health checks.

In our next lesson, we will explore the Bulkhead pattern, which isolates service dependencies and limits concurrent executions to prevent a single slow dependency from bringing down your entire application.

Can't find a good explanation? Sign up and we'll make it for you

Sign up