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}