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 -> e.getStatusCode() == 503)
60 * .build();
61 * </code></pre>
62 *
63 * @author guinetik <guinetik@gmail.com>
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 }