View Javadoc
1   package com.guinetik.rr.interceptor;
2   
3   import com.guinetik.rr.http.CircuitBreakerOpenException;
4   import com.guinetik.rr.http.HttpConstants;
5   import com.guinetik.rr.http.RocketRestException;
6   import com.guinetik.rr.request.RequestSpec;
7   import org.slf4j.Logger;
8   import org.slf4j.LoggerFactory;
9   
10  import java.util.Arrays;
11  import java.util.HashSet;
12  import java.util.Set;
13  import java.util.function.Predicate;
14  
15  /**
16   * Interceptor that retries failed requests with configurable backoff.
17   *
18   * <p>This interceptor catches exceptions during request execution and retries
19   * them based on configurable criteria. It supports exponential backoff,
20   * maximum retry limits, and custom retry predicates.
21   *
22   * <h2>Default Retry Behavior</h2>
23   * <p>By default, retries are attempted for:
24   * <ul>
25   *   <li>5xx Server Errors (500-599)</li>
26   *   <li>408 Request Timeout</li>
27   *   <li>429 Too Many Requests</li>
28   *   <li>Network/Connection errors (status code 0 or -1)</li>
29   * </ul>
30   *
31   * <p>The following are NOT retried:
32   * <ul>
33   *   <li>4xx Client Errors (except 408, 429)</li>
34   *   <li>{@link CircuitBreakerOpenException} (circuit is open)</li>
35   * </ul>
36   *
37   * <h2>Basic Usage</h2>
38   * <pre class="language-java"><code>
39   * // Retry up to 3 times with 1 second initial delay
40   * RetryInterceptor retry = new RetryInterceptor(3, 1000);
41   *
42   * RocketClient client = RocketClientFactory.builder("https://api.example.com")
43   *     .withInterceptor(retry)
44   *     .build();
45   * </code></pre>
46   *
47   * <h2>Exponential Backoff</h2>
48   * <pre class="language-java"><code>
49   * // Retry with exponential backoff: 1s, 2s, 4s
50   * RetryInterceptor retry = new RetryInterceptor(3, 1000, 2.0);
51   * </code></pre>
52   *
53   * <h2>Custom Retry Predicate</h2>
54   * <pre class="language-java"><code>
55   * // Only retry on specific status codes
56   * RetryInterceptor retry = RetryInterceptor.builder()
57   *     .maxRetries(3)
58   *     .initialDelayMs(500)
59   *     .retryOn(e -&gt; e.getStatusCode() == 503)
60   *     .build();
61   * </code></pre>
62   *
63   * @author guinetik &lt;guinetik@gmail.com&gt;
64   * @see RequestInterceptor
65   * @see InterceptorChain
66   * @since 1.1.0
67   */
68  public class RetryInterceptor implements RequestInterceptor {
69  
70      private static final Logger logger = LoggerFactory.getLogger(RetryInterceptor.class);
71  
72      /** Default status codes that trigger retry */
73      private static final Set<Integer> DEFAULT_RETRYABLE_STATUS_CODES = new HashSet<Integer>(
74          Arrays.asList(
75              HttpConstants.StatusCodes.INTERNAL_SERVER_ERROR,  // 500
76              HttpConstants.StatusCodes.BAD_GATEWAY,            // 502
77              HttpConstants.StatusCodes.SERVICE_UNAVAILABLE,    // 503
78              504,  // Gateway Timeout
79              408,  // Request Timeout
80              429   // Too Many Requests
81          )
82      );
83  
84      private final int maxRetries;
85      private final long initialDelayMs;
86      private final double backoffMultiplier;
87      private final long maxDelayMs;
88      private final Predicate<RocketRestException> retryPredicate;
89  
90      /**
91       * Creates a retry interceptor with default settings.
92       * Uses 3 retries, 1 second initial delay, 2x backoff, 30 second max delay.
93       */
94      public RetryInterceptor() {
95          this(3, 1000, 2.0, 30000, null);
96      }
97  
98      /**
99       * Creates a retry interceptor with custom retry count and delay.
100      *
101      * @param maxRetries Maximum number of retries (must be positive)
102      * @param initialDelayMs Initial delay between retries in milliseconds
103      */
104     public RetryInterceptor(int maxRetries, long initialDelayMs) {
105         this(maxRetries, initialDelayMs, 2.0, 30000, null);
106     }
107 
108     /**
109      * Creates a retry interceptor with exponential backoff.
110      *
111      * @param maxRetries Maximum number of retries
112      * @param initialDelayMs Initial delay in milliseconds
113      * @param backoffMultiplier Multiplier for each subsequent retry (e.g., 2.0 for doubling)
114      */
115     public RetryInterceptor(int maxRetries, long initialDelayMs, double backoffMultiplier) {
116         this(maxRetries, initialDelayMs, backoffMultiplier, 30000, null);
117     }
118 
119     /**
120      * Creates a fully customized retry interceptor.
121      *
122      * @param maxRetries Maximum number of retries
123      * @param initialDelayMs Initial delay in milliseconds
124      * @param backoffMultiplier Multiplier for exponential backoff
125      * @param maxDelayMs Maximum delay cap in milliseconds
126      * @param retryPredicate Custom predicate to determine if exception is retryable (null for default)
127      */
128     public RetryInterceptor(int maxRetries, long initialDelayMs, double backoffMultiplier,
129                            long maxDelayMs, Predicate<RocketRestException> retryPredicate) {
130         if (maxRetries < 0) {
131             throw new IllegalArgumentException("maxRetries must be non-negative");
132         }
133         if (initialDelayMs < 0) {
134             throw new IllegalArgumentException("initialDelayMs must be non-negative");
135         }
136         if (backoffMultiplier < 1.0) {
137             throw new IllegalArgumentException("backoffMultiplier must be >= 1.0");
138         }
139 
140         this.maxRetries = maxRetries;
141         this.initialDelayMs = initialDelayMs;
142         this.backoffMultiplier = backoffMultiplier;
143         this.maxDelayMs = maxDelayMs;
144         this.retryPredicate = retryPredicate != null ? retryPredicate : createDefaultPredicate();
145     }
146 
147     @Override
148     public <Req, Res> Res onError(RocketRestException e, RequestSpec<Req, Res> request,
149                                    InterceptorChain chain) throws RocketRestException {
150         int currentRetry = chain.getRetryCount();
151 
152         // Check if we should retry
153         if (currentRetry >= maxRetries) {
154             logger.debug("Max retries ({}) exceeded for {} {}",
155                 maxRetries, request.getMethod(), request.getEndpoint());
156             throw e;
157         }
158 
159         if (!retryPredicate.test(e)) {
160             logger.debug("Exception not retryable: {} (status {})",
161                 e.getClass().getSimpleName(), e.getStatusCode());
162             throw e;
163         }
164 
165         // Calculate delay with exponential backoff
166         long delay = calculateDelay(currentRetry);
167 
168         logger.info("Retrying request {} {} (attempt {}/{}) after {}ms due to: {}",
169             request.getMethod(), request.getEndpoint(),
170             currentRetry + 1, maxRetries, delay, e.getMessage());
171 
172         // Sleep before retry
173         if (delay > 0) {
174             try {
175                 Thread.sleep(delay);
176             } catch (InterruptedException ie) {
177                 Thread.currentThread().interrupt();
178                 throw new RocketRestException("Retry interrupted", ie);
179             }
180         }
181 
182         // Retry the request
183         return chain.retry(request);
184     }
185 
186     @Override
187     public int getOrder() {
188         return 100; // Run after most interceptors, before logging
189     }
190 
191     /**
192      * Calculates the delay for a given retry attempt using exponential backoff.
193      */
194     private long calculateDelay(int retryCount) {
195         double delay = initialDelayMs * Math.pow(backoffMultiplier, retryCount);
196         return Math.min((long) delay, maxDelayMs);
197     }
198 
199     /**
200      * Creates the default retry predicate.
201      */
202     private Predicate<RocketRestException> createDefaultPredicate() {
203         return new Predicate<RocketRestException>() {
204             @Override
205             public boolean test(RocketRestException e) {
206                 // Never retry circuit breaker exceptions
207                 if (e instanceof CircuitBreakerOpenException) {
208                     return false;
209                 }
210 
211                 int statusCode = e.getStatusCode();
212 
213                 // Retry on connection errors (no status code)
214                 if (statusCode <= 0) {
215                     return true;
216                 }
217 
218                 // Retry on specific status codes
219                 if (DEFAULT_RETRYABLE_STATUS_CODES.contains(statusCode)) {
220                     return true;
221                 }
222 
223                 // Retry on any 5xx error
224                 if (statusCode >= HttpConstants.StatusCodes.SERVER_ERROR_MIN &&
225                     statusCode <= HttpConstants.StatusCodes.SERVER_ERROR_MAX) {
226                     return true;
227                 }
228 
229                 return false;
230             }
231         };
232     }
233 
234     /**
235      * Creates a builder for custom retry configuration.
236      *
237      * @return A new builder instance
238      */
239     public static Builder builder() {
240         return new Builder();
241     }
242 
243     /**
244      * Builder for creating customized RetryInterceptor instances.
245      */
246     public static class Builder {
247         private int maxRetries = 3;
248         private long initialDelayMs = 1000;
249         private double backoffMultiplier = 2.0;
250         private long maxDelayMs = 30000;
251         private Predicate<RocketRestException> retryPredicate;
252 
253         /**
254          * Sets the maximum number of retries.
255          *
256          * @param maxRetries Maximum retries (must be non-negative)
257          * @return This builder
258          */
259         public Builder maxRetries(int maxRetries) {
260             this.maxRetries = maxRetries;
261             return this;
262         }
263 
264         /**
265          * Sets the initial delay between retries.
266          *
267          * @param initialDelayMs Delay in milliseconds
268          * @return This builder
269          */
270         public Builder initialDelayMs(long initialDelayMs) {
271             this.initialDelayMs = initialDelayMs;
272             return this;
273         }
274 
275         /**
276          * Sets the backoff multiplier for exponential backoff.
277          *
278          * @param multiplier Multiplier (must be >= 1.0)
279          * @return This builder
280          */
281         public Builder backoffMultiplier(double multiplier) {
282             this.backoffMultiplier = multiplier;
283             return this;
284         }
285 
286         /**
287          * Sets the maximum delay cap.
288          *
289          * @param maxDelayMs Maximum delay in milliseconds
290          * @return This builder
291          */
292         public Builder maxDelayMs(long maxDelayMs) {
293             this.maxDelayMs = maxDelayMs;
294             return this;
295         }
296 
297         /**
298          * Sets a custom predicate to determine if an exception should trigger retry.
299          *
300          * @param predicate The retry predicate
301          * @return This builder
302          */
303         public Builder retryOn(Predicate<RocketRestException> predicate) {
304             this.retryPredicate = predicate;
305             return this;
306         }
307 
308         /**
309          * Builds the RetryInterceptor with configured settings.
310          *
311          * @return A new RetryInterceptor instance
312          */
313         public RetryInterceptor build() {
314             return new RetryInterceptor(maxRetries, initialDelayMs, backoffMultiplier,
315                 maxDelayMs, retryPredicate);
316         }
317     }
318 }