diff --git a/astra-sdk-devops/pom.xml b/astra-sdk-devops/pom.xml index 74359075..27453472 100644 --- a/astra-sdk-devops/pom.xml +++ b/astra-sdk-devops/pom.xml @@ -57,6 +57,18 @@ junit-jupiter-engine test + + org.mockito + mockito-core + 5.10.0 + test + + + org.mockito + mockito-junit-jupiter + 5.10.0 + test + ch.qos.logback logback-classic diff --git a/astra-sdk-devops/src/main/java/com/dtsx/astra/sdk/utils/HttpClientWrapper.java b/astra-sdk-devops/src/main/java/com/dtsx/astra/sdk/utils/HttpClientWrapper.java index e81029ec..c8a3627a 100644 --- a/astra-sdk-devops/src/main/java/com/dtsx/astra/sdk/utils/HttpClientWrapper.java +++ b/astra-sdk-devops/src/main/java/com/dtsx/astra/sdk/utils/HttpClientWrapper.java @@ -55,6 +55,15 @@ public class HttpClientWrapper { /** Default settings in Request and Retry */ private static final int DEFAULT_TIMEOUT_CONNECT = 20; + /** Default retry settings */ + private static final int DEFAULT_MAX_RETRIES = 3; + + /** Default retry settings */ + private static final int DEFAULT_RETRY_INITIAL_DELAY_MS = 1000; + + /** Default retry settings */ + private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0; + /** Headers, Api is using JSON */ private static final String CONTENT_TYPE_JSON = "application/json"; @@ -100,6 +109,15 @@ public class HttpClientWrapper { .setTargetPreferredAuthSchemes(Arrays.asList(StandardAuthScheme.NTLM, StandardAuthScheme.DIGEST)) .build(); + /** Retry configuration. */ + protected static int maxRetries = DEFAULT_MAX_RETRIES; + + /** Retry configuration. */ + protected static int retryInitialDelayMs = DEFAULT_RETRY_INITIAL_DELAY_MS; + + /** Retry configuration. */ + protected static double retryBackoffMultiplier = DEFAULT_RETRY_BACKOFF_MULTIPLIER; + // ------------------------------------------- // ----------------- Singleton --------------- // ------------------------------------------- @@ -355,8 +373,8 @@ public ApiResponseHttp executeHttp(final Method method, final String url, final } /** - * Execute a request coming from elsewhere. - * + * Execute a request coming from elsewhere with retry logic. + * * @param req * current request * @param mandatory @@ -365,56 +383,105 @@ public ApiResponseHttp executeHttp(final Method method, final String url, final * api response */ public ApiResponseHttp executeHttp(HttpUriRequestBase req, boolean mandatory) { + return executeHttpWithRetry(req, mandatory, maxRetries, retryInitialDelayMs, retryBackoffMultiplier); + } + /** + * Execute a request with configurable retry logic. + * + * @param req + * current request + * @param mandatory + * mandatory + * @param maxRetries + * maximum number of retries + * @param initialDelayMs + * initial delay between retries in milliseconds + * @param backoffMultiplier + * multiplier for exponential backoff + * @return + * api response + */ + public ApiResponseHttp executeHttpWithRetry(HttpUriRequestBase req, boolean mandatory, int maxRetries, int initialDelayMs, double backoffMultiplier) { // Execution Infos ApiExecutionInfos.ApiExecutionInfoBuilder executionInfo = ApiExecutionInfos.builder() .withOperationName(operationName) .withHttpRequest(req); - try(CloseableHttpResponse response = httpClient.execute(req)) { - - ApiResponseHttp res; - if (response == null) { - res = new ApiResponseHttp("Response is empty, please check url", - HttpURLConnection.HTTP_UNAVAILABLE, null); - } else { - // Mapping response - String body = null; - if (null != response.getEntity()) { - body = EntityUtils.toString(response.getEntity()); - EntityUtils.consume(response.getEntity()); + int retryCount = 0; + long delayMs = initialDelayMs; + + while (true) { + try (CloseableHttpResponse response = httpClient.execute(req)) { + ApiResponseHttp res; + if (response == null) { + res = new ApiResponseHttp("Response is empty, please check url", + HttpURLConnection.HTTP_UNAVAILABLE, null); + } else { + // Mapping response + String body = null; + if (null != response.getEntity()) { + body = EntityUtils.toString(response.getEntity()); + EntityUtils.consume(response.getEntity()); + } + Map headers = new HashMap<>(); + Arrays.stream(response.getHeaders()).forEach(h -> headers.put(h.getName(), h.getValue())); + res = new ApiResponseHttp(body, response.getCode(), headers); + } + + // Error management + if (HttpURLConnection.HTTP_NOT_FOUND == res.getCode() && !mandatory) { + return res; + } + + // Check if we should retry + if (res.getCode() >= 500 && retryCount < maxRetries) { + LOGGER.warn("Received HTTP {} error, retrying in {} ms (attempt {}/{})", + res.getCode(), delayMs, retryCount + 1, maxRetries); + Thread.sleep(delayMs); + delayMs = (long) (delayMs * backoffMultiplier); + retryCount++; + continue; + } + + if (res.getCode() >= 300) { + String entity = "n/a"; + if (req.getEntity() != null) { + entity = EntityUtils.toString(req.getEntity()); + } + LOGGER.error("Error for request, url={}, method={}, body={}", + req.getUri().toString(), req.getMethod(), entity); + LOGGER.error("Response code={}, body={}", res.getCode(), res.getBody()); + processErrors(res, mandatory); + LOGGER.error("An HTTP Error occurred. The HTTP CODE Return is {}", res.getCode()); } - Map headers = new HashMap<>(); - Arrays.stream(response.getHeaders()).forEach(h -> headers.put(h.getName(), h.getValue())); - res = new ApiResponseHttp(body, response.getCode(), headers); - } - // Error management - if (HttpURLConnection.HTTP_NOT_FOUND == res.getCode() && !mandatory) { + executionInfo.withHttpResponse(res); return res; + } catch (IllegalArgumentException | IllegalStateException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Request interrupted", e); + } catch (Exception e) { + if (retryCount < maxRetries) { + LOGGER.warn("Request failed with exception, retrying in {} ms (attempt {}/{})", + delayMs, retryCount + 1, maxRetries, e); + try { + Thread.sleep(delayMs); + delayMs = (long) (delayMs * backoffMultiplier); + retryCount++; + continue; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Request interrupted", ie); + } + } + throw new RuntimeException("Error in HTTP Request: " + e.getMessage(), e); + } finally { + // Notify the observers + CompletableFuture.runAsync(() -> notifyASync(l -> l.onRequest(executionInfo.build()), observers.values())); } - if (res.getCode() >= 300) { - String entity = "n/a"; - if (req.getEntity() != null) { - entity = EntityUtils.toString(req.getEntity()); - } - LOGGER.error("Error for request, url={}, method={}, body={}", - req.getUri().toString(), req.getMethod(), entity); - LOGGER.error("Response code={}, body={}", res.getCode(), res.getBody()); - processErrors(res, mandatory); - LOGGER.error("An HTTP Error occurred. The HTTP CODE Return is {}", res.getCode()); - } - - executionInfo.withHttpResponse(res); - return res; - // do not swallow the exception - } catch (IllegalArgumentException | IllegalStateException e) { - throw e; - } catch(Exception e) { - throw new RuntimeException("Error in HTTP Request: " + e.getMessage(), e); - } finally { - // Notify the observers - CompletableFuture.runAsync(()-> notifyASync(l -> l.onRequest(executionInfo.build()), observers.values())); } } @@ -570,4 +637,51 @@ private void notifyASync(Consumer lambda, Collection { + int attempt = attemptCount.incrementAndGet(); + if (attempt <= 2) { + return mockResponse; + } + ClassicHttpResponse successResponse = mock(ClassicHttpResponse.class); + when(successResponse.getCode()).thenReturn(HttpStatus.SC_OK); + when(successResponse.getEntity()).thenReturn(new StringEntity("Success")); + return successResponse; + }); + ApiResponseHttp response = httpClient.executeHttp(request, true); + assertEquals(HttpStatus.SC_OK, response.getCode()); + assertEquals("Success", response.getBody()); + assertEquals(3, attemptCount.get()); + } + + @Test + void shouldRespectMaxRetries() throws Exception { + when(mockResponse.getCode()).thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR); + when(mockResponse.getEntity()).thenReturn(new StringEntity("Server Error")); + HttpClientWrapper.configureRetry(2); + AtomicInteger attemptCount = new AtomicInteger(0); + httpClient.httpClient = mock(CloseableHttpClient.class); + when(httpClient.httpClient.execute(any())).thenAnswer(invocation -> { + attemptCount.incrementAndGet(); + return mockResponse; + }); + RuntimeException exception = assertThrows(RuntimeException.class, + () -> httpClient.executeHttp(request, true)); + assertTrue(exception.getMessage().contains("code=500")); + assertEquals(3, attemptCount.get()); + } + + @Test + void shouldUseExponentialBackoff() throws Exception { + when(mockResponse.getCode()).thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR); + when(mockResponse.getEntity()).thenReturn(new StringEntity("Server Error")); + HttpClientWrapper.configureRetry(3, 100, 2.0); + AtomicInteger attemptCount = new AtomicInteger(0); + httpClient.httpClient = mock(CloseableHttpClient.class); + when(httpClient.httpClient.execute(any())).thenAnswer(invocation -> { + int attempt = attemptCount.incrementAndGet(); + if (attempt <= 2) { + return mockResponse; + } + ClassicHttpResponse successResponse = mock(ClassicHttpResponse.class); + when(successResponse.getCode()).thenReturn(HttpStatus.SC_OK); + when(successResponse.getEntity()).thenReturn(new StringEntity("Success")); + return successResponse; + }); + long startTime = System.currentTimeMillis(); + ApiResponseHttp response = httpClient.executeHttp(request, true); + long endTime = System.currentTimeMillis(); + assertEquals(HttpStatus.SC_OK, response.getCode()); + assertTrue(endTime - startTime >= 300); + } + + @Test + void shouldNotRetryOn400Error() throws Exception { + when(mockResponse.getCode()).thenReturn(HttpStatus.SC_BAD_REQUEST); + when(mockResponse.getEntity()).thenReturn(new StringEntity("Bad Request")); + httpClient.httpClient = mock(CloseableHttpClient.class); + when(httpClient.httpClient.execute(any())).thenAnswer(invocation -> mockResponse); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> httpClient.executeHttp(request, true)); + assertTrue(exception.getMessage().contains("HTTP_BAD_REQUEST")); + verify(httpClient.httpClient, times(1)).execute(any()); + } + + @Test + void shouldHandleInterruptedException() throws Exception { + httpClient.httpClient = mock(CloseableHttpClient.class); + when(httpClient.httpClient.execute(any())).thenThrow(new InterruptedException("Test interruption")); + RuntimeException exception = assertThrows(RuntimeException.class, + () -> httpClient.executeHttp(request, true)); + assertTrue(exception.getMessage().contains("Request interrupted")); + assertTrue(Thread.currentThread().isInterrupted()); + } + + @Test + void shouldResetRetryConfiguration() { + HttpClientWrapper.configureRetry(5, 2000, 1.5); + HttpClientWrapper.resetRetryConfiguration(); + assertEquals(3, HttpClientWrapper.maxRetries); + assertEquals(1000, HttpClientWrapper.retryInitialDelayMs); + assertEquals(2.0, HttpClientWrapper.retryBackoffMultiplier); + } +} \ No newline at end of file