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}