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; 017 018import de.cuioss.http.client.converter.HttpContentConverter; 019import de.cuioss.http.client.result.HttpErrorCategory; 020import de.cuioss.http.client.result.HttpResultObject; 021import de.cuioss.http.client.retry.RetryContext; 022import de.cuioss.http.client.retry.RetryStrategy; 023import de.cuioss.tools.logging.CuiLogger; 024import de.cuioss.tools.net.http.HttpHandler; 025import de.cuioss.tools.net.http.HttpStatusFamily; 026import de.cuioss.uimodel.nameprovider.DisplayName; 027import de.cuioss.uimodel.result.ResultDetail; 028import de.cuioss.uimodel.result.ResultState; 029import lombok.Getter; 030import lombok.NonNull; 031 032import java.io.IOException; 033import java.net.http.HttpClient; 034import java.net.http.HttpRequest; 035import java.net.http.HttpResponse; 036import java.util.Optional; 037import java.util.concurrent.locks.ReentrantLock; 038 039/** 040 * ETag-aware HTTP handler with stateful caching capabilities and built-in retry logic. 041 * <p> 042 * This component provides HTTP-based caching using ETags and "If-None-Match" headers, 043 * with resilient HTTP operations through configurable retry strategies. 044 * It tracks whether content was loaded from cache (304 Not Modified) or freshly fetched (200 OK). 045 * <p> 046 * Thread-safe implementation using ReentrantLock for virtual thread compatibility. 047 * <h2>Retry Integration</h2> 048 * The handler integrates with {@link RetryStrategy} to provide resilient HTTP operations, 049 * solving permanent failure issues in well-known endpoint discovery and JWKS loading. 050 * 051 * @param <T> the target type for content conversion 052 * @author Oliver Wolff 053 * @since 1.0 054 */ 055public class ResilientHttpHandler<T> { 056 057 private static final CuiLogger LOGGER = new CuiLogger(ResilientHttpHandler.class); 058 private final HttpHandler httpHandler; 059 private final RetryStrategy retryStrategy; 060 private final HttpContentConverter<T> contentConverter; 061 private final ReentrantLock lock = new ReentrantLock(); 062 063 private HttpResultObject<T> cachedResult; // Guarded by lock, no volatile needed 064 @Getter private volatile LoaderStatus loaderStatus = LoaderStatus.UNDEFINED; // Explicitly tracked status 065 066 /** 067 * Creates a new ETag-aware HTTP handler with unified provider for HTTP operations and retry strategy. 068 * <p> 069 * This constructor implements the HttpHandlerProvider pattern for unified dependency injection, 070 * providing both HTTP handling capabilities and retry resilience in a single interface. 071 * 072 * @param provider the HTTP handler provider containing both HttpHandler and RetryStrategy 073 * @throws IllegalArgumentException if provider is null 074 */ 075 public ResilientHttpHandler(@NonNull HttpHandlerProvider provider, @NonNull HttpContentConverter<T> contentConverter) { 076 this.httpHandler = provider.getHttpHandler(); 077 this.retryStrategy = provider.getRetryStrategy(); 078 this.contentConverter = contentConverter; 079 } 080 081 /** 082 * Creates a ResilientHttpHandler instance without retry capability. 083 * <p> 084 * This static factory method creates a ResilientHttpHandler that only provides ETag caching 085 * and content conversion functionality, without retry logic. 086 * For retry-capable HTTP operations, use {@link #ResilientHttpHandler(HttpHandlerProvider, HttpContentConverter)} instead. 087 * 088 * @param httpHandler the HTTP handler for making requests 089 * @param contentConverter the converter for HTTP content 090 * @param <T> the type of content to be converted 091 * @return a ResilientHttpHandler instance configured without retry capability 092 */ 093 public static <T> ResilientHttpHandler<T> withoutRetry(@NonNull HttpHandler httpHandler, @NonNull HttpContentConverter<T> contentConverter) { 094 return new ResilientHttpHandler<>(httpHandler, contentConverter); 095 } 096 097 private ResilientHttpHandler(@NonNull HttpHandler httpHandler, @NonNull HttpContentConverter<T> contentConverter) { 098 this.httpHandler = httpHandler; 099 this.retryStrategy = RetryStrategy.none(); 100 this.contentConverter = contentConverter; 101 } 102 103 /** 104 * Loads HTTP content with resilient retry logic and ETag-based HTTP caching. 105 * <p> 106 * This method integrates {@link RetryStrategy} to provide resilient HTTP operations, 107 * automatically retrying transient failures and preventing permanent failure states 108 * that previously affected WellKnownResolver and JWKS loading. 109 * 110 * <h2>Virtual Threads Integration</h2> 111 * <p> 112 * The retry strategy now uses Java 21 virtual threads with non-blocking delays for efficient 113 * resource utilization. While this method maintains a synchronous API for compatibility, 114 * the internal retry operations run asynchronously on virtual threads, providing: 115 * </p> 116 * <ul> 117 * <li><strong>Non-blocking delays</strong>: Uses CompletableFuture.delayedExecutor() instead of Thread.sleep()</li> 118 * <li><strong>Resource efficiency</strong>: No blocked threads during retry delays</li> 119 * <li><strong>High scalability</strong>: Supports thousands of concurrent retry operations</li> 120 * <li><strong>Better composition</strong>: Internal async operations can be composed efficiently</li> 121 * </ul> 122 * 123 * <h2>Result States</h2> 124 * <ul> 125 * <li><strong>VALID + 200</strong>: Content freshly loaded from server (equivalent to LOADED_FROM_SERVER)</li> 126 * <li><strong>VALID + 304</strong>: Content unchanged, using cached version (equivalent to CACHE_ETAG)</li> 127 * <li><strong>VALID + no HTTP status</strong>: Content unchanged, using local cache (equivalent to CACHE_CONTENT)</li> 128 * <li><strong>WARNING + cached content</strong>: Error occurred but using cached data (equivalent to ERROR_WITH_CACHE)</li> 129 * <li><strong>ERROR + no content</strong>: Error occurred with no fallback (equivalent to ERROR_NO_CACHE)</li> 130 * </ul> 131 * 132 * <h2>Retry Integration</h2> 133 * The method uses the configured {@link RetryStrategy} to handle transient failures: 134 * <ul> 135 * <li>Network timeouts and connection errors are retried with exponential backoff</li> 136 * <li>HTTP 5xx server errors are retried as they're often transient</li> 137 * <li>HTTP 4xx client errors are not retried as they're typically permanent</li> 138 * <li>Cache responses (304 Not Modified) are not subject to retry</li> 139 * </ul> 140 * 141 * @return HttpResultObject containing content and detailed state information, never null 142 */ 143 public HttpResultObject<T> load() { 144 lock.lock(); 145 try { 146 // Set status to LOADING before starting the operation 147 loaderStatus = LoaderStatus.LOADING; 148 149 // Use RetryStrategy to handle transient failures 150 RetryContext retryContext = new RetryContext("ETag-HTTP-Load:" + httpHandler.getUri().toString(), 1); 151 152 // Execute async retry strategy and block for result (maintains existing synchronous API) 153 HttpResultObject<T> result = retryStrategy.execute(this::fetchContentWithCache, retryContext).join(); 154 155 // Update status based on the result 156 updateStatusFromResult(result); 157 158 return result; 159 } finally { 160 lock.unlock(); 161 } 162 } 163 164 165 /** 166 * Handles error results by returning cached content if available. 167 * 168 * @param category the error category to use for the result 169 */ 170 private HttpResultObject<T> handleErrorResult(HttpErrorCategory category) { 171 if (cachedResult != null && cachedResult.getResult() != null) { 172 return new HttpResultObject<>( 173 cachedResult.getResult(), 174 ResultState.WARNING, // Using cached content but with error condition 175 new ResultDetail( 176 new DisplayName("HTTP request failed, using cached content from " + httpHandler.getUrl())), 177 category, 178 cachedResult.getETag().orElse(null), 179 cachedResult.getHttpStatus().orElse(null) 180 ); 181 } else { 182 return HttpResultObject.error( 183 getEmptyFallback(), // Safe empty fallback 184 category, 185 new ResultDetail( 186 new DisplayName("HTTP request failed with no cached content available from " + httpHandler.getUrl())) 187 ); 188 } 189 } 190 191 192 /** 193 * Executes HTTP request with ETag validation support and direct HttpResultObject return. 194 * <p> 195 * This method now returns HttpResultObject directly to support RetryStrategy.execute(), 196 * implementing the HttpOperation<String> pattern for resilient HTTP operations. 197 * 198 * @return HttpResultObject containing content and state information, never null 199 */ 200 @SuppressWarnings("java:S2095") 201 private HttpResultObject<T> fetchContentWithCache() { 202 // Build request with conditional headers 203 HttpRequest request = buildRequestWithConditionalHeaders(); 204 205 try { 206 HttpClient client = httpHandler.createHttpClient(); 207 HttpResponse<?> response = client.send(request, contentConverter.getBodyHandler()); 208 209 return processHttpResponse(response); 210 211 } catch (IOException e) { 212 LOGGER.warn(e, HttpLogMessages.WARN.HTTP_FETCH_FAILED.format(httpHandler.getUrl())); 213 // Return error result for IOException - RetryStrategy will handle retry logic 214 return handleErrorResult(HttpErrorCategory.NETWORK_ERROR); 215 } catch (InterruptedException e) { 216 Thread.currentThread().interrupt(); 217 LOGGER.warn(HttpLogMessages.WARN.HTTP_FETCH_INTERRUPTED.format(httpHandler.getUrl())); 218 // InterruptedException should not be retried 219 return handleErrorResult(HttpErrorCategory.NETWORK_ERROR); 220 } 221 } 222 223 /** 224 * Builds an HTTP request with conditional headers (ETag support). 225 * 226 * @return configured HttpRequest with conditional headers if available 227 */ 228 private HttpRequest buildRequestWithConditionalHeaders() { 229 HttpRequest.Builder requestBuilder = httpHandler.requestBuilder(); 230 231 // Add If-None-Match header if we have a cached ETag 232 if (cachedResult != null) { 233 cachedResult.getETag().ifPresent(etag -> 234 requestBuilder.header("If-None-Match", etag)); 235 } 236 237 return requestBuilder.build(); 238 } 239 240 /** 241 * Processes the HTTP response and returns appropriate result based on status code. 242 * 243 * @param response the HTTP response to process 244 * @return HttpResultObject representing the processed response 245 */ 246 private HttpResultObject<T> processHttpResponse(HttpResponse<?> response) { 247 HttpStatusFamily statusFamily = HttpStatusFamily.fromStatusCode(response.statusCode()); 248 249 if (response.statusCode() == 304) { 250 return handleNotModifiedResponse(); 251 } else if (statusFamily == HttpStatusFamily.SUCCESS) { 252 return handleSuccessResponse(response); 253 } else { 254 return handleErrorResponse(response.statusCode(), statusFamily); 255 } 256 } 257 258 /** 259 * Handles HTTP 304 Not Modified responses. 260 * 261 * @return cached content result if available 262 */ 263 private HttpResultObject<T> handleNotModifiedResponse() { 264 LOGGER.debug("HTTP content not modified (304) for %s", httpHandler.getUrl()); 265 if (cachedResult != null) { 266 return HttpResultObject.success(cachedResult.getResult(), cachedResult.getETag().orElse(null), 304); 267 } else { 268 return HttpResultObject.error( 269 getEmptyFallback(), // Safe empty fallback 270 HttpErrorCategory.SERVER_ERROR, 271 new ResultDetail( 272 new DisplayName("304 Not Modified but no cached content available")) 273 ); 274 } 275 } 276 277 /** 278 * Handles successful HTTP responses (2xx status codes). 279 * 280 * @param response the successful HTTP response 281 * @return success result with converted content or error if conversion fails 282 */ 283 private HttpResultObject<T> handleSuccessResponse(HttpResponse<?> response) { 284 Object rawContent = response.body(); 285 String etag = response.headers().firstValue("ETag").orElse(null); 286 287 LOGGER.debug("HTTP response received: %s SUCCESS for %s (etag: %s)", 288 response.statusCode(), httpHandler.getUrl(), etag); 289 290 // Convert raw content to target type 291 Optional<T> contentOpt = contentConverter.convert(rawContent); 292 293 if (contentOpt.isPresent()) { 294 // Successful conversion - update cache with new result 295 T content = contentOpt.get(); 296 HttpResultObject<T> result = HttpResultObject.success(content, etag, response.statusCode()); 297 this.cachedResult = result; 298 return result; 299 } else { 300 // Content conversion failed - return error with no cache update 301 LOGGER.warn(HttpLogMessages.WARN.CONTENT_CONVERSION_FAILED.format(httpHandler.getUrl())); 302 return HttpResultObject.error( 303 getEmptyFallback(), // Safe empty fallback 304 HttpErrorCategory.INVALID_CONTENT, 305 new ResultDetail( 306 new DisplayName("Content conversion failed for %s".formatted(httpHandler.getUrl()))) 307 ); 308 } 309 } 310 311 /** 312 * Handles error HTTP responses (4xx, 5xx status codes). 313 * 314 * @param statusCode the HTTP status code 315 * @param statusFamily the HTTP status family 316 * @return error result with appropriate category 317 */ 318 private HttpResultObject<T> handleErrorResponse(int statusCode, HttpStatusFamily statusFamily) { 319 LOGGER.warn(HttpLogMessages.WARN.HTTP_STATUS_WARNING.format(statusCode, statusFamily, httpHandler.getUrl())); 320 321 // For 4xx client errors, don't retry and return error with cache fallback if available 322 if (statusFamily == HttpStatusFamily.CLIENT_ERROR) { 323 return handleErrorResult(HttpErrorCategory.CLIENT_ERROR); 324 } 325 326 // For 5xx server errors, return error result with cache fallback if available 327 // RetryStrategy will handle retry logic, but if retries are exhausted we want cached content 328 return handleErrorResult(HttpErrorCategory.SERVER_ERROR); 329 } 330 331 /** 332 * Provides a safe empty fallback result for error cases. 333 * Uses semantically correct empty value from content converter. 334 * If no cached result available, uses converter's empty value. 335 * 336 * @return empty fallback result, never null 337 */ 338 private T getEmptyFallback() { 339 // Try to get cached result first 340 if (cachedResult != null && cachedResult.getResult() != null) { 341 return cachedResult.getResult(); 342 } 343 // Use semantically correct empty value from converter 344 // This ensures CUI ResultObject never gets null result 345 return contentConverter.emptyValue(); 346 } 347 348 /** 349 * Updates the status based on the HttpResultObject result. 350 * This method assumes the lock is already held. 351 * 352 * @param result the HttpResultObject to evaluate for status update 353 */ 354 private void updateStatusFromResult(HttpResultObject<T> result) { 355 if (result.isValid()) { 356 loaderStatus = LoaderStatus.OK; 357 } else { 358 loaderStatus = LoaderStatus.ERROR; 359 } 360 } 361 362}