Spring Boot: Retry with @Retryable

Spring Retry provides declarative retry support for Spring applications. With @Retryable you can automatically re-invoke a failed method a configurable number of times with backoff delays between attempts. When all retries are exhausted, @Recover provides a fallback path. This is essential for handling transient failures in distributed systems: network timeouts, temporary service unavailability, and database lock contention.

Contents

Why Retry?

In distributed systems, transient failures are inevitable. A network call may time out, a downstream service may be momentarily overloaded, or a database connection may be briefly unavailable. These failures are temporary: the same request would succeed if attempted again a few moments later.

Without a retry mechanism, every transient failure becomes a user-visible error. Spring Retry allows you to absorb these glitches transparently by re-executing the failed operation before giving up.

Retry is appropriate when:

  • The failure is transient: network timeouts, HTTP 503, connection refused, temporary lock contention
  • The operation is idempotent: repeating it produces the same result without side effects
  • The downstream service is expected to recover quickly

Retry is not appropriate when:

  • The failure is deterministic: validation errors, authentication failures, 4xx HTTP responses
  • The operation is not idempotent: duplicate charges, duplicate order creation
  • The downstream service is experiencing a prolonged outage (use a circuit breaker instead)
Never retry non-idempotent operations blindly. If a payment call times out, you cannot be certain whether the charge was applied. Use idempotency keys or check the state before retrying.

Setup & Dependencies

Spring Retry requires two dependencies: the retry library itself and Spring AOP (which provides the proxy-based interception for @Retryable).

XML
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

If you use Gradle:

Groovy
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop'

Next, enable retry processing by adding @EnableRetry to a configuration class or directly on your @SpringBootApplication class.

Java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication
@EnableRetry
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
@EnableRetry activates the AOP advice that intercepts @Retryable method calls. Without it, the annotation is silently ignored and no retries occur.

@Retryable: Declarative Retry

Annotate any public method in a Spring-managed bean with @Retryable to enable automatic retries on failure.

Basic Usage

By default, @Retryable retries up to 3 times (the initial call plus 2 retries) on any Exception, with a 1-second fixed delay between attempts.

Java
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class ExternalApiService {

    private final RestTemplate restTemplate;

    public ExternalApiService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Retryable
    public String fetchData(String endpoint) {
        return restTemplate.getForObject(endpoint, String.class);
    }
}
Configuring maxAttempts

Use maxAttempts to control how many times the method is invoked in total (including the first call).

Java
@Retryable(maxAttempts = 5)
public String fetchData(String endpoint) {
    return restTemplate.getForObject(endpoint, String.class);
}
Targeting Specific Exceptions

Use retryFor (or include) to retry only on certain exception types. Use noRetryFor (or exclude) to skip retries for specific exceptions.

Java
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;

@Retryable(
    retryFor = { ResourceAccessException.class, HttpServerErrorException.class },
    noRetryFor = { IllegalArgumentException.class },
    maxAttempts = 4
)
public OrderResponse placeOrder(OrderRequest request) {
    return orderClient.submit(request);
}

With this configuration, the method retries up to 4 times for network errors (ResourceAccessException) and server errors (HttpServerErrorException), but immediately propagates an IllegalArgumentException without any retry.

Full Example: Service with Retry
Java
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Recover;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Service
public class PaymentService {

    private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
    private final PaymentGateway paymentGateway;

    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    @Retryable(
        retryFor = ResourceAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2)
    )
    public PaymentResult processPayment(PaymentRequest request) {
        log.info("Attempting payment for order {}", request.getOrderId());
        return paymentGateway.charge(request);
    }

    @Recover
    public PaymentResult recoverPayment(ResourceAccessException e, PaymentRequest request) {
        log.error("All retries exhausted for order {}. Error: {}", request.getOrderId(), e.getMessage());
        return PaymentResult.failed("Payment gateway unreachable after retries");
    }
}
@Retryable only works on public methods called through the Spring proxy. Self-invocation (calling the method from within the same class) bypasses the proxy and no retries occur.

@Backoff: Delay Strategies

The @Backoff annotation controls the delay between retry attempts. It supports fixed delays, exponential backoff, and randomized backoff.

Fixed Delay

A constant delay between each retry attempt.

Java
// Wait 1 second between retries
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public String callService() {
    return externalService.getData();
}
Exponential Backoff

Each successive delay is multiplied by the multiplier. This gradually increases wait times, giving the downstream service more time to recover.

Java
// Attempt 1: immediate
// Attempt 2: wait 1s
// Attempt 3: wait 2s (1000 * 2)
// Attempt 4: wait 4s (2000 * 2)
@Retryable(
    maxAttempts = 4,
    backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String callService() {
    return externalService.getData();
}
Exponential Backoff with maxDelay

Use maxDelay to cap the backoff duration. Without a cap, exponential growth can produce extremely long waits.

Java
// Delays: 500ms, 1000ms, 2000ms, 4000ms, 5000ms (capped), 5000ms (capped)
@Retryable(
    maxAttempts = 7,
    backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 5000)
)
public String callService() {
    return externalService.getData();
}
Random Backoff

Set random = true to add jitter to the delay. This prevents the thundering herd problem where many clients retry simultaneously after a shared failure.

Java
// Random delay between 1000ms and 3000ms on each retry
@Retryable(
    maxAttempts = 4,
    backoff = @Backoff(delay = 1000, maxDelay = 3000, random = true)
)
public String callService() {
    return externalService.getData();
}
Exponential Random Backoff

Combine exponential growth with randomization for the most resilient backoff strategy. The delay grows exponentially, but each value is randomized within a range.

Java
// Exponential backoff with jitter — ideal for high-concurrency scenarios
@Retryable(
    maxAttempts = 5,
    backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000, random = true)
)
public String callService() {
    return externalService.getData();
}
Exponential backoff with jitter is the recommended strategy for production systems. It prevents synchronized retry storms and distributes retry load evenly over time.

@Recover: Fallback Methods

When all retry attempts are exhausted, the @Recover method provides a fallback. Spring matches the recovery method based on the exception type and the original method's parameters and return type.

Basic Recovery
Java
@Service
public class InventoryService {

    @Retryable(retryFor = ResourceAccessException.class, maxAttempts = 3)
    public int getStockCount(String productId) {
        return inventoryClient.getStock(productId);
    }

    @Recover
    public int recoverStockCount(ResourceAccessException e, String productId) {
        log.warn("Inventory service unavailable for product {}. Returning cached value.", productId);
        return cacheService.getCachedStock(productId);
    }
}
Matching Rules

The @Recover method must satisfy these conditions:

  • The return type must match the @Retryable method's return type
  • The first parameter must be the exception type (or a parent type) that triggered the recovery
  • The remaining parameters must match the @Retryable method's parameters in order and type
  • The method must be in the same class as the @Retryable method
Multiple Recovery Methods

You can define multiple @Recover methods to handle different exception types with different fallback logic.

Java
@Service
public class CatalogService {

    @Retryable(
        retryFor = { ResourceAccessException.class, HttpServerErrorException.class },
        maxAttempts = 3
    )
    public List<Product> searchProducts(String query) {
        return catalogClient.search(query);
    }

    @Recover
    public List<Product> recoverFromNetworkError(ResourceAccessException e, String query) {
        log.warn("Network error searching for '{}'. Returning cached results.", query);
        return cacheService.getCachedProducts(query);
    }

    @Recover
    public List<Product> recoverFromServerError(HttpServerErrorException e, String query) {
        log.error("Server error {} searching for '{}'.", e.getStatusCode(), query);
        return Collections.emptyList();
    }
}

Spring selects the most specific @Recover method based on the exception type. If a ResourceAccessException causes the final failure, the first recovery method is invoked. If a HttpServerErrorException is the cause, the second is invoked.

If no matching @Recover method exists, the exception from the last retry attempt propagates to the caller. Always define a recovery method for each exception type you retry on, or use a broad parent type like Exception.

RetryTemplate: Programmatic Retry

When you need more control than @Retryable provides, such as conditional retries based on response content, dynamic policy selection, or retrying a block of code rather than a single method: use RetryTemplate directly.

Creating a RetryTemplate Bean
Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
public class RetryConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();

        // Retry up to 4 times
        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(4);
        template.setRetryPolicy(retryPolicy);

        // Exponential backoff: 1s, 2s, 4s
        ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
        backOffPolicy.setInitialInterval(1000);
        backOffPolicy.setMultiplier(2.0);
        backOffPolicy.setMaxInterval(10000);
        template.setBackOffPolicy(backOffPolicy);

        return template;
    }
}
Using RetryTemplate with RetryCallback
Java
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;

@Service
public class DataSyncService {

    private final RetryTemplate retryTemplate;
    private final ExternalApi externalApi;

    public DataSyncService(RetryTemplate retryTemplate, ExternalApi externalApi) {
        this.retryTemplate = retryTemplate;
        this.externalApi = externalApi;
    }

    public SyncResult syncData(String datasetId) {
        return retryTemplate.execute(context -> {
            log.info("Sync attempt {} for dataset {}", context.getRetryCount() + 1, datasetId);
            return externalApi.sync(datasetId);
        });
    }
}
RetryTemplate with RecoveryCallback

Pass a second lambda as the recovery callback: this is the programmatic equivalent of @Recover.

Java
public SyncResult syncData(String datasetId) {
    return retryTemplate.execute(
        // RetryCallback — the operation to retry
        context -> {
            log.info("Attempt {} for dataset {}", context.getRetryCount() + 1, datasetId);
            return externalApi.sync(datasetId);
        },
        // RecoveryCallback — invoked when all retries are exhausted
        context -> {
            log.error("All retries failed for dataset {}. Last error: {}",
                datasetId, context.getLastThrowable().getMessage());
            return SyncResult.failed("Sync failed after " + context.getRetryCount() + " attempts");
        }
    );
}
RetryTemplate with Listeners

Attach listeners to hook into the retry lifecycle for logging, metrics, or alerting.

Java
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;

public class LoggingRetryListener implements RetryListener {

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

    @Override
    public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
        log.info("Retry operation starting");
        return true; // return false to abort before the first attempt
    }

    @Override
    public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable t) {
        log.warn("Attempt {} failed: {}", context.getRetryCount(), t.getMessage());
    }

    @Override
    public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable t) {
        if (t == null) {
            log.info("Retry operation succeeded after {} attempts", context.getRetryCount());
        } else {
            log.error("Retry operation exhausted after {} attempts", context.getRetryCount());
        }
    }
}
Java
// Register the listener on the RetryTemplate
@Bean
public RetryTemplate retryTemplate() {
    RetryTemplate template = new RetryTemplate();
    template.setRetryPolicy(new SimpleRetryPolicy(4));
    template.setBackOffPolicy(new ExponentialBackOffPolicy());
    template.registerListener(new LoggingRetryListener());
    return template;
}
RetryTemplate is thread-safe and can be shared across multiple beans. Define it once as a Spring bean and inject it wherever needed.

Retry Policies

Retry policies determine whether a failed operation should be retried. Spring Retry ships with several built-in policies.

SimpleRetryPolicy

Retries a fixed number of times regardless of the exception type. This is the default policy used by @Retryable.

Java
import org.springframework.retry.policy.SimpleRetryPolicy;

SimpleRetryPolicy policy = new SimpleRetryPolicy();
policy.setMaxAttempts(5); // 1 initial + 4 retries
SimpleRetryPolicy with Exception Map

Control which exceptions are retryable by passing a map of exception types to boolean values.

Java
import org.springframework.retry.policy.SimpleRetryPolicy;
import java.util.Map;
import java.util.HashMap;

Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>();
exceptionMap.put(ResourceAccessException.class, true);   // retry
exceptionMap.put(HttpServerErrorException.class, true);   // retry
exceptionMap.put(IllegalArgumentException.class, false);  // do not retry

SimpleRetryPolicy policy = new SimpleRetryPolicy(4, exceptionMap);
MaxAttemptsRetryPolicy

A simpler alternative that only specifies the maximum number of attempts.

Java
import org.springframework.retry.policy.MaxAttemptsRetryPolicy;

MaxAttemptsRetryPolicy policy = new MaxAttemptsRetryPolicy(3);
ExceptionClassifierRetryPolicy

Uses a classifier to select different retry policies based on the exception type. This allows fine-grained control: for example, retry network errors 5 times but server errors only 2 times.

Java
import org.springframework.retry.policy.ExceptionClassifierRetryPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.policy.NeverRetryPolicy;
import org.springframework.classify.SubclassClassifier;
import java.util.Map;
import java.util.HashMap;

ExceptionClassifierRetryPolicy classifierPolicy = new ExceptionClassifierRetryPolicy();

Map<Class<? extends Throwable>, org.springframework.retry.RetryPolicy> policyMap = new HashMap<>();
policyMap.put(ResourceAccessException.class, new SimpleRetryPolicy(5));
policyMap.put(HttpServerErrorException.class, new SimpleRetryPolicy(2));
policyMap.put(IllegalArgumentException.class, new NeverRetryPolicy());

classifierPolicy.setPolicyMap(policyMap);
CompositeRetryPolicy

Combines multiple policies. In optimistic mode (default), a retry is attempted if any policy allows it. In pessimistic mode, all policies must agree.

Java
import org.springframework.retry.policy.CompositeRetryPolicy;

CompositeRetryPolicy composite = new CompositeRetryPolicy();
composite.setPolicies(new org.springframework.retry.RetryPolicy[]{
    new SimpleRetryPolicy(5),
    new TimeoutRetryPolicy()      // also limits by total elapsed time
});
composite.setOptimistic(false);   // pessimistic — ALL policies must allow

Backoff Policies

Backoff policies control the delay between retry attempts when using RetryTemplate.

FixedBackOffPolicy

A constant delay between each retry.

Java
import org.springframework.retry.backoff.FixedBackOffPolicy;

FixedBackOffPolicy backOff = new FixedBackOffPolicy();
backOff.setBackOffPeriod(2000); // 2 seconds between retries
ExponentialBackOffPolicy

The delay doubles (or grows by a custom multiplier) after each retry, up to a maximum interval.

Java
import org.springframework.retry.backoff.ExponentialBackOffPolicy;

ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(500);    // first delay: 500ms
backOff.setMultiplier(2.0);          // each subsequent delay doubles
backOff.setMaxInterval(10000);       // cap at 10 seconds
// Delays: 500ms, 1000ms, 2000ms, 4000ms, 8000ms, 10000ms, 10000ms...
ExponentialRandomBackOffPolicy

Adds randomization (jitter) to exponential backoff. This is the most robust choice for distributed systems because it prevents synchronized retries from multiple clients.

Java
import org.springframework.retry.backoff.ExponentialRandomBackOffPolicy;

ExponentialRandomBackOffPolicy backOff = new ExponentialRandomBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(15000);
// Each delay is randomized between the previous and next exponential value
UniformRandomBackOffPolicy

Generates a random delay within a fixed range for each retry.

Java
import org.springframework.retry.backoff.UniformRandomBackOffPolicy;

UniformRandomBackOffPolicy backOff = new UniformRandomBackOffPolicy();
backOff.setMinBackOffPeriod(500);    // minimum 500ms
backOff.setMaxBackOffPeriod(3000);   // maximum 3 seconds
Applying Backoff Policies to RetryTemplate
Java
@Bean
public RetryTemplate retryTemplate() {
    RetryTemplate template = new RetryTemplate();

    SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(5);
    template.setRetryPolicy(retryPolicy);

    ExponentialRandomBackOffPolicy backOffPolicy = new ExponentialRandomBackOffPolicy();
    backOffPolicy.setInitialInterval(1000);
    backOffPolicy.setMultiplier(2.0);
    backOffPolicy.setMaxInterval(10000);
    template.setBackOffPolicy(backOffPolicy);

    return template;
}

Retry with Circuit Breaker

Retry alone can be harmful during prolonged outages: it hammers the failing service with repeated requests. Combining retry with a circuit breaker prevents this by stopping retries entirely when the failure rate is too high.

Spring Retry includes a built-in circuit breaker via @CircuitBreaker, which is a specialized form of @Retryable.

Java
import org.springframework.retry.annotation.CircuitBreaker;
import org.springframework.retry.annotation.Recover;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

    private final SmsGateway smsGateway;

    public NotificationService(SmsGateway smsGateway) {
        this.smsGateway = smsGateway;
    }

    @CircuitBreaker(
        maxAttempts = 3,
        openTimeout = 15000,    // circuit stays open for 15 seconds
        resetTimeout = 30000    // circuit resets after 30 seconds
    )
    public void sendSms(String phoneNumber, String message) {
        smsGateway.send(phoneNumber, message);
    }

    @Recover
    public void recoverSendSms(Exception e, String phoneNumber, String message) {
        log.error("SMS circuit open. Queuing message for {} : {}", phoneNumber, e.getMessage());
        messageQueue.enqueue(new SmsMessage(phoneNumber, message));
    }
}

How the Spring Retry circuit breaker works:

  • CLOSED: the method executes normally with retries (up to maxAttempts)
  • OPEN: if failures occur within the openTimeout window, the circuit opens and immediately delegates to @Recover without attempting the method
  • HALF_OPEN: after resetTimeout elapses, the next call is allowed through. If it succeeds, the circuit closes; if it fails, it reopens
Using RetryTemplate with CircuitBreakerRetryPolicy
Java
import org.springframework.retry.policy.CircuitBreakerRetryPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

@Bean
public RetryTemplate circuitBreakerRetryTemplate() {
    RetryTemplate template = new RetryTemplate();

    CircuitBreakerRetryPolicy circuitBreaker = new CircuitBreakerRetryPolicy(
        new SimpleRetryPolicy(3)
    );
    circuitBreaker.setOpenTimeout(15000);   // 15 seconds
    circuitBreaker.setResetTimeout(30000);  // 30 seconds

    template.setRetryPolicy(circuitBreaker);
    return template;
}
For more advanced circuit breaker features (sliding windows, failure rate thresholds, half-open call limits), consider using Resilience4j alongside Spring Retry. Spring Retry's circuit breaker is simpler but sufficient for many use cases.

Best Practices

1. Ensure operations are idempotent

Any operation you retry must be safe to repeat. For HTTP calls, use idempotency keys. For database writes, use upserts or check-before-write patterns. If you cannot make an operation idempotent, do not retry it.

2. Log every retry attempt

Retries can mask underlying problems. Use a RetryListener or add logging inside @Retryable methods to track retry counts, exception types, and timing. This visibility is critical for diagnosing intermittent issues.

Java
@Retryable(
    retryFor = ResourceAccessException.class,
    maxAttempts = 3,
    listeners = "loggingRetryListener"
)
public String fetchData(String endpoint) {
    return restTemplate.getForObject(endpoint, String.class);
}

3. Only retry transient exceptions

Always specify retryFor to target transient failures. Retrying a NullPointerException, IllegalArgumentException, or HTTP 400 wastes time because the result will be the same every time.

4. Use exponential backoff with jitter

Fixed-interval retries can overwhelm a recovering service. Exponential backoff spreads out retry load, and jitter prevents synchronized retry storms from multiple instances.

5. Set a maxDelay

Without a cap, exponential backoff can produce delays of minutes or hours. Always set maxDelay to a reasonable upper bound (e.g. 10–30 seconds).

6. Keep maxAttempts low

Three to five attempts is usually sufficient. More than that suggests the failure is not transient, and a circuit breaker would be more appropriate.

7. Always define a @Recover method

Without a recovery method, the last exception propagates to the caller after retries are exhausted. A @Recover method lets you return a safe default, throw a domain-specific exception, or queue the work for later processing.

8. Test your retry configuration

Write integration tests that simulate failures and verify that the correct number of retries occurs, the backoff timing is reasonable, and the recovery method is invoked.

Java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.web.client.ResourceAccessException;

import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;

@SpringBootTest
class PaymentServiceRetryTest {

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentGateway paymentGateway;

    @Test
    void shouldRetryAndRecover() {
        when(paymentGateway.charge(any()))
            .thenThrow(new ResourceAccessException("Connection refused"));

        PaymentResult result = paymentService.processPayment(new PaymentRequest("order-1"));

        // Verify 3 attempts were made (maxAttempts = 3)
        verify(paymentGateway, times(3)).charge(any());
        assertThat(result.isSuccess()).isFalse();
    }

    @Test
    void shouldSucceedOnSecondAttempt() {
        when(paymentGateway.charge(any()))
            .thenThrow(new ResourceAccessException("Timeout"))
            .thenReturn(PaymentResult.success("txn-123"));

        PaymentResult result = paymentService.processPayment(new PaymentRequest("order-2"));

        verify(paymentGateway, times(2)).charge(any());
        assertThat(result.isSuccess()).isTrue();
    }
}

9. Combine with circuit breaker for production resilience

Retries handle individual transient failures. Circuit breakers handle sustained outages. Together they provide a complete fault tolerance strategy: retry a few times for quick recovery, then trip the circuit breaker to stop wasting resources on a service that is clearly down.

10. Be cautious with retry on write operations

Read operations are naturally idempotent, but writes require careful design. Consider these patterns:

  • Use idempotency keys in API calls so the server deduplicates repeated requests
  • Check the resource state before retrying a create operation to avoid duplicates
  • Use database transactions with appropriate isolation levels for retry safety
  • For messaging systems, enable producer idempotence (e.g. Kafka's enable.idempotence=true)