00:00

Retry Pattern in Microservices

Building resilient microservices that recover from temporary failures. In a microservices architecture, the simple act of one service calling another can fail for many reasons. The Retry pattern lets a service automatically try again when a failure is likely to be temporary, so users rarely notice the hiccup.

This guide explains when retries help and when they hurt, the building blocks of a good retry strategy, and working Java examples: plain Java with exponential backoff and jitter, Resilience4j, Spring’s @Retryable, idempotency keys, and retries in Kafka consumers.

1. Why Remote Calls Fail

In a monolith, most calls are method calls inside one process, and they almost never fail. In a microservices system, the same calls travel over the network, which is far less reliable:

  • A network glitch drops or delays a packet.
  • A service is briefly overloaded and responds slowly.
  • An instance is restarting during a deployment.
  • A database is failing over to a replica.
  • A rate limiter temporarily rejects a burst of requests.

Many of these problems last only milliseconds or seconds. Returning an error to the user for such a short hiccup creates a poor experience, and that is exactly what the Retry pattern prevents.

2. What Is the Retry Pattern?

The Retry pattern is a resilience strategy in which an application automatically repeats a failed operation a limited number of times, waiting a little between attempts.

The core idea: many failures are temporary (transient). Instead of immediately returning an error, the calling service waits briefly and tries again. Often the next attempt succeeds, and the operation completes as if nothing had gone wrong.

However, not all failures are temporary. A bug in the code or an invalid request will fail every time, no matter how often you retry. The Retry pattern must therefore be applied intelligently, or it will make problems worse.

Analogy: If a phone call drops, you call back after a moment and usually get through. If the number does not exist, calling it a hundred times will never help.

3. Transient vs Permanent Failures

Transient (retry)Permanent (do not retry)
MeaningLikely to succeed if tried again soonWill fail the same way every time
NetworkConnection reset, connection timeout, read timeoutUnknown host because of a wrong configuration
HTTP status codes502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout, 429 Too Many Requests (after waiting)400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable Content
DatabaseDeadlock, lock timeout, failover in progressConstraint violation, syntax error
Typical Java exceptionsConnectException, SocketTimeoutException, ResourceAccessExceptionIllegalArgumentException, validation errors, HttpClientErrorException (4xx)

500 Internal Server Error is a grey area: it may be a temporary problem or a bug. Retry it at most once or twice, and investigate if it keeps happening.

4. Key Components of a Retry Strategy

A naive retry, trying again immediately and forever, can do more harm than good. A well-designed retry strategy has four parts:

ComponentWhat it doesExample
Retry limit (max attempts)Caps how many times an operation is tried, so a request that will never succeed does not consume resources forever3 attempts in total
Backoff strategyDecides how long to wait between attempts, giving the struggling service time to recoverFixed: 1 s, 1 s, 1 s · Exponential: 1 s, 2 s, 4 s
Jitter (randomness)Adds a random amount to each wait, so many clients do not retry at exactly the same moment and cause a “retry storm”Exponential with ±50% randomness: 0.7 s, 2.4 s, 3.1 s
Failure conditionsDefines which errors are worth retryingRetry timeouts and 503; never retry 400 or 404

Why exponential backoff? Each retry gives the failing service more and more time to recover, while the total number of attempts stays small:

AttemptFixed backoff (1 s)Exponential backoff (1 s × 2)Exponential + jitter
1st call———
Wait before 2nd1 s1 ssomewhere between 0.5 s and 1.5 s
Wait before 3rd1 s2 ssomewhere between 1 s and 3 s
Wait before 4th1 s4 ssomewhere between 2 s and 6 s

Always cap the maximum wait, for example at 10 seconds, so the exponential growth does not keep users waiting for minutes.

5. A Practical Example: Order Service and Payment Service

5.1 Scenario without the Retry pattern

  1. A user places an order, and the order service receives the request.
  2. The order service calls the payment service to take the payment.
  3. At that exact moment, the payment service is under heavy load, or a brief network problem causes the request to time out.
  4. The order service immediately returns “Payment failed” to the user.
  5. The user is frustrated, even though their card is valid, and may try again, creating duplicate order attempts.

5.2 Scenario with the Retry pattern

  1. The order service calls the payment service, and the request times out.
  2. Instead of failing, it enters its retry logic and waits about 1 second (the first step of an exponential backoff).
  3. It calls the payment service again, sending the same idempotency key, so the customer can never be charged twice (section 9).
  4. This time the network problem is gone, and the payment succeeds.
  5. The order is created, and the user sees a success message without ever knowing about the hiccup.
Timeline
Order Service                         Payment Service
     |---- attempt 1: charge(order-1001) --->|  (timeout)
     |                                        x
     |   wait ~1 s
     |---- attempt 2: charge(order-1001) --->|
     |<--------------- 200 OK ---------------|  (charged once)
     |
  create order, reply "Order placed"

6. Retry with Backoff and Jitter in Plain Java

To understand the mechanics, here is a small, reusable retry helper in plain Java, with a retry limit, exponential backoff, jitter, a maximum wait and a check for which errors to retry:

RetryExecutor.java
package com.example.resilience;

import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import java.util.function.Supplier;

public class RetryExecutor {

    private final int maxAttempts;
    private final Duration initialDelay;
    private final double multiplier;
    private final Duration maxDelay;
    private final Predicate<Exception> isRetryable;

    public RetryExecutor(int maxAttempts, Duration initialDelay, double multiplier,
                         Duration maxDelay, Predicate<Exception> isRetryable) {
        this.maxAttempts = maxAttempts;
        this.initialDelay = initialDelay;
        this.multiplier = multiplier;
        this.maxDelay = maxDelay;
        this.isRetryable = isRetryable;
    }

    public <T> T execute(Supplier<T> operation) {
        for (int attempt = 1; ; attempt++) {
            try {
                return operation.get();
            } catch (RuntimeException ex) {
                // Give up on permanent errors or after the last attempt
                if (!isRetryable.test(ex) || attempt >= maxAttempts) {
                    throw ex;
                }
                Duration wait = delayBefore(attempt + 1);
                System.out.printf("Attempt %d failed (%s). Retrying in %d ms%n",
                        attempt, ex.getClass().getSimpleName(), wait.toMillis());
                sleep(wait);
            }
        }
    }

    // Exponential backoff with jitter: a random wait between half and the full backoff value
    private Duration delayBefore(int nextAttempt) {
        double backoff = initialDelay.toMillis() * Math.pow(multiplier, nextAttempt - 2);
        long capped = (long) Math.min(backoff, maxDelay.toMillis());
        long withJitter = ThreadLocalRandom.current().nextLong(capped / 2, capped + 1);
        return Duration.ofMillis(withJitter);
    }

    private static void sleep(Duration duration) {
        try {
            Thread.sleep(duration.toMillis());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Retry interrupted", e);
        }
    }
}

Using it for the payment call from the example:

Usage
RetryExecutor retry = new RetryExecutor(
        3,                          // at most 3 attempts in total
        Duration.ofSeconds(1),      // first wait about 1 second
        2.0,                        // then about 2 seconds
        Duration.ofSeconds(10),     // never wait longer than 10 seconds
        ex -> ex instanceof ResourceAccessException                    // timeouts, connection errors
           || ex instanceof HttpServerErrorException.ServiceUnavailable);  // 503

PaymentResult result = retry.execute(
        () -> paymentClient.charge(order.id(), order.total()));

This version uses a slightly gentler jitter: each wait is a random value between half and the full backoff. In real projects, prefer a proven library such as Resilience4j, which adds configuration, metrics and integration with circuit breakers.

7. Retry with Resilience4j in Spring Boot

Resilience4j is the standard resilience library for Spring Boot. Add io.github.resilience4j:resilience4j-spring-boot3 and Spring AOP to your project, then configure a retry instance:

application.yml
resilience4j:
  retry:
    instances:
      payment:
        max-attempts: 3                    # includes the first call
        wait-duration: 1s                  # first wait
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2  # 1s, then 2s
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
          - org.springframework.web.client.HttpServerErrorException
        ignore-exceptions:
          - org.springframework.web.client.HttpClientErrorException
PaymentClient.java
package com.example.order.client;

import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import io.github.resilience4j.retry.annotation.Retry;

@Component
public class PaymentClient {

    private final RestClient restClient;

    public PaymentClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://payment-service").build();
    }

    @Retry(name = "payment", fallbackMethod = "paymentPending")
    public PaymentResult charge(ChargeRequest request) {
        return restClient.post()
                .uri("/payments")
                // The same key on every attempt: the payment service charges only once
                .header("Idempotency-Key", request.orderId())
                .body(request)
                .retrieve()
                .body(PaymentResult.class);
    }

    // Called after all attempts have failed
    private PaymentResult paymentPending(ChargeRequest request, Exception ex) {
        return PaymentResult.pending(request.orderId(),
                "Payment is being confirmed. We will email you shortly.");
    }
}

Exceptions listed under retry-exceptions are retried; those under ignore-exceptions, such as 4xx client errors, fail immediately. The fallback runs only after the last attempt fails, and here it returns a friendly “pending” result instead of an error.

7.1 Adding jitter

To combine exponential backoff with randomness, configure the interval in Java:

RetryConfiguration.java
package com.example.order.config;

import java.time.Duration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import io.github.resilience4j.common.retry.configuration.RetryConfigCustomizer;
import io.github.resilience4j.core.IntervalFunction;

@Configuration
public class RetryConfiguration {

    @Bean
    public RetryConfigCustomizer paymentRetryJitter() {
        return RetryConfigCustomizer.of("payment", builder -> builder
                .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
                        Duration.ofSeconds(1),   // initial wait
                        2.0,                     // multiplier
                        0.5)));                  // +/- 50% randomness
    }
}

8. Declarative Retries with @Retryable

Spring offers its own declarative retries. With the Spring Retry library (org.springframework.retry:spring-retry plus Spring AOP), add @EnableRetry to a configuration class and annotate methods:

InventoryClient.java
package com.example.order.client;

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;

@Component
public class InventoryClient {

    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://inventory-service").build();
    }

    @Retryable(retryFor = ResourceAccessException.class,
               maxAttempts = 3,
               backoff = @Backoff(delay = 500, multiplier = 2, random = true))
    public StockStatus checkStock(String productId) {
        return restClient.get()
                .uri("/stock/{id}", productId)
                .retrieve()
                .body(StockStatus.class);
    }

    // Runs when all attempts have failed
    @Recover
    public StockStatus stockUnknown(ResourceAccessException ex, String productId) {
        return StockStatus.unknown(productId);
    }
}
Version note: Spring Framework 7 (used by Spring Boot 4) adds a built-in @Retryable annotation in the org.springframework.resilience.annotation package, enabled with @EnableResilientMethods, so a separate library is no longer needed for simple cases. The ideas, limits, backoff and which exceptions to retry, are the same.

9. Idempotency: Making Retries Safe

A retry sends the same request again. If the first request actually succeeded but the response was lost on the way back, a retry could repeat the action: charge a card twice, create two orders, or send two emails.

The solution is idempotency: performing an operation several times must have the same effect as performing it once.

OperationNaturally idempotent?Safe to retry?
GET /orders/42Yes, it only readsYes
PUT /orders/42/status with SHIPPEDYes, it sets a valueYes
DELETE /cart/items/7Yes, deleting twice leaves the same resultYes
POST /paymentsNo, each call could create a new chargeOnly with an idempotency key

For non-idempotent operations, the client sends a unique idempotency key, such as the order ID, with every attempt. The server remembers which keys it has already processed and returns the original result instead of repeating the action:

PaymentService.java (payment-service)
@Service
public class PaymentService {

    private final PaymentRepository payments;
    private final CardProcessor cardProcessor;

    public PaymentService(PaymentRepository payments, CardProcessor cardProcessor) {
        this.payments = payments;
        this.cardProcessor = cardProcessor;
    }

    @Transactional
    public PaymentResult charge(String idempotencyKey, ChargeRequest request) {
        // Already processed? Return the stored result instead of charging again
        Optional<Payment> existing = payments.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return PaymentResult.from(existing.get());
        }

        String transactionId = cardProcessor.charge(request.cardToken(), request.amount());
        Payment payment = payments.save(new Payment(idempotencyKey, transactionId, request.amount()));
        return PaymentResult.from(payment);
    }
}

Put a unique constraint on the idempotency key column, so even two retries arriving at the same moment cannot both create a payment.

10. Combining Retry with Timeout and Circuit Breaker

Retry is most effective as part of a team of resilience patterns:

PatternRole with retries
TimeoutMakes each attempt fail fast instead of hanging, so retries can happen in reasonable time
Circuit breakerWhen retries keep failing, the circuit opens and stops all calls for a while, giving the service time to recover
BulkheadLimits how many calls, including retries, can run at once
FallbackReturns a useful answer after the last attempt fails
OrderPaymentGateway.java
@Retry(name = "payment")
@CircuitBreaker(name = "payment", fallbackMethod = "paymentPending")
@TimeLimiter(name = "payment")
public CompletableFuture<PaymentResult> charge(ChargeRequest request) {
    return CompletableFuture.supplyAsync(() -> paymentClient.charge(request));
}

Resilience4j applies these from the outside in: Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead. Each retry attempt therefore goes through the circuit breaker; once the circuit is open, attempts fail instantly with CallNotPermittedException, which you should usually not retry. Add it to ignore-exceptions so the retry stops at once.

11. Avoiding Retry Storms

Retries can multiply load exactly when a system is weakest. If every layer retries, the effect grows quickly:

Retry amplification
Client  -> retries 3 times
  API Gateway  -> retries 3 times each
    Order Service  -> retries 3 times each
      Payment Service receives up to 3 x 3 x 3 = 27 requests
      for ONE user click - while it is already struggling.

How to prevent retry storms:

  • Retry in one layer only, usually the one closest to the failing call. Other layers should fail fast.
  • Keep attempts low: 2–3 in total is usually enough.
  • Always use exponential backoff with jitter.
  • Use a circuit breaker so retries stop when a service is clearly down.
  • Use a retry budget: allow retries only while they make up a small share, for example 10%, of total traffic.
  • Respect Retry-After headers on 429 and 503 responses.

12. Retries in Messaging with Kafka

Retries are just as important when consuming messages. If a Kafka listener throws an exception, Spring for Apache Kafka can retry the message with backoff and then move it to a dead-letter topic, so one bad message does not block the others:

KafkaErrorHandlingConfig.java
package com.example.shipping.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;

@Configuration
public class KafkaErrorHandlingConfig {

    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
        // After the retries fail, publish the message to "<topic>-dlt"
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);

        ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
        backOff.setInitialInterval(1_000);   // 1 s
        backOff.setMultiplier(2.0);          // 2 s, 4 s
        backOff.setMaxInterval(10_000);

        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
        // Bad data will never succeed: send it to the dead-letter topic immediately
        handler.addNotRetryableExceptions(IllegalArgumentException.class);
        return handler;
    }
}

Spring Boot picks up this error handler for the default listener container. As with HTTP calls, the listener should be idempotent, because a message may be delivered more than once. See Asynchronous Communication in Microservices.

13. Logging and Monitoring Retries

Retries hide problems from users, but they should never hide them from you. Log every retry and track metrics:

RetryLoggingConfig.java
@Configuration
public class RetryLoggingConfig {

    private static final Logger log = LoggerFactory.getLogger(RetryLoggingConfig.class);

    public RetryLoggingConfig(RetryRegistry retryRegistry) {
        retryRegistry.retry("payment").getEventPublisher()
                .onRetry(event -> log.warn("Retry {} for '{}' after {}",
                        event.getNumberOfRetryAttempts(),
                        event.getName(),
                        event.getLastThrowable().toString()))
                .onError(event -> log.error("All retries failed for '{}'", event.getName()));
    }
}
Metric (Resilience4j via Actuator)What it tells you
resilience4j.retry.calls with kind=successful_without_retryHealthy calls
resilience4j.retry.calls with kind=successful_with_retryCalls saved by retrying; a rising number signals an unstable dependency
resilience4j.retry.calls with kind=failed_with_retryCalls that failed even after all retries
resilience4j.retry.calls with kind=failed_without_retryCalls that failed with a non-retryable error

Use these numbers to spot unstable services and to tune your policies, for example by changing the number of attempts or the backoff.

14. Best Practices

  1. Retry only transient failures: timeouts, connection errors, 502, 503, 504. Never retry client errors such as 400, 401, 403 or 404.
  2. Make operations idempotent, or use idempotency keys for anything that changes data.
  3. Limit attempts: usually 2–3 in total.
  4. Use exponential backoff with jitter, and cap the maximum wait.
  5. Set a timeout on every attempt and an overall time budget for the whole operation.
  6. Retry in one layer only to avoid multiplying load.
  7. Combine with a circuit breaker, so retries stop when a service is clearly down.
  8. Provide a fallback for when all attempts fail.
  9. Respect Retry-After headers from the server.
  10. Log and monitor retries, and alert when they increase.

15. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
Retrying immediately with no waitHammers a struggling service and makes it worseUse exponential backoff with jitter
Retrying foreverThreads and requests pile up; the user waits endlesslyLimit attempts and set an overall deadline
Retrying 4xx errorsWasted calls that can never succeedRetry only transient failures
Retrying non-idempotent operationsDouble charges, duplicate orders or emailsUse idempotency keys
Retrying at every layerOne click becomes dozens of requestsRetry in one place; fail fast elsewhere
No timeout per attemptEach attempt can hang for a long timeSet connect and read timeouts
Silent retriesUnstable services go unnoticed until they fail completelyLog retries and monitor metrics
Retrying when the circuit is openPointless attempts that fail instantlyDo not retry CallNotPermittedException

16. Summary

The Retry pattern is a simple yet powerful tool for building resilient microservices. By automatically repeating operations that fail for temporary reasons, it turns network glitches, short overloads and restarts into invisible hiccups instead of user-facing errors. Implement it carefully: retry only transient failures, limit attempts, use exponential backoff with jitter, and make operations idempotent with idempotency keys. Combine retries with timeouts, circuit breakers and fallbacks, retry in one layer only, and monitor every retry. Done well, retries transform a fragile distributed system into one that heals itself from the everyday storms of distributed computing. Continue with the Circuit Breaker pattern to learn when to stop retrying.