The Retry Pattern: Building Resilient Microservices
In a microservices architecture, services communicate over a network rather than in-memory calls. While this decoupling enables massive horizontal scaling and independent deployments, it also introduces a major vulnerability: the network is unreliable.
At any moment, a downstream service might experience a brief network glitch, a temporary CPU spike, a quick database lock contention, or a rolling update restart. These temporary failures are known as transient faults.
If your service immediately throws an error and fails the request the moment a downstream call fails, you create a fragile user experience. Instead, many of these transient errors can be resolved automatically by waiting a moment and trying again. This is where the Retry Pattern comes in.
In this guide, we will explore the Retry pattern, how it works under the hood, the dangers of naive implementations, and how to implement it correctly in Java (Resilience4j) and Go.
The Real-World Analogy: Redialing a Busy Line
Imagine you are trying to call a friend. You dial their number, but you get a busy signal because they are currently on another call.
Do you immediately give up, delete their contact, and assume you can never speak to them again? Of course not. You hang up, wait a minute, and dial their number again. If they are still busy, you might wait five minutes before trying again.
Eventually, their call ends, and your retry succeeds.
In microservices:
- The Call is an API request to a downstream service.
- The Busy Signal is a transient network error or a
503 Service Unavailableresponse. - The Redial is a retry attempt.
- The Wait Time is the backoff duration.
The Danger: Naive Retries & “Retry Storms”
Implementing a retry mechanism seems trivial at first glance: just wrap your HTTP call in a for loop and keep trying until it succeeds. However, a naive retry implementation can easily morph a minor hiccup into a catastrophic system-wide outage.
Imagine a downstream service that is struggling under a sudden surge of traffic. Its database is running at 99% CPU utilization, and requests are starting to timeout.
If 100 client services all detect a timeout and immediately retry 3 times without waiting, they will suddenly triple the volume of traffic hitting the already-strangling downstream service. This sudden amplification of traffic is known as a Retry Storm (or the Thundering Herd problem).
Instead of helping the downstream service recover, your retries will keep pushing it down, preventing it from ever catching up.
The Solution: Backoff & Jitter
To prevent retry storms, a resilient system must utilize two essential strategies: Backoff and Jitter.
1. Backoff Strategies
Backoff dictates how long a client should wait before making a subsequent retry attempt.
- Fixed Backoff: The client waits a constant amount of time (e.g., exactly 200ms) between attempts. While simple, it still runs the risk of synchronized traffic spikes.
- Exponential Backoff: The wait time increases exponentially with each failed attempt (e.g., 100ms, 200ms, 400ms, 800ms). This gives the downstream service progressively more time to recover as the failure persists.
2. Jitter (Randomness)
Even with exponential backoff, if a network blip causes 1,000 requests to fail at the exact same instant, all 1,000 clients will calculate the exact same backoff delay. Consequently, they will all retry simultaneously in waves, hitting the downstream service with synchronized spikes.
Jitter solves this by adding random variance to the backoff delay.
Without Jitter (Synchronized Waves):
Time: 0ms -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]
With Jitter (Distributed Traffic):
Time: 0ms -> [1000 requests fail]
Time: 92ms -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)
By spreading the retries out over a random interval, the load on the downstream service is smoothed out, allowing it to recover gracefully.
The Golden Rule of Retries: Idempotency
Before you apply retries to any API endpoint, you must ask one critical question: Is this operation idempotent?
An idempotent operation is one where making multiple identical requests has the exact same effect as making a single request.
- Idempotent: Reading a user profile (
GET /users/123), updating an entire email field (PUT /users/123/email), or deleting an item (DELETE /items/456). - Non-Idempotent: Creating a new order (
POST /orders), or processing a credit card charge (POST /payments).
Suppose you call POST /payments to charge a customer $50. The downstream payment gateway receives the request, charges the credit card successfully, but then a network blip occurs before it can send the 200 OK response back to you.
Your service registers a timeout, assumes the call failed, and automatically retries. If the payment gateway is not designed to handle duplicate requests, the customer will be charged twice.
[!WARNING] Never retry non-idempotent operations unless the downstream service supports Idempotency Keys (unique request identifiers used to detect and discard duplicate transactions).
Implementation Examples
Let’s look at how to implement the Retry Pattern in Java and Go.
1. Java (Resilience4j & Spring Boot)
Resilience4j provides a robust, highly configurable retry module. Here is how to configure it for an external billing service.
Configuration (application.yml)
resilience4j.retry:
instances:
billingService:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2.0
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions:
- org.springframework.web.client.ResourceAccessException
- io.netty.channel.ConnectTimeoutException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried
Code Implementation
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class PaymentProcessor {
private final RestTemplate restTemplate;
public PaymentProcessor(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply the configured retry policy with a fallback method
@Retry(name = "billingService", fallbackMethod = "billingFallback")
public String chargeUser(BillingRequest request) {
return restTemplate.postForObject("http://billing-service/charge", request, String.class);
}
// Executed when all retry attempts fail
public String billingFallback(BillingRequest request, Throwable throwable) {
return "Billing service is currently unavailable. Your transaction will be queued.";
}
}
2. Go (Golang)
In Go, we can write an elegant retry runner featuring exponential backoff and randomized jitter using standard library timers.
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
MaxAttempts int
MinBackoff time.Duration
MaxBackoff time.Duration
}
// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
var err error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
err = operation()
if err == nil {
return nil // Success!
}
if attempt == config.MaxAttempts {
break
}
// Calculate exponential backoff
// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
backoff := config.MinBackoff * (1 << (attempt - 1))
if backoff > config.MaxBackoff || backoff <= 0 {
backoff = config.MaxBackoff
}
// Apply Full Jitter: wait randomly between 0 and backoff
jitter := time.Duration(rand.Int63n(int64(backoff)))
fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}
func main() {
config := RetryConfig{
MaxAttempts: 4,
MinBackoff: 100 * time.Millisecond,
MaxBackoff: 2000 * time.Millisecond,
}
// Mock function that fails 3 times and succeeds on the 4th
attempts := 0
mockAPI := func() error {
attempts++
if attempts < 4 {
return errors.New("network timeout (503)")
}
return nil
}
ctx := context.Background()
err := Execute(ctx, config, mockAPI)
if err != nil {
fmt.Printf("Final Outcome: %v\n", err)
} else {
fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
}
}
Retry vs. Circuit Breaker: When to Use Which?
Developers often confuse the Retry Pattern with the Circuit Breaker Pattern. While both aim to improve system resilience, they handle fundamentally different types of failures:
| Metric | Retry Pattern | Circuit Breaker Pattern |
|---|---|---|
| Primary Goal | Recovers from transient (short-lived) failures automatically. | Prevents persistent (long-lived) failures from taking down the caller. |
| Strategy | Try again immediately or after a short backoff wait. | Fail-fast immediately without invoking the target service. |
| Downstream Impact | Increases load on the downstream service temporarily. | Protects the downstream service from traffic, allowing it to recover. |
| Typical Triggers | Brief network disconnects, database timeouts, socket drops. | Downstream service is completely down, returning continuous 500s or timeouts. |
The Power Couple: Combining Both
In production systems, these two patterns are designed to be used together.
When you make a downstream request, it should pass through the Circuit Breaker first, and then the Retry wrapper.
If a transient glitch occurs, the Retry mechanism intercepts it and retries. However, if the downstream service is dead, the failures will pile up. The Circuit Breaker detects the consecutive failures, “trips” open, and blocks all future calls.
Now, when you try to call the service, the Circuit Breaker fails fast immediately, bypassing the Retry loop entirely and saving your computing resources.
Best Practices for the Retry Pattern
- Only Retry Transient Errors: Inspect HTTP status codes. Retry
503 Service Unavailable,429 Too Many Requests(respectingRetry-Afterheaders if present), and network timeouts. Do NOT retry400 Bad Request,401 Unauthorized, or404 Not Found—these will never succeed on retry. - Apply Jitter: Never use a fixed retry timer without introducing random jitter.
- Limit Max Attempts: Stop retrying after a reasonable threshold (typically 3 to 5 attempts). Unlimited retries consume resources and drag down latency.
- Be Careful with Cascading Retries: If Service A calls Service B (which retries 3 times), and Service B calls Service C (which retries 3 times), a failure in C can result in $3 \times 3 = 9$ cascading calls. Only apply retries at the boundaries where they make the most sense.
- Always Set Strict Timeouts: Make sure your network timeouts are shorter than your backoff periods, so threads are not held indefinitely.
Conclusion
The Retry Pattern is a powerful first line of defense against network unreliability in microservice architectures. When paired with Exponential Backoff, Jitter, and a Circuit Breaker, you can protect your systems from cascading outages and create a seamless, self-healing experience for your users.