Spring Retry and @Retryable for a paid API call in Spring 7

Spring Retry is archived; Spring Framework 7 has @Retryable in core. For a paid API call, narrow the exceptions, back off, and pass one idempotency key in.

5 min readSume
All posts

Spring Retry, the spring-retry library behind @EnableRetry, @Retryable, @Backoff and @Recover, is archived: its README says it is no longer maintained and has been superseded by Spring Framework 7, whose core now ships its own @Retryable, turned on with @EnableResilientMethods. With either one, a method that calls a paid API needs the exceptions narrowed, a capped exponential backoff, and an idempotency key created before the retryable method and passed in, so every attempt sends the same key.

Spring facts come from the Spring Retry README, Spring Framework's Resilience Features and REST Clients pages, and the @Retryable API docs; Resilience4j facts from its Retry page. The example API's retry rules come from Sume's Errors and spend and Create a run pages. All were read on 2026-09-29.

What changed between Spring Retry and Spring Framework 7?

The annotation name stayed; the package, attributes and defaults moved. Spring Framework 7's @Retryable lives in org.springframework.resilience.annotation.

From the Spring Retry README, Spring's Resilience Features and the @Retryable API docs, read 2026-09-29.
Spring Retry (archived)Spring Framework 7
Turn it on@EnableRetry@EnableResilientMethods
Default retriesUp to three timesmaxRetries = 3 after the first call, so 4 calls at most
Default delayNot stated in the README1 second (delay = 1000)
Which exceptionsretryFor / noRetryForincludes / excludes, plus a predicate
Backoff@Backoff(delay, maxDelay, multiplier, random)delay, multiplier, jitter, maxDelay
When retries run outA @Recover method, if you supply oneThe last original exception reaches the caller

Which exceptions should @Retryable retry?

Not the default. Spring Framework 7 retries any exception unless you narrow it, and RestClient throws a RestClientException subclass for every 4xx and 5xx. For Sume's run create, only three answers are worth resending: 409 idempotency_key_in_use (wait about a second), 429 rate_limited (wait retry-after) and 503 studio_agent_upstream_unavailable (retry with the same key). 402 insufficient_credits and 409 idempotency_conflict fail the same way every time, 502 attachment_fetch_failed is a bad input URL, and a 4xx at create means nothing ran and nothing was charged. Retryable HTTP status codes covers the general rules. Map the three to your own exception with onStatus and put only that in includes:

@Configuration
@EnableResilientMethods
class ResilienceConfig {}

@Service
class SumeRuns {
  private final RestClient http = RestClient.builder()
      .baseUrl("https://api.sume.com")
      .defaultHeader("Authorization", "Bearer " + System.getenv("SUME_API_KEY"))
      .build();

  @Retryable(includes = TransientApiException.class,
      maxRetries = 4, delay = 1000, multiplier = 2, jitter = 250, maxDelay = 60000)
  public String startRun(String idempotencyKey, Map<String, Object> body) {
    return http.post().uri("/v1/formats/acme/product-promo/runs")
        .header("Idempotency-Key", idempotencyKey)
        .contentType(MediaType.APPLICATION_JSON)
        .body(body)
        .retrieve()
        .onStatus(s -> s.value() == 409 || s.value() == 429 || s.value() == 503, (req, res) -> {
          String err = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
          if (res.getStatusCode().value() != 409 || err.contains("idempotency_key_in_use")) {
            throw new TransientApiException(res.getStatusCode().value());
          }
          throw new IllegalStateException("Sume answered 409: " + err);
        })
        .body(String.class);
  }
}

How do I stop @Retryable from sending duplicate requests?

Create the key before the call, from the thing being made, and pass it in: runs.startRun("order-" + orderId + "-promo-v1", body). The retry re-invokes the method with the same arguments, so each attempt sends the same key, and Sume answers a same-key, same-body replay with 200, the original run and idempotency_hit: true: no second run, no second charge. A UUID.randomUUID() inside the method would make every attempt a new paid run; Sume's docs say a per-request UUID makes the header decorative.

Two more rules. The annotation applies to proxy-invoked methods, so call startRun from another bean. And its delays are fixed attributes; neither the reference page nor the API docs show a way to feed a server's retry-after into them, so a 429 waits your backoff, not the header.

What about Resilience4j Retry?

It does the same job as configuration instead of annotations. Its defaults are maxAttempts 3, counting the first call, and a fixed waitDuration of 500 ms; retryExceptions, ignoreExceptions and a retryExceptionPredicate choose what to retry. The idempotency rule is the same: build the key outside the retried call.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume