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