javaspringspring-retry

Springboot @Retryable


Springboot version: 3.3.2 Java: 21

import io.cloudevents.CloudEvent;
import java.io.IOException;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.core5.http.NoHttpResponseException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

@Component
@Slf4j
public class MyClient {

  private final RestTemplate restTemplate;

  public MyClient(RestTemplate restTemplate) {
    this.restTemplate = restTemplate;
    restTemplate.setInterceptors(createInterceptors());
  }

  @Retryable(
      retryFor = {NoHttpResponseException.class, RestClientException.class},
      noRetryFor = {MyRunTimeException.class},
      maxAttempts = 3,
      backoff = @Backoff(delay = 1000, multiplier = 1),
      listeners = {"MyRetryListener"})
  public CloudEvent getDecision(CloudEvent cloudEvent, String endpoint) {

    HttpEntity<CloudEvent> requestEntity = new HttpEntity<>(cloudEvent);

    ResponseEntity<CloudEvent> responseEntity =
        restTemplate.exchange(endpoint, HttpMethod.POST, requestEntity, CloudEvent.class);

    return responseEntity.getBody();
  }

  private List<ClientHttpRequestInterceptor> createInterceptors() {
    return List.of(
        (request, body, execution) -> {
          try {
            ClientHttpResponse response = execution.execute(request, body);
            HttpStatusCode statusCode = response.getStatusCode();
            if (!statusCode.is2xxSuccessful()) {
              throw new MyRunTimeException(
                  "Failed to create decision: response status %s".formatted(statusCode));
            } else {
              return response;
            }
          } catch (IOException ex) {
            throw new MyRunTimeException("Unexpected error: %s".formatted(ex.getMessage()), ex);
          }
        });
  }
}

import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class MyRetryListener implements RetryListener {

  public <T, E extends Throwable> void onError(
      RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
    log.error(
        "My retry attempt {} failed due to: {}", context.getRetryCount(), throwable.getMessage());
  }
}

I'm confused about the Springboot @Retryable behaviour, I have especially add exception MyRunTimeException in noRetryFor, but the @Retryable is still executed once for MyRunTimeException

The RestTemplate interceptor throws MyRunTimeException if the response status code is not 2xx. So I created @SpringbootTest and simulate the response status is 500.

I was able to find the error log MyRetryListener : My retry attempt 1 failed due to: Failed to create decision: response status 500 INTERNAL_SERVER_ERROR if the response status is not 2xx. It only retried once. May I ask how can I fix this issue?


Solution

  • The logic there in the RetryTemplate like this:

        catch (Throwable e) {
    
                    lastException = e;
    
                    try {
                        registerThrowable(retryPolicy, state, context, e);
                    }
                    catch (Exception ex) {
                        throw new TerminatedRetryException("Could not register throwable", ex);
                    }
                    finally {
                        doOnErrorInterceptors(retryCallback, context, e);
                    }
    
                    if (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
    

    That registerThrowable() calls eventually a RetryContextSupport:

    public void registerThrowable(Throwable throwable) {
        this.lastException = throwable;
        if (throwable != null)
            count++;
    }
    

    So, that is really expected to see that 1 in logs of your MyRetryListener.onError().

    That next if (canRetry(retryPolicy, context) after interceptor is not going to be fulfilled because of your noRetryFor = {MyRunTimeException.class}. Therefore so far all good.