diff --git a/.changes/next-release/bugfix-ddfb0a9a-529f-4597-8b7a-519d4e838fa4.json b/.changes/next-release/bugfix-ddfb0a9a-529f-4597-8b7a-519d4e838fa4.json new file mode 100644 index 00000000000..f1a3fe8dc39 --- /dev/null +++ b/.changes/next-release/bugfix-ddfb0a9a-529f-4597-8b7a-519d4e838fa4.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "/transform: handle InvalidGrantException properly when polling job status" +} \ No newline at end of file diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt index 3727a46aa7f..20e21f46ba5 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt @@ -127,7 +127,7 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { amazonQStreamingClient.exportResultArchive("test-id", ExportIntent.TRANSFORMATION, null, {}, {}) } - assertThat(attemptCount).isEqualTo(3) + assertThat(attemptCount).isEqualTo(4) assertThat(thrown) .isInstanceOf(ValidationException::class.java) .hasMessage("Resource validation failed") diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt index 88b055a41b8..d15047c4fe8 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt @@ -547,7 +547,7 @@ class CodeModernizerSession( } catch (e: Exception) { return when (e) { is AlreadyDisposedException, is CancellationException -> { - LOG.warn { "The session was disposed while polling for job details." } + LOG.error(e) { "The session was disposed while polling for job details." } CodeModernizerJobCompletedResult.ManagerDisposed } diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt index f94e25d166f..37b6c3b2069 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.util.io.HttpRequests +import software.amazon.awssdk.core.exception.SdkException import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeResponse import software.amazon.awssdk.services.codewhispererruntime.model.ContentChecksumType @@ -32,8 +33,10 @@ import software.amazon.awssdk.services.codewhispererruntime.model.UploadContext import software.amazon.awssdk.services.codewhispererruntime.model.UploadIntent import software.amazon.awssdk.services.codewhispererstreaming.model.ExportContext import software.amazon.awssdk.services.codewhispererstreaming.model.ExportIntent +import software.amazon.awssdk.services.codewhispererstreaming.model.ThrottlingException import software.amazon.awssdk.services.codewhispererstreaming.model.TransformationDownloadArtifactType import software.amazon.awssdk.services.codewhispererstreaming.model.TransformationExportContext +import software.amazon.awssdk.services.codewhispererstreaming.model.ValidationException import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info @@ -41,6 +44,7 @@ import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.services.amazonq.APPLICATION_ZIP import software.aws.toolkits.jetbrains.services.amazonq.AWS_KMS import software.aws.toolkits.jetbrains.services.amazonq.CONTENT_SHA256 +import software.aws.toolkits.jetbrains.services.amazonq.RetryableOperation import software.aws.toolkits.jetbrains.services.amazonq.SERVER_SIDE_ENCRYPTION import software.aws.toolkits.jetbrains.services.amazonq.SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID import software.aws.toolkits.jetbrains.services.amazonq.clients.AmazonQStreamingClient @@ -52,7 +56,9 @@ import software.aws.toolkits.jetbrains.services.codemodernizer.utils.calculateTo import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getTelemetryOptOutPreference import java.io.File import java.net.HttpURLConnection +import java.net.SocketTimeoutException import java.time.Instant +import java.util.concurrent.TimeoutException @Service(Service.Level.PROJECT) class GumbyClient(private val project: Project) { @@ -152,15 +158,34 @@ class GumbyClient(private val project: Project) { apiCall: () -> T, apiName: String, ): T { - var result: CodeWhispererRuntimeResponse? = null + var result: T? = null try { - result = apiCall() - LOG.info { "$apiName request ID: ${result.responseMetadata()?.requestId()}" } - return result + RetryableOperation().execute( + operation = { + result = apiCall() + }, + isRetryable = { e -> + when (e) { + is ValidationException, + is ThrottlingException, + is SdkException, + is TimeoutException, + is SocketTimeoutException, + -> true + else -> false + } + }, + errorHandler = { e, attempts -> + LOG.error(e) { "After $attempts attempts, $apiName failed: ${e.message}" } + throw e + } + ) } catch (e: Exception) { - LOG.error(e) { "$apiName failed: ${e.message}" } - throw e // pass along error to callee + LOG.error(e) { "$apiName failed: ${e.message}; may have been retried up to 3 times" } + throw e } + LOG.info { "$apiName request ID: ${result?.responseMetadata()?.requestId()}" } + return result ?: error("$apiName failed") } suspend fun downloadExportResultArchive( diff --git a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/utils/CodeTransformApiUtils.kt b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/utils/CodeTransformApiUtils.kt index 730ab8ea267..f3b424d8e3c 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/utils/CodeTransformApiUtils.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codemodernizer/utils/CodeTransformApiUtils.kt @@ -5,7 +5,6 @@ package software.aws.toolkits.jetbrains.services.codemodernizer.utils import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.grazie.utils.orFalse -import com.intellij.notification.NotificationAction import com.intellij.openapi.application.runInEdt import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.diff.impl.patch.PatchReader @@ -144,16 +143,10 @@ suspend fun JobId.pollTransformationStatusAndPlan( refreshToken(project) return@waitUntil state } catch (e: InvalidGrantException) { - CodeTransformMessageListener.instance.onCheckAuth() + CodeTransformMessageListener.instance.onReauthStarted() notifyStickyWarn( message("codemodernizer.notification.warn.expired_credentials.title"), message("codemodernizer.notification.warn.expired_credentials.content"), - project, - listOf( - NotificationAction.createSimpleExpiring(message("codemodernizer.notification.warn.action.reauthenticate")) { - CodeTransformMessageListener.instance.onReauthStarted() - } - ) ) return@waitUntil state } finally { @@ -161,6 +154,7 @@ suspend fun JobId.pollTransformationStatusAndPlan( } } } catch (e: Exception) { + getLogger().error(e) { "Error when polling for job status & plan" } // Still call onStateChange to update the UI onStateChange(state, TransformationStatus.FAILED, transformationPlan) when (e) { diff --git a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerGumbyClientTest.kt b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerGumbyClientTest.kt index ab3a4985b34..6e8da48cce8 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerGumbyClientTest.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerGumbyClientTest.kt @@ -13,10 +13,12 @@ import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.stub +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions @@ -32,6 +34,7 @@ import software.amazon.awssdk.services.codewhispererruntime.model.StartTransform import software.amazon.awssdk.services.codewhispererruntime.model.StartTransformationResponse import software.amazon.awssdk.services.codewhispererruntime.model.StopTransformationRequest import software.amazon.awssdk.services.codewhispererruntime.model.StopTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException import software.amazon.awssdk.services.codewhispererruntime.model.TransformationLanguage import software.amazon.awssdk.services.codewhispererstreaming.CodeWhispererStreamingAsyncClient import software.amazon.awssdk.services.codewhispererstreaming.model.ExportIntent @@ -135,6 +138,80 @@ class CodeWhispererCodeModernizerGumbyClientTest : CodeWhispererCodeModernizerTe } } + @Test + fun `getCodeModernizationJob should retry on retryable exceptions`() { + var callCount = 0 + bearerClient = mockClientManagerRule.create().stub { + on { getTransformation(any()) } doAnswer { + callCount++ + when (callCount) { + 1 -> throw ThrottlingException.builder().message("Throttled 1").build() + 2 -> throw ThrottlingException.builder().message("Throttled 2").build() + else -> exampleGetCodeMigrationResponse + } + } + } + val actual = gumbyClient.getCodeModernizationJob("jobId") + argumentCaptor().apply { + // succeeds on 3rd attempt + verify(bearerClient, times(3)).getTransformation(capture()) + verifyNoMoreInteractions(bearerClient) + verifyNoInteractions(streamingBearerClient) + assertThat(allValues).hasSize(3) + allValues.forEach { request -> + assertThat(request.transformationJobId()).isEqualTo("jobId") + } + assertThat(actual).isInstanceOf(GetTransformationResponse::class.java) + assertThat(actual).usingRecursiveComparison() + .comparingOnlyFields("jobId", "status", "transformationType", "source") + .isEqualTo(exampleGetCodeMigrationResponse) + } + } + + @Test + fun `getCodeModernizationJob should fail immediately on non-retryable exception`() { + val exception = IllegalArgumentException("Non-retryable error") + bearerClient = mockClientManagerRule.create().stub { + on { getTransformation(any()) } doAnswer { + throw exception + } + } + val thrown = runCatching { + gumbyClient.getCodeModernizationJob("jobId") + }.exceptionOrNull() + assertThat(thrown) + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("Non-retryable error") + // called just once since it fails immediately + verify(bearerClient, times(1)).getTransformation(any()) + verifyNoMoreInteractions(bearerClient) + verifyNoInteractions(streamingBearerClient) + } + + @Test + fun `getCodeModernizationJob should fail after max attempts`() { + bearerClient = mockClientManagerRule.create().stub { + on { getTransformation(any()) } doAnswer { + throw ThrottlingException.builder().message("Always throttled").build() + } + } + val thrown = runCatching { + gumbyClient.getCodeModernizationJob("jobId") + }.exceptionOrNull() + assertThat(thrown) + .isInstanceOf(ThrottlingException::class.java) + .hasMessage("Always throttled") + argumentCaptor().apply { + // called 4 times since it always fails + verify(bearerClient, times(4)).getTransformation(capture()) + allValues.forEach { request -> + assertThat(request.transformationJobId()).isEqualTo("jobId") + } + } + verifyNoMoreInteractions(bearerClient) + verifyNoInteractions(streamingBearerClient) + } + @Test fun `check startCodeModernization on JAVA_17 target`() { val actual = gumbyClient.startCodeModernization("jobId", TransformationLanguage.JAVA_8, TransformationLanguage.JAVA_17) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/RetryableOperation.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/RetryableOperation.kt index 049ca957d08..36da6aac003 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/RetryableOperation.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/RetryableOperation.kt @@ -30,12 +30,12 @@ class RetryableOperation { isRetryable: (Exception) -> Boolean = { it is RetryableException }, errorHandler: (suspend (Exception, Int) -> Nothing), ): T { - while (attempts < MAX_RETRY_ATTEMPTS) { + while (attempts < MAX_ATTEMPTS) { try { return operation() } catch (e: Exception) { attempts++ - if (attempts >= MAX_RETRY_ATTEMPTS || !isRetryable(e)) { + if (attempts >= MAX_ATTEMPTS || !isRetryable(e)) { errorHandler.invoke(e, attempts) } delay(getJitteredDelay()) @@ -48,6 +48,6 @@ class RetryableOperation { companion object { private const val INITIAL_DELAY = 100L private const val MAX_BACKOFF = 10000L - private const val MAX_RETRY_ATTEMPTS = 3 + private const val MAX_ATTEMPTS = 4 } } diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index 0aaf82bbd84..9b189e2b222 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -626,7 +626,7 @@ codemodernizer.chat.form.user_selection.item.choose_sql_metadata_file=Okay, I ca codemodernizer.chat.form.user_selection.item.choose_target_version=Choose the target code version codemodernizer.chat.form.user_selection.title=Q - Code transformation codemodernizer.chat.message.absolute_path_detected=I detected {0} potential absolute file path(s) in your {1} file: **{2}**. Absolute file paths might cause issues when I build your code. Any errors will show up in the build log. -codemodernizer.chat.message.auth_prompt=Follow instructions to re-authenticate ... +codemodernizer.chat.message.auth_prompt=Follow instructions to re-authenticate. If you experience difficulty, sign out of the Amazon Q plugin and sign in again. codemodernizer.chat.message.button.cancel=Cancel codemodernizer.chat.message.button.confirm=Confirm codemodernizer.chat.message.button.hil_cancel=Cancel @@ -780,7 +780,7 @@ codemodernizer.notification.warn.download_failed_invalid_artifact=Amazon Q was u codemodernizer.notification.warn.download_failed_other.content=Amazon Q ran into an issue while trying to download your {0}. Please try again. {1} codemodernizer.notification.warn.download_failed_ssl.content=Please make sure all your certificates for your proxy client have been set up correctly for your IDE. codemodernizer.notification.warn.download_failed_wildcard.content=Check your IDE proxy settings and remove any wildcard (*) references, and then try viewing the diff again. -codemodernizer.notification.warn.expired_credentials.content=Unable to check transformation status as your credentials expired. Try signing out of Amazon Q and signing back in again if 'Reauthenticate' below does not work. +codemodernizer.notification.warn.expired_credentials.content=Unable to check transformation status as your credentials expired. Try signing out of the Amazon Q plugin and signing in again. codemodernizer.notification.warn.expired_credentials.title=Your connection to Q has expired codemodernizer.notification.warn.invalid_project.description.reason.missing_content_roots=None of your open modules are supported for code transformation with Amazon Q. Amazon Q can upgrade Java 8, Java 11, Java 17, and Java 21 projects built on Maven, with content roots configured. codemodernizer.notification.warn.invalid_project.description.reason.not_logged_in=Amazon Q cannot start the transformation as you are not logged in with Identity Center or Builder ID. Also ensure that you are not using IntelliJ version 232.8660.185 and that you are not developing on a remote host (uncommon).