001/*
002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.http.client.retry;
017
018import de.cuioss.http.client.result.HttpResultObject;
019import de.cuioss.tools.logging.CuiLogger;
020
021import java.time.Duration;
022import java.util.Objects;
023import java.util.concurrent.*;
024
025import static de.cuioss.http.client.HttpLogMessages.INFO;
026import static de.cuioss.http.client.HttpLogMessages.WARN;
027
028/**
029 * Exponential backoff retry strategy with jitter to prevent thundering herd.
030 * <p>
031 * Algorithm based on AWS Architecture Blog recommendations:
032 * - Base delay starts at initialDelay
033 * - Each retry multiplies by backoffMultiplier
034 * - Random jitter applied: delay * (1 ± jitterFactor)
035 * - Maximum delay capped at maxDelay
036 * - Total attempts limited by maxAttempts
037 * <p>
038 * The strategy includes intelligent exception classification to determine
039 * which exceptions should trigger retries versus immediate failure.
040 */
041@SuppressWarnings("java:S6218")
042public class ExponentialBackoffRetryStrategy implements RetryStrategy {
043
044    private static final CuiLogger LOGGER = new CuiLogger(ExponentialBackoffRetryStrategy.class);
045
046    private final int maxAttempts;
047    private final Duration initialDelay;
048    private final double backoffMultiplier;
049    private final Duration maxDelay;
050    private final double jitterFactor;
051    private final RetryMetrics retryMetrics;
052
053    ExponentialBackoffRetryStrategy(int maxAttempts, Duration initialDelay, double backoffMultiplier,
054                                    Duration maxDelay, double jitterFactor, RetryMetrics retryMetrics) {
055        this.maxAttempts = maxAttempts;
056        this.initialDelay = Objects.requireNonNull(initialDelay, "initialDelay");
057        this.backoffMultiplier = backoffMultiplier;
058        this.maxDelay = Objects.requireNonNull(maxDelay, "maxDelay");
059        this.jitterFactor = jitterFactor;
060        this.retryMetrics = Objects.requireNonNull(retryMetrics, "retryMetrics");
061    }
062
063    @Override
064    public <T> CompletableFuture<HttpResultObject<T>> execute(HttpOperation<T> operation, RetryContext context) {
065        Objects.requireNonNull(operation, "operation");
066        Objects.requireNonNull(context, "context");
067
068        long totalStartTime = System.nanoTime();
069        retryMetrics.recordRetryStart(context);
070
071        return executeAttempt(operation, context, 1, totalStartTime);
072    }
073
074    /**
075     * Executes a single retry attempt using virtual threads with async delays.
076     *
077     * @param operation the HTTP operation to execute
078     * @param context retry context with operation name and attempt info
079     * @param attempt current attempt number (1-based)
080     * @param totalStartTime start time for total retry operation timing
081     * @return CompletableFuture containing the result of this attempt or recursive retry
082     */
083    private <T> CompletableFuture<HttpResultObject<T>> executeAttempt(
084            HttpOperation<T> operation, RetryContext context, int attempt, long totalStartTime) {
085
086        // Execute operation on virtual thread
087        return CompletableFuture
088                .supplyAsync(() -> {
089                    long attemptStartTime = System.nanoTime();
090                    LOGGER.debug("Starting retry attempt %s for operation %s", attempt, context.operationName());
091
092                    // Execute operation - no exceptions to catch, using result pattern
093                    HttpResultObject<T> result = operation.execute();
094                    Duration attemptDuration = Duration.ofNanos(System.nanoTime() - attemptStartTime);
095
096                    // Record attempt metrics
097                    boolean success = result.isValid();
098                    retryMetrics.recordRetryAttempt(context, attempt, attemptDuration, success);
099
100                    return new AttemptResult<>(result, attemptDuration, success);
101                }, Executors.newVirtualThreadPerTaskExecutor())
102                .thenCompose(attemptResult -> {
103                    HttpResultObject<T> result = attemptResult.result();
104                    Duration attemptDuration = attemptResult.attemptDuration();
105                    boolean success = attemptResult.success();
106
107                    if (success) {
108                        // Success - record completion and return
109                        Duration totalDuration = Duration.ofNanos(System.nanoTime() - totalStartTime);
110                        retryMetrics.recordRetryComplete(context, totalDuration, true, attempt);
111
112                        if (attempt > 1) {
113                            LOGGER.info(INFO.RETRY_OPERATION_SUCCEEDED_AFTER_ATTEMPTS.format(context.operationName(), attempt, maxAttempts));
114                            // Operation succeeded after retries - just return the successful result
115                            return CompletableFuture.completedFuture(result);
116                        } else {
117                            // First attempt succeeded
118                            return CompletableFuture.completedFuture(result);
119                        }
120                    } else {
121                        // Operation failed
122                        if (attempt >= maxAttempts) {
123                            // Max attempts reached
124                            LOGGER.warn(WARN.RETRY_MAX_ATTEMPTS_REACHED.format(context.operationName(), maxAttempts, "Final attempt failed"));
125                            Duration totalDuration = Duration.ofNanos(System.nanoTime() - totalStartTime);
126                            retryMetrics.recordRetryComplete(context, totalDuration, false, maxAttempts);
127                            LOGGER.warn(WARN.RETRY_OPERATION_FAILED.format(context.operationName(), maxAttempts, totalDuration.toMillis()));
128                            return CompletableFuture.completedFuture(result);
129                        }
130
131                        // Check if this error is retryable
132                        if (!result.isRetryable()) {
133                            LOGGER.debug("Non-retryable error for operation %s (duration: %sms)", context.operationName(), attemptDuration.toMillis());
134                            Duration totalDuration = Duration.ofNanos(System.nanoTime() - totalStartTime);
135                            retryMetrics.recordRetryComplete(context, totalDuration, false, attempt);
136                            return CompletableFuture.completedFuture(result);
137                        }
138
139                        LOGGER.debug("Retry attempt %s failed for operation %s (duration: %sms)", attempt, context.operationName(), attemptDuration.toMillis());
140
141                        // Calculate delay and schedule retry using CompletableFuture.delayedExecutor
142                        Duration delay = calculateDelay(attempt);
143                        int nextAttempt = attempt + 1;
144
145                        // Record delay metrics
146                        retryMetrics.recordRetryDelay(context, nextAttempt, delay, delay); // Planned = actual for async delays
147
148                        // Use CompletableFuture.delayedExecutor with virtual threads
149                        Executor delayedExecutor = CompletableFuture.delayedExecutor(
150                                delay.toMillis(), TimeUnit.MILLISECONDS,
151                                Executors.newVirtualThreadPerTaskExecutor()
152                        );
153
154                        return CompletableFuture
155                                .supplyAsync(() -> executeAttempt(operation, context, nextAttempt, totalStartTime), delayedExecutor)
156                                .thenCompose(future -> future);
157                    }
158                });
159    }
160
161    /**
162     * Record for holding attempt result data.
163     */
164    private record AttemptResult<T>(HttpResultObject<T> result, Duration attemptDuration, boolean success) {
165    }
166
167
168    /**
169     * Calculates the delay for the given attempt using exponential backoff with jitter.
170     *
171     * @param attemptNumber the current attempt number (1-based)
172     * @return the calculated delay duration
173     */
174    @SuppressWarnings("java:S2245")
175    private Duration calculateDelay(int attemptNumber) {
176        // Exponential backoff: initialDelay * (backoffMultiplier ^ (attempt - 1))
177        double exponentialDelay = initialDelay.toMillis() * Math.pow(backoffMultiplier, (double) attemptNumber - 1);
178
179        // Apply jitter: delay * (1 ± jitterFactor)
180        // Random value between -1.0 and 1.0
181        double randomFactor = 2.0 * ThreadLocalRandom.current().nextDouble() - 1.0;
182        double jitter = 1.0 + (randomFactor * jitterFactor);
183        long delayMs = Math.round(exponentialDelay * jitter);
184
185        // Cap at maximum delay
186        return Duration.ofMillis(Math.min(delayMs, maxDelay.toMillis()));
187    }
188
189
190    /**
191     * Creates a builder for configuring the exponential backoff strategy.
192     *
193     * @return a new builder instance with default values
194     */
195    public static Builder builder() {
196        return new Builder();
197    }
198
199    /**
200     * Builder for creating ExponentialBackoffRetryStrategy instances with custom configuration.
201     */
202    public static class Builder {
203        private int maxAttempts = 5;
204        private Duration initialDelay = Duration.ofSeconds(1);
205        private double backoffMultiplier = 2.0;
206        private Duration maxDelay = Duration.ofMinutes(1);
207        private double jitterFactor = 0.1; // ±10% jitter
208        private RetryMetrics retryMetrics = RetryMetrics.noOp();
209
210        /**
211         * Sets the maximum number of retry attempts.
212         *
213         * @param maxAttempts maximum attempts (must be positive)
214         * @return this builder
215         */
216        public Builder maxAttempts(int maxAttempts) {
217            if (maxAttempts < 1) {
218                throw new IllegalArgumentException("maxAttempts must be positive, got: " + maxAttempts);
219            }
220            this.maxAttempts = maxAttempts;
221            return this;
222        }
223
224        /**
225         * Sets the initial delay before the first retry.
226         *
227         * @param initialDelay initial delay (must not be null or negative)
228         * @return this builder
229         */
230        public Builder initialDelay(Duration initialDelay) {
231            this.initialDelay = Objects.requireNonNull(initialDelay, "initialDelay");
232            if (initialDelay.isNegative()) {
233                throw new IllegalArgumentException("initialDelay cannot be negative");
234            }
235            return this;
236        }
237
238        /**
239         * Sets the backoff multiplier for exponential delay increase.
240         *
241         * @param backoffMultiplier multiplier (must be >= 1.0)
242         * @return this builder
243         */
244        public Builder backoffMultiplier(double backoffMultiplier) {
245            if (backoffMultiplier < 1.0) {
246                throw new IllegalArgumentException("backoffMultiplier must be >= 1.0, got: " + backoffMultiplier);
247            }
248            this.backoffMultiplier = backoffMultiplier;
249            return this;
250        }
251
252        /**
253         * Sets the maximum delay between retries.
254         *
255         * @param maxDelay maximum delay (must not be null or negative)
256         * @return this builder
257         */
258        public Builder maxDelay(Duration maxDelay) {
259            this.maxDelay = Objects.requireNonNull(maxDelay, "maxDelay");
260            if (maxDelay.isNegative()) {
261                throw new IllegalArgumentException("maxDelay cannot be negative");
262            }
263            return this;
264        }
265
266        /**
267         * Sets the jitter factor for randomizing delays.
268         *
269         * @param jitterFactor jitter factor (0.0 = no jitter, 1.0 = 100% jitter, must be between 0.0 and 1.0)
270         * @return this builder
271         */
272        public Builder jitterFactor(double jitterFactor) {
273            if (jitterFactor < 0.0 || jitterFactor > 1.0) {
274                throw new IllegalArgumentException("jitterFactor must be between 0.0 and 1.0, got: " + jitterFactor);
275            }
276            this.jitterFactor = jitterFactor;
277            return this;
278        }
279
280
281        /**
282         * Sets the metrics recorder for retry operations.
283         *
284         * @param retryMetrics metrics recorder (must not be null, use RetryMetrics.noOp() for no metrics)
285         * @return this builder
286         */
287        public Builder retryMetrics(RetryMetrics retryMetrics) {
288            this.retryMetrics = Objects.requireNonNull(retryMetrics, "retryMetrics");
289            return this;
290        }
291
292        /**
293         * Builds the ExponentialBackoffRetryStrategy with the configured parameters.
294         *
295         * @return configured retry strategy
296         */
297        public ExponentialBackoffRetryStrategy build() {
298            return new ExponentialBackoffRetryStrategy(maxAttempts, initialDelay, backoffMultiplier,
299                    maxDelay, jitterFactor, retryMetrics);
300        }
301    }
302}