diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 3986331..bc7e34c 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -7,6 +7,9 @@ We greatly value your feedback, feature requests, additions to the code, bug rep - [Developer Guide](#developer-guide) - [Repository structure](#repository-structure) + - [Implementing a module waiter](#implementing-a-module-waiter) + - [Waiter structure](#waiter-structure) + - [Notes](#notes) - [Code Contributions](#code-contributions) - [Bug Reports](#bug-reports) @@ -39,6 +42,29 @@ The files located in `services/[service]` are automatically generated from the [ Inside the `core` submodule you can find several classes that are used by all service modules. Examples of usage of the SDK are located in the `examples` directory. +### Implementing a service waiter + +Waiters are routines that wait for the completion of asynchronous operations. They are located in a folder named `wait` inside each service folder. + +Let's suppose you want to implement the waiters for the `Create`, `Update` and `Delete` operations of a resource `bar` of service `foo`: + +1. Start by creating a new folder `wait/` inside `services/foo/`, if it doesn't exist yet +2. Create a file `FooWait.java` inside your new Java package `cloud.stackit.sdk.resourcemanager.wait`, if it doesn't exist yet. The class should be named `FooWait`. +3. Refer to the [Waiter structure](./CONTRIBUTION.md/#waiter-structure) section for details on the structure of the file and the methods +4. Add unit tests to the wait functions + +#### Waiter structure + +You can find a typical waiter structure here: [Example](./services/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/wait/ResourcemanagerWait.java) + +#### Notes + +- The success condition may vary from service to service. In the example above we wait for the field `Status` to match a successful or failed message, but other services may have different fields and/or values to represent the state of the create, update or delete operations. +- The `id` and the `state` might not be present on the root level of the API response, this also varies from service to service. You must always match the resource `id` and the resource `state` to what is expected. +- The timeout values included above are just for reference, each resource takes different amounts of time to finish the create, update or delete operations. You should account for some buffer, e.g. 15 minutes, on top of normal execution times. +- For some resources, after a successful delete operation the resource can't be found anymore, so a call to the `Get` method would result in an error. In those cases, the waiter can be implemented by calling the `List` method and check that the resource is not present. +- The main objective of the waiter functions is to make sure that the operation was successful, which means any other special cases such as intermediate error states should also be handled. + ## Code Contributions To make your contribution, follow these steps: diff --git a/core/src/main/java/cloud/stackit/sdk/core/oapierror/GenericOpenAPIException.java b/core/src/main/java/cloud/stackit/sdk/core/oapierror/GenericOpenAPIException.java new file mode 100644 index 0000000..365fee6 --- /dev/null +++ b/core/src/main/java/cloud/stackit/sdk/core/oapierror/GenericOpenAPIException.java @@ -0,0 +1,75 @@ +package cloud.stackit.sdk.core.oapierror; + +import cloud.stackit.sdk.core.exception.ApiException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; + +public class GenericOpenAPIException extends ApiException { + + // When a response has a bad status, this limits the number of characters that are shown from + // the response Body + public static int ApiErrorMaxCharacterLimit = 500; + + private final int statusCode; + private byte[] body; + private final String errorMessage; + private Object model; + + public GenericOpenAPIException(ApiException e) { + this.statusCode = e.getCode(); + this.errorMessage = e.getMessage(); + } + + public GenericOpenAPIException(int statusCode, String errorMessage) { + this(statusCode, errorMessage, null, new HashMap<>()); + } + + public GenericOpenAPIException(int statusCode, String errorMessage, byte[] body, Object model) { + this.statusCode = statusCode; + this.errorMessage = errorMessage; + this.body = body; + this.model = model; + } + + @Override + public String getMessage() { + // Prevent negative values + if (ApiErrorMaxCharacterLimit < 0) { + ApiErrorMaxCharacterLimit = 500; + } + + if (body == null) { + return String.format("%s, status code %d", errorMessage, statusCode); + } + + String bodyStr = new String(body, StandardCharsets.UTF_8); + + if (bodyStr.length() <= ApiErrorMaxCharacterLimit) { + return String.format("%s, status code %d, Body: %s", errorMessage, statusCode, bodyStr); + } + + int indexStart = ApiErrorMaxCharacterLimit / 2; + int indexEnd = bodyStr.length() - ApiErrorMaxCharacterLimit / 2; + int numberTruncatedCharacters = indexEnd - indexStart; + + return String.format( + "%s, status code %d, Body: %s [...truncated %d characters...] %s", + errorMessage, + statusCode, + bodyStr.substring(0, indexStart), + numberTruncatedCharacters, + bodyStr.substring(indexEnd)); + } + + public int getStatusCode() { + return statusCode; + } + + public byte[] getBody() { + return body; + } + + public Object getModel() { + return model; + } +} diff --git a/core/src/main/java/cloud/stackit/sdk/core/wait/AsyncActionHandler.java b/core/src/main/java/cloud/stackit/sdk/core/wait/AsyncActionHandler.java new file mode 100644 index 0000000..3064ed2 --- /dev/null +++ b/core/src/main/java/cloud/stackit/sdk/core/wait/AsyncActionHandler.java @@ -0,0 +1,220 @@ +package cloud.stackit.sdk.core.wait; + +import cloud.stackit.sdk.core.exception.ApiException; +import cloud.stackit.sdk.core.oapierror.GenericOpenAPIException; +import java.net.HttpURLConnection; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +public class AsyncActionHandler { + public static final Set RetryHttpErrorStatusCodes = + new HashSet<>( + Arrays.asList( + HttpURLConnection.HTTP_BAD_GATEWAY, + HttpURLConnection.HTTP_GATEWAY_TIMEOUT)); + + public final String TemporaryErrorMessage = + "Temporary error was found and the retry limit was reached."; + // public final String TimoutErrorMessage = "WaitWithContext() has timed out."; + public final String NonGenericAPIErrorMessage = "Found non-GenericOpenAPIError."; + + private final Callable> checkFn; + + private long sleepBeforeWaitMillis; + private long throttleMillis; + private long timeoutMillis; + private int tempErrRetryLimit; + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + // private final WaitHandler waitHandler; + + public AsyncActionHandler(Callable> checkFn) { + this.checkFn = checkFn; + this.sleepBeforeWaitMillis = 0; + this.throttleMillis = TimeUnit.SECONDS.toMillis(5); + this.timeoutMillis = TimeUnit.MINUTES.toMillis(30); + this.tempErrRetryLimit = 5; + } + + /** + * SetThrottle sets the time interval between each check of the async action. + * + * @param duration + * @param unit + * @return + */ + public AsyncActionHandler setThrottle(long duration, TimeUnit unit) { + this.throttleMillis = unit.toMillis(duration); + return this; + } + + /** + * SetTimeout sets the duration for wait timeout. + * + * @param duration + * @param unit + * @return + */ + public AsyncActionHandler setTimeout(long duration, TimeUnit unit) { + this.timeoutMillis = unit.toMillis(duration); + return this; + } + + /** + * SetSleepBeforeWait sets the duration for sleep before wait. + * + * @param duration + * @param unit + * @return + */ + public AsyncActionHandler setSleepBeforeWait(long duration, TimeUnit unit) { + this.sleepBeforeWaitMillis = unit.toMillis(duration); + return this; + } + + /** + * SetTempErrRetryLimit sets the retry limit if a temporary error is found. The list of + * temporary errors is defined in the RetryHttpErrorStatusCodes variable. + * + * @param limit + * @return + */ + public AsyncActionHandler setTempErrRetryLimit(int limit) { + this.tempErrRetryLimit = limit; + return this; + } + + /** + * WaitWithContextAsync starts the wait until there's an error or wait is done + * + * @return + */ + public CompletableFuture waitWithContextAsync() { + if (throttleMillis <= 0) { + throw new IllegalArgumentException("Throttle can't be 0 or less"); + } + + CompletableFuture future = new CompletableFuture<>(); + long startTime = System.currentTimeMillis(); + AtomicInteger retryTempErrorCounter = new AtomicInteger(0); + + // This runnable is called periodically. + Runnable checkTask = + new Runnable() { + @Override + public void run() { + if (System.currentTimeMillis() - startTime >= timeoutMillis) { + future.completeExceptionally(new TimeoutException("Timeout occurred.")); + } + + try { + AsyncActionResult result = checkFn.call(); + if (result.error != null) { + ErrorResult errorResult = + handleException(retryTempErrorCounter.get(), result.error); + retryTempErrorCounter.set(errorResult.retryTempErrorCounter); + + if (retryTempErrorCounter.get() == tempErrRetryLimit) { + future.completeExceptionally(errorResult.getError()); + } + } + + if (result != null && result.isFinished()) { + future.complete(result.getResponse()); + } + } catch (Exception e) { + future.completeExceptionally(e); + } + } + }; + + // start the periodic execution + ScheduledFuture scheduledFuture = + scheduler.scheduleAtFixedRate( + checkTask, sleepBeforeWaitMillis, throttleMillis, TimeUnit.MILLISECONDS); + + // stop task when future is completed + future.whenComplete( + (result, error) -> { + scheduledFuture.cancel(true); + }); + + return future; + } + + private ErrorResult handleException(int retryTempErrorCounter, Exception exception) { + if (exception instanceof ApiException) { + ApiException apiException = (ApiException) exception; + GenericOpenAPIException oapiErr = new GenericOpenAPIException(apiException); + // Some APIs may return temporary errors and the request should be retried + if (!RetryHttpErrorStatusCodes.contains(oapiErr.getStatusCode())) { + return new ErrorResult(retryTempErrorCounter, oapiErr); + } + retryTempErrorCounter++; + if (retryTempErrorCounter == tempErrRetryLimit) { + return new ErrorResult( + retryTempErrorCounter, new Exception(TemporaryErrorMessage, oapiErr)); + } + return new ErrorResult(retryTempErrorCounter, null); + } else { + retryTempErrorCounter++; + // If it's not a GenericOpenAPIError, handle it differently + return new ErrorResult( + retryTempErrorCounter, new Exception(NonGenericAPIErrorMessage, exception)); + } + } + + // Helper class to encapsulate the result of handleError + public static class ErrorResult { + private final int retryTempErrorCounter; + private final Exception error; + + public ErrorResult(int retryTempErrorCounter, Exception error) { + this.retryTempErrorCounter = retryTempErrorCounter; + this.error = error; + } + + public int getRetryErrorCounter() { + return retryTempErrorCounter; + } + + public Exception getError() { + return error; + } + } + + // Helper class to encapsulate the result of the checkFn + public static class AsyncActionResult { + private final boolean finished; + private final T response; + private final Exception error; + + public AsyncActionResult(boolean finished, T response, Exception error) { + this.finished = finished; + this.response = response; + this.error = error; + } + + public boolean isFinished() { + return finished; + } + + public T getResponse() { + return response; + } + + public Exception getError() { + return error; + } + } +} diff --git a/core/src/test/java/cloud/stackit/sdk/core/wait/AsyncWaitHandlerTest.java b/core/src/test/java/cloud/stackit/sdk/core/wait/AsyncWaitHandlerTest.java new file mode 100644 index 0000000..930f5c9 --- /dev/null +++ b/core/src/test/java/cloud/stackit/sdk/core/wait/AsyncWaitHandlerTest.java @@ -0,0 +1,127 @@ +package cloud.stackit.sdk.core.wait; + +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import cloud.stackit.sdk.core.exception.ApiException; +import cloud.stackit.sdk.core.wait.AsyncActionHandler.AsyncActionResult; +import java.net.HttpURLConnection; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class AsyncWaitHandlerTest { + + // Helper class for testing + public static class ApiHelper { + + private final String response = "APIResponse"; + + public ApiHelper() {} + + public String callApi() throws ApiException { + return response; + } + } + + @Mock private ApiHelper apiClient; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + // testWaitHandler just calls the ApiHelper function + public static AsyncActionHandler testWaitHandler(ApiHelper apiClient) { + Callable> checkFn = + () -> { + try { + apiClient.callApi(); + return new AsyncActionResult<>(false, null, null); + } catch (Exception e) { + return new AsyncActionResult<>(false, null, e); + } + }; + return new AsyncActionHandler<>(checkFn); + } + + // Non GenericOpenAPIError + @Test + void testNonGenericOpenAPIError() throws Exception { + Exception nonApiException = new ArrayIndexOutOfBoundsException(); + when(apiClient.callApi()).thenThrow(nonApiException); + + AsyncActionHandler handler = testWaitHandler(apiClient); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(40, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows(Exception.class, () -> handler.waitWithContextAsync().get(), ""); + assertTrue(thrown.getMessage().contains(handler.NonGenericAPIErrorMessage)); + } + + // GenericOpenAPIError(ApiException) not in RetryHttpErrorStatusCodes + @Test + void testOpenAPIErrorNotInList() throws Exception { + ApiException apiException = new ApiException(409, ""); + when(apiClient.callApi()).thenThrow(apiException); + + AsyncActionHandler handler = testWaitHandler(apiClient); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(40, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows(Exception.class, () -> handler.waitWithContextAsync().get(), ""); + assertTrue(thrown.getMessage().contains("Timeout occurred")); + } + + // GenericOpenAPIError(ApiException) in RetryHttpErrorStatusCodes -> max retries reached + @Test + void testOpenAPIErrorTimeoutBadGateway() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_BAD_GATEWAY, ""); + when(apiClient.callApi()).thenThrow(apiException); + + AsyncActionHandler handler = testWaitHandler(apiClient); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains(handler.TemporaryErrorMessage)); + } + + // GenericOpenAPIError(ApiException) in RetryHttpErrorStatusCodes -> max retries reached + @Test + void testOpenAPIErrorTimeoutGatewayTimeout() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_GATEWAY_TIMEOUT, ""); + when(apiClient.callApi()).thenThrow(apiException); + + AsyncActionHandler handler = testWaitHandler(apiClient); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains(handler.TemporaryErrorMessage)); + } +} diff --git a/examples/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/examples/ResourcemanagerExample.java b/examples/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/examples/ResourcemanagerExample.java index 34ed2aa..03f9289 100644 --- a/examples/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/examples/ResourcemanagerExample.java +++ b/examples/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/examples/ResourcemanagerExample.java @@ -12,13 +12,13 @@ import cloud.stackit.sdk.resourcemanager.model.PartialUpdateFolderPayload; import cloud.stackit.sdk.resourcemanager.model.PartialUpdateProjectPayload; import cloud.stackit.sdk.resourcemanager.model.Project; -import java.io.IOException; +import cloud.stackit.sdk.resourcemanager.wait.ResourcemanagerWait; import java.util.Arrays; import java.util.Collections; import java.util.UUID; class ResourcemanagerExample { - public static void main(String[] args) throws IOException { + public static void main(String[] args) throws Exception { // Credentials are read from the credentialsFile in `~/.stackit/credentials.json` or the env // STACKIT_SERVICE_ACCOUNT_KEY_PATH / STACKIT_SERVICE_ACCOUNT_KEY ResourceManagerApi resourceManagerApi = new ResourceManagerApi(); @@ -64,9 +64,15 @@ public static void main(String[] args) throws IOException { new CreateFolderPayload() .containerParentId(containerParentId.toString()) .name("java-testing-folder") + .addMembersItem(member) .labels(Collections.singletonMap("foo", "bar"))); System.out.println("Folder: \n" + folder.toString()); + ResourcemanagerWait.createProjectWaitHandler( + resourceManagerApi, project.getContainerId()) + .waitWithContextAsync() + .get(); + /* list folders */ ListFoldersResponse responseListFolders = resourceManagerApi.listFolders( @@ -90,6 +96,11 @@ public static void main(String[] args) throws IOException { project.getContainerId(), new PartialUpdateProjectPayload().containerParentId(folder.getContainerId())); + ResourcemanagerWait.createProjectWaitHandler( + resourceManagerApi, project.getContainerId()) + .waitWithContextAsync() + .get(); + /* get organization details */ OrganizationResponse organizationResponse = resourceManagerApi.getOrganization(organizationIdString); @@ -103,6 +114,11 @@ public static void main(String[] args) throws IOException { /* delete project */ resourceManagerApi.deleteProject(project.getContainerId()); + ResourcemanagerWait.deleteProjectWaitHandler( + resourceManagerApi, project.getContainerId()) + .waitWithContextAsync() + .get(); + /* delete folder */ resourceManagerApi.deleteFolder(folder.getContainerId(), true); } catch (ApiException e) { diff --git a/services/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/wait/ResourcemanagerWait.java b/services/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/wait/ResourcemanagerWait.java new file mode 100644 index 0000000..624bcd1 --- /dev/null +++ b/services/resourcemanager/src/main/java/cloud/stackit/sdk/resourcemanager/wait/ResourcemanagerWait.java @@ -0,0 +1,120 @@ +package cloud.stackit.sdk.resourcemanager.wait; + +import cloud.stackit.sdk.core.exception.ApiException; +import cloud.stackit.sdk.core.oapierror.GenericOpenAPIException; +import cloud.stackit.sdk.core.wait.AsyncActionHandler; +import cloud.stackit.sdk.core.wait.AsyncActionHandler.AsyncActionResult; +import cloud.stackit.sdk.resourcemanager.api.ResourceManagerApi; +import cloud.stackit.sdk.resourcemanager.model.GetProjectResponse; +import cloud.stackit.sdk.resourcemanager.model.LifecycleState; +import java.net.HttpURLConnection; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +public class ResourcemanagerWait { + /** + * createProjectWaitHandler will wait for project creation. Uses the default values for + * sleepBeforeWait (1 min) and timeout (45 min). + * + * @param apiClient + * @param containerId + * @return + */ + public static AsyncActionHandler createProjectWaitHandler( + ResourceManagerApi apiClient, String containerId) { + return createProjectWaitHandler(apiClient, containerId, 1, 45); + } + + /** + * createProjectWaitHandler will wait for project creation + * + * @param apiClient + * @param containerId + * @param sleepBeforeWait in Minutes + * @param timeout in Minutes + * @return + */ + public static AsyncActionHandler createProjectWaitHandler( + ResourceManagerApi apiClient, String containerId, long sleepBeforeWait, long timeout) { + Callable> checkFn = + () -> { + try { + GetProjectResponse p = apiClient.getProject(containerId, false); + if (p.getContainerId().equals(containerId) + && p.getLifecycleState().equals(LifecycleState.ACTIVE)) { + return new AsyncActionResult<>(true, p, null); + } + + if (p.getContainerId().equals(containerId) + && p.getLifecycleState().equals(LifecycleState.CREATING)) { + return new AsyncActionResult<>(false, null, null); + } + return new AsyncActionResult<>( + true, + p, + new Exception( + "Creation failed: received project state '" + + p.getLifecycleState().getValue() + + "'")); + } catch (ApiException e) { + return new AsyncActionResult<>(false, null, e); + } + }; + AsyncActionHandler handler = new AsyncActionHandler<>(checkFn); + handler.setSleepBeforeWait(sleepBeforeWait, TimeUnit.MINUTES); + handler.setTimeout(timeout, TimeUnit.MINUTES); + return handler; + } + + /** + * deleteProjectWaitHandler will wait for project deletion. Uses the deault value for timeout + * (15 min). + * + * @param apiClient + * @param containerId + * @return + */ + public static AsyncActionHandler deleteProjectWaitHandler( + ResourceManagerApi apiClient, String containerId) { + return deleteProjectWaitHandler(apiClient, containerId, 15); + } + + /** + * deleteProjectWaitHandler will wait for project deletion + * + * @param apiClient + * @param containerId + * @param timeout in minutes + * @return + */ + public static AsyncActionHandler deleteProjectWaitHandler( + ResourceManagerApi apiClient, String containerId, long timeout) { + Callable> checkFn = + () -> { + try { + GetProjectResponse p = apiClient.getProject(containerId, false); + + if (p.getContainerId().equals(containerId) + && p.getLifecycleState().equals(LifecycleState.DELETING)) { + return new AsyncActionResult<>(true, null, null); + } + + // The call does throw an exception for HttpURLConnection.HTTP_NOT_FOUND and + // HttpURLConnection.HTTP_FORBIDDEN + return new AsyncActionResult<>(false, null, null); + + } catch (ApiException e) { + GenericOpenAPIException oapiErr = new GenericOpenAPIException(e); + if (oapiErr.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND + || oapiErr.getStatusCode() == HttpURLConnection.HTTP_FORBIDDEN) { + // Resource is gone, so deletion is complete + return new AsyncActionResult<>(true, null, null); + } + return new AsyncActionResult<>(false, null, e); + } + }; + AsyncActionHandler handler = new AsyncActionHandler<>(checkFn); + handler.setTimeout(timeout, TimeUnit.MINUTES); + return handler; + } +} diff --git a/services/resourcemanager/src/test/java/cloud/stackit/sdk/resourcemanager/ResourcemanagerWaitTestmanagerWaitTest.java b/services/resourcemanager/src/test/java/cloud/stackit/sdk/resourcemanager/ResourcemanagerWaitTestmanagerWaitTest.java new file mode 100644 index 0000000..8b5aec3 --- /dev/null +++ b/services/resourcemanager/src/test/java/cloud/stackit/sdk/resourcemanager/ResourcemanagerWaitTestmanagerWaitTest.java @@ -0,0 +1,256 @@ +package cloud.stackit.sdk.resourcemanager; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cloud.stackit.sdk.core.exception.ApiException; +import cloud.stackit.sdk.core.wait.AsyncActionHandler; +import cloud.stackit.sdk.resourcemanager.api.ResourceManagerApi; +import cloud.stackit.sdk.resourcemanager.model.GetProjectResponse; +import cloud.stackit.sdk.resourcemanager.model.LifecycleState; +import cloud.stackit.sdk.resourcemanager.wait.ResourcemanagerWait; +import java.net.HttpURLConnection; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ResourcemanagerWaitTestmanagerWaitTest { + + @Mock private ResourceManagerApi apiClient; + + private final String containerId = "my-test-container"; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testCreateProjectSuccess() throws Exception { + // First call returns "CREATING", second call returns "ACTIVE" + GetProjectResponse creatingResponse = new GetProjectResponse(); + creatingResponse.setContainerId(containerId); + creatingResponse.setLifecycleState(LifecycleState.CREATING); + + GetProjectResponse activeResponse = new GetProjectResponse(); + activeResponse.setContainerId(containerId); + activeResponse.setLifecycleState(LifecycleState.ACTIVE); + + AtomicInteger callCount = new AtomicInteger(0); + when(apiClient.getProject(containerId, false)) + .thenAnswer( + invocation -> { + if (callCount.getAndIncrement() < 1) { + return creatingResponse; + } + return activeResponse; + }); + + AsyncActionHandler handler = + ResourcemanagerWait.createProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(2, TimeUnit.SECONDS); + + GetProjectResponse result = handler.waitWithContextAsync().get(); + + assertNotNull(result); + verify(apiClient, times(2)).getProject(containerId, false); + } + + @Test + void testCreateProjectTimeout() throws Exception { + // Always return "CREATING" to trigger the timeout + GetProjectResponse creatingResponse = new GetProjectResponse(); + creatingResponse.setContainerId(containerId); + creatingResponse.setLifecycleState(LifecycleState.CREATING); + when(apiClient.getProject(containerId, false)).thenReturn(creatingResponse); + + AsyncActionHandler handler = + ResourcemanagerWait.createProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(500, TimeUnit.MILLISECONDS); + + Exception thrown = + assertThrows(Exception.class, () -> handler.waitWithContextAsync().get(), ""); + assertTrue(thrown.getMessage().contains("Timeout occurred")); + } + + // GenericOpenAPIError not in RetryHttpErrorStatusCodes + @Test + void testCreateProjectOpenAPIError() throws Exception { + // Trigger API Exception which is not in RetryHttpErrorStatusCodes + ApiException apiException = new ApiException(409, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.createProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains("Timeout occurred")); + } + + // GenericOpenAPIError in RetryHttpErrorStatusCodes -> max retries reached + @Test + void testOpenAPIErrorTimeoutBadGateway() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_BAD_GATEWAY, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.createProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains(handler.TemporaryErrorMessage)); + } + + // GenericOpenAPIError in RetryHttpErrorStatusCodes -> max retries reached + @Test + void testOpenAPIErrorTimeoutGatewayTimeout() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_GATEWAY_TIMEOUT, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.createProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains(handler.TemporaryErrorMessage)); + } + + @Test + void testDeleteProjectSuccessDeleting() throws Exception { + // First call returns "ACTIVE", second call returns "DELETING" + GetProjectResponse activeResponse = new GetProjectResponse(); + activeResponse.setContainerId(containerId); + activeResponse.setLifecycleState(LifecycleState.ACTIVE); + + GetProjectResponse deletingResponse = new GetProjectResponse(); + deletingResponse.setContainerId(containerId); + deletingResponse.setLifecycleState(LifecycleState.DELETING); + + AtomicInteger callCount = new AtomicInteger(0); + when(apiClient.getProject(containerId, false)) + .thenAnswer( + invocation -> { + if (callCount.getAndIncrement() < 1) { + return activeResponse; + } + return deletingResponse; + }); + + AsyncActionHandler handler = + ResourcemanagerWait.deleteProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(2, TimeUnit.SECONDS); + + handler.waitWithContextAsync().get(); + verify(apiClient, times(2)).getProject(containerId, false); + } + + @Test + void testDeleteProjectSuccessNotFoundExc() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_NOT_FOUND, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.deleteProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(2, TimeUnit.SECONDS); + handler.waitWithContextAsync().get(); + // Only one invocation since the project is gone (HTTP_NOT_FOUND) + verify(apiClient, times(1)).getProject(containerId, false); + } + + @Test + void testDeleteProjectSuccessForbiddenExc() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_FORBIDDEN, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.deleteProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(2, TimeUnit.SECONDS); + handler.waitWithContextAsync().get(); + // Only one invocation since the project is gone (HTTP_FORBIDDEN) + verify(apiClient, times(1)).getProject(containerId, false); + } + + @Test + void testDeleteProjectDifferentErrorCode() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_ENTITY_TOO_LARGE, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.deleteProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains("Timeout occurred")); + } + + @Test + void testOpenAPIErrorGatewayTimeout() throws Exception { + // Trigger API Exception + ApiException apiException = new ApiException(HttpURLConnection.HTTP_GATEWAY_TIMEOUT, ""); + when(apiClient.getProject(containerId, false)).thenThrow(apiException); + + AsyncActionHandler handler = + ResourcemanagerWait.deleteProjectWaitHandler(apiClient, containerId); + handler.setSleepBeforeWait(0, TimeUnit.SECONDS); + handler.setThrottle(10, TimeUnit.MILLISECONDS); + handler.setTimeout(100, TimeUnit.MILLISECONDS); + handler.setTempErrRetryLimit(2); + + Exception thrown = + assertThrows( + Exception.class, + () -> handler.waitWithContextAsync().get(), + apiException.getMessage()); + assertTrue(thrown.getMessage().contains(handler.TemporaryErrorMessage)); + } +}