diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2Metadata.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2Metadata.java index 1d89b27c6bb1..cd54ba1c75f4 100644 --- a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2Metadata.java +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2Metadata.java @@ -18,7 +18,6 @@ import java.net.URI; import java.time.Duration; import software.amazon.awssdk.annotations.SdkPublicApi; -import software.amazon.awssdk.core.retry.RetryPolicy; import software.amazon.awssdk.http.SdkHttpClient; import software.amazon.awssdk.imds.internal.DefaultEc2Metadata; import software.amazon.awssdk.imds.internal.EndpointMode; @@ -67,7 +66,7 @@ interface Builder { * @param retryPolicy The retry policy which includes the number of retry attempts for any failed request. * @return Returns a reference to this builder */ - Builder retryPolicy(RetryPolicy retryPolicy); + Builder retryPolicy(Ec2MetadataRetryPolicy retryPolicy); /** * Define the endpoint of IMDS. diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java new file mode 100644 index 000000000000..0b7638e34e4b --- /dev/null +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/Ec2MetadataRetryPolicy.java @@ -0,0 +1,155 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.imds; + +import java.util.Objects; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.utils.builder.CopyableBuilder; +import software.amazon.awssdk.utils.builder.ToCopyableBuilder; + +/** + * Interface for specifying a retry policy to use when evaluating whether or not a request should be retried , and the gap + * between each retry. The {@link #builder()} can be used to construct a retry policy with numRetries and backoffStrategy. + *

+ * When using the {@link #builder()} the SDK will use default values for fields that are not provided.A custom BackoffStrategy + * can be used to construct a policy or a default {@link BackoffStrategy} is used. + * + * @see BackoffStrategy for a list of SDK provided backoff strategies + */ +@SdkPublicApi +public class Ec2MetadataRetryPolicy implements ToCopyableBuilder { + + private final BackoffStrategy backoffStrategy; + private final int numRetries; + + private Ec2MetadataRetryPolicy(BuilderImpl builder) { + + this.numRetries = builder.numRetries != null ? builder.numRetries : 3; + + this.backoffStrategy = builder.backoffStrategy != null ? builder.backoffStrategy : + BackoffStrategy.defaultStrategy(RetryMode.STANDARD); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Ec2MetadataRetryPolicy ec2MetadataRetryPolicy = (Ec2MetadataRetryPolicy) o; + + if (!Objects.equals(numRetries, ec2MetadataRetryPolicy.numRetries)) { + return false; + } + return Objects.equals(backoffStrategy, ec2MetadataRetryPolicy.backoffStrategy); + } + + @Override + public int hashCode() { + + int result = numRetries >= 0 ? numRetries : 0; + result = 31 * result + (backoffStrategy != null ? backoffStrategy.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "Ec2MetadataRetryPolicy{" + + "backoffStrategy=" + backoffStrategy.toString() + + ", numRetries=" + numRetries + + '}'; + } + + /** + * Method to return the number of retries allowed. + * @return The number of retries allowed. + */ + public int numRetries() { + return numRetries; + } + + /** + * Method to return the BackoffStrategy used. + * @return The backoff Strategy used. + */ + public BackoffStrategy backoffStrategy() { + return backoffStrategy; + } + + public static Builder builder() { + return new BuilderImpl(); + } + + @Override + public Builder toBuilder() { + return builder().numRetries(numRetries) + .backoffStrategy(backoffStrategy); + } + + public interface Builder extends CopyableBuilder { + + /** + * Configure the backoff strategy that should be used for waiting in between retry attempts. + */ + Builder backoffStrategy(BackoffStrategy backoffStrategy); + + /** + * Configure the maximum number of times that a single request should be retried, assuming it fails for a retryable error. + */ + Builder numRetries(Integer numRetries); + + @Override + Ec2MetadataRetryPolicy build(); + } + + private static final class BuilderImpl implements Builder { + + private Integer numRetries; + private BackoffStrategy backoffStrategy; + + private BuilderImpl() { + } + + @Override + public Builder numRetries(Integer numRetries) { + this.numRetries = numRetries; + return this; + } + + public void setNumRetries(Integer numRetries) { + numRetries(numRetries); + } + + @Override + public Builder backoffStrategy(BackoffStrategy backoffStrategy) { + this.backoffStrategy = backoffStrategy; + return this; + } + + public void setBackoffStrategy(BackoffStrategy backoffStrategy) { + backoffStrategy(backoffStrategy); + } + + @Override + public Ec2MetadataRetryPolicy build() { + return new Ec2MetadataRetryPolicy(this); + } + } +} \ No newline at end of file diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2Metadata.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2Metadata.java index 5d1d415d9b2e..e8bcdb55cf98 100644 --- a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2Metadata.java +++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2Metadata.java @@ -16,19 +16,15 @@ package software.amazon.awssdk.imds.internal; import java.io.IOException; -import java.net.HttpURLConnection; import java.net.URI; import java.time.Duration; import java.util.Objects; import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import software.amazon.awssdk.annotations.Immutable; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.ThreadSafe; import software.amazon.awssdk.core.exception.SdkClientException; -import software.amazon.awssdk.core.exception.SdkServiceException; -import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.RetryPolicyContext; import software.amazon.awssdk.http.AbortableInputStream; import software.amazon.awssdk.http.HttpExecuteRequest; import software.amazon.awssdk.http.HttpExecuteResponse; @@ -36,8 +32,10 @@ import software.amazon.awssdk.http.SdkHttpMethod; import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; import software.amazon.awssdk.imds.Ec2Metadata; +import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy; import software.amazon.awssdk.imds.MetadataResponse; import software.amazon.awssdk.utils.IoUtils; +import software.amazon.awssdk.utils.Logger; /** * An Implementation of the Ec2Metadata Interface. @@ -49,12 +47,13 @@ public final class DefaultEc2Metadata implements Ec2Metadata { private static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; - private static final Logger log = LoggerFactory.getLogger(DefaultEc2Metadata.class); + private static final Logger log = Logger.loggerFor(DefaultEc2Metadata.class); private static final RequestMarshaller REQUEST_MARSHALLER = new RequestMarshaller(); private static final EndpointProvider ENDPOINT_PROVIDER = EndpointProvider.builder().build(); - private final RetryPolicy retryPolicy; + + private final Ec2MetadataRetryPolicy retryPolicy; private final URI endpoint; @@ -68,7 +67,7 @@ public final class DefaultEc2Metadata implements Ec2Metadata { private DefaultEc2Metadata(DefaultEc2Metadata.Ec2MetadataBuilder builder) { - this.retryPolicy = builder.retryPolicy != null ? builder.retryPolicy : RetryPolicy.builder().build(); + this.retryPolicy = builder.retryPolicy != null ? builder.retryPolicy : Ec2MetadataRetryPolicy.builder().build(); this.endpoint = URI.create(ENDPOINT_PROVIDER.resolveEndpoint(builder.endpoint, builder.endpointMode)); this.tokenTtl = builder.tokenTtl != null ? builder.tokenTtl : Duration.ofSeconds(21600); this.endpointMode = ENDPOINT_PROVIDER.resolveEndpointMode(builder.endpointMode); @@ -152,78 +151,130 @@ public String toString() { public MetadataResponse get(String path) { MetadataResponse metadataResponse = null; - String data = null; AbortableInputStream abortableInputStream = null; - try { - String token = getToken(); - URI uri = URI.create(endpoint + path); - HttpExecuteRequest httpExecuteRequest = REQUEST_MARSHALLER.createDataRequest(uri, SdkHttpMethod.GET, token, - tokenTtl); - HttpExecuteResponse response = httpClient.prepareRequest(httpExecuteRequest).call(); - int statusCode = response.httpResponse().statusCode(); - Optional responseBody = response.responseBody(); - if (statusCode == HttpURLConnection.HTTP_OK && responseBody.isPresent()) { - abortableInputStream = responseBody.get(); - data = IoUtils.toUtf8String(abortableInputStream); - metadataResponse = new MetadataResponse(data); - } else if (statusCode == HttpURLConnection.HTTP_NOT_FOUND) { - throw SdkServiceException.builder() - .message("The requested metadata at path ( " + path + " ) is not found ").build(); - } else if (statusCode == HttpURLConnection.HTTP_OK) { - throw SdkClientException.builder() - .message("Response body empty with Status Code " + statusCode).build(); - } else { - throw SdkClientException.builder() - .message("Instance metadata service returned unexpected status code " + statusCode) - .build(); + for (int tries = 1 ; tries <= retryPolicy.numRetries() + 1; tries ++) { + + try { + Optional token = getToken(); + if (token.isPresent()) { + HttpExecuteResponse response = getDataHttpResponse(path, token.get()); + + int statusCode = response.httpResponse().statusCode(); + Optional responseBody = response.responseBody(); + + if (statusCode == 200) { + if (!responseBody.isPresent()) { + throw SdkClientException.builder() + .message("Response body empty with Status Code " + statusCode).build(); + } + abortableInputStream = responseBody.get(); + String data = IoUtils.toUtf8String(abortableInputStream); + metadataResponse = new MetadataResponse(data); + return metadataResponse; + } + handleException(statusCode, path); + } + //TODO Create IMDS Custom Exception + } catch (IOException io) { + log.warn(() -> "Received an IOException ", io); + } finally { + IoUtils.closeQuietly(abortableInputStream, log.logger()); } - } catch (SdkServiceException sd) { - throw SdkServiceException.builder().message(sd.getMessage()).cause(sd).build(); - } catch (IOException | SdkClientException io) { - // TODO Retry Logic will be added - log.warn("Received an IOException {0} " , io); - } finally { - IoUtils.closeQuietly(abortableInputStream, log); + pauseBeforeRetryIfNeeded(tries); } - return metadataResponse; } - private String getToken() throws IOException { + private void handleException(int statusCode, String path) { + if (statusCode == 404) { + throw SdkClientException.builder() + .message("The requested metadata at path ( " + path + " ) is not found ").build(); + } + } + + private void pauseBeforeRetryIfNeeded(int tries) { + + if (tries == retryPolicy.numRetries() + 1) { + throw SdkClientException.builder().message("Exceeded maximum number of retries.").build(); + } + + try { + long backoffTimeMillis = getBackoffDuration(tries); + Thread.sleep(backoffTimeMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw SdkClientException.builder().message("Thread interrupted while trying to sleep").cause(e).build(); + } + } + + private HttpExecuteResponse getDataHttpResponse(String path, String token) throws IOException { + + URI uri = URI.create(endpoint + path); + HttpExecuteRequest httpExecuteRequest = REQUEST_MARSHALLER.createDataRequest(uri, SdkHttpMethod.GET, token, + tokenTtl); + return httpClient.prepareRequest(httpExecuteRequest).call(); + } + + private long getBackoffDuration(int tries) { + + long backoffTime = 0L; + if (tries >= 1) { + backoffTime = retryPolicy.backoffStrategy() + .computeDelayBeforeNextRetry(RetryPolicyContext.builder() + .retriesAttempted(tries - 1) + .build()).toMillis(); + } + + return backoffTime; + } + + private Optional getToken() throws IOException { AbortableInputStream abortableInputStream = null; try { - URI uri = URI.create(endpoint + TOKEN_RESOURCE_PATH); - HttpExecuteRequest httpExecuteRequest = REQUEST_MARSHALLER.createTokenRequest(uri, SdkHttpMethod.PUT, tokenTtl); - HttpExecuteResponse response = httpClient.prepareRequest(httpExecuteRequest).call(); + HttpExecuteResponse response = getTokenHttpResponse(); int statusCode = response.httpResponse().statusCode(); Optional responseBody = response.responseBody(); - if (statusCode == HttpURLConnection.HTTP_OK && responseBody.isPresent()) { + if (statusCode == 200) { + if (!responseBody.isPresent()) { + throw SdkClientException.builder() + .message("Response body empty with Status Code " + statusCode).build(); + } abortableInputStream = responseBody.get(); - return IoUtils.toUtf8String(abortableInputStream); - } else if (statusCode == HttpURLConnection.HTTP_FORBIDDEN || statusCode == HttpURLConnection.HTTP_BAD_REQUEST) { - throw SdkServiceException.builder() - .message("Could not retrieve token as " + statusCode + " error occurred.").build(); - } else if (statusCode == HttpURLConnection.HTTP_OK) { - throw SdkClientException.builder() - .message("Response body empty with Status Code " + statusCode).build(); - } else { - throw SdkClientException.builder() - .message("Instance metadata service returned unexpected status code " + statusCode) - .build(); + return Optional.of(IoUtils.toUtf8String(abortableInputStream)); } + handleErrorResponse(statusCode); } catch (IOException e) { + log.warn(() -> "Received an IOException ", e); throw e; } finally { - IoUtils.closeQuietly(abortableInputStream, log); + IoUtils.closeQuietly(abortableInputStream, log.logger()); } + return Optional.empty(); + } + + private void handleErrorResponse(int statusCode) { + + if (statusCode == 403 || statusCode == 400) { + throw SdkClientException.builder() + .message("Could not retrieve token as " + statusCode + " error occurred.").build(); + } + } + + + private HttpExecuteResponse getTokenHttpResponse() throws IOException { + + URI uri = URI.create(endpoint + TOKEN_RESOURCE_PATH); + HttpExecuteRequest httpExecuteRequest = REQUEST_MARSHALLER.createTokenRequest(uri, SdkHttpMethod.PUT, tokenTtl); + return httpClient.prepareRequest(httpExecuteRequest).call(); + } private static final class Ec2MetadataBuilder implements Ec2Metadata.Builder { - private RetryPolicy retryPolicy; + private Ec2MetadataRetryPolicy retryPolicy; private URI endpoint; @@ -238,7 +289,7 @@ private static final class Ec2MetadataBuilder implements Ec2Metadata.Builder { private Ec2MetadataBuilder() { } - public void setRetryPolicy(RetryPolicy retryPolicy) { + public void setRetryPolicy(Ec2MetadataRetryPolicy retryPolicy) { this.retryPolicy = retryPolicy; } @@ -263,7 +314,7 @@ public void setHttpClient(SdkHttpClient httpClient) { } @Override - public Builder retryPolicy(RetryPolicy retryPolicy) { + public Builder retryPolicy(Ec2MetadataRetryPolicy retryPolicy) { this.retryPolicy = retryPolicy; return this; } diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataTest.java index 70bf073ed144..61f6a03264a6 100644 --- a/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataTest.java +++ b/core/imds/src/test/java/software/amazon/awssdk/imds/Ec2MetadataTest.java @@ -23,10 +23,13 @@ import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.junit.WireMockRule; import java.io.IOException; import java.net.URI; @@ -34,14 +37,11 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import software.amazon.awssdk.core.SdkSystemSetting; -import software.amazon.awssdk.core.exception.SdkServiceException; -import software.amazon.awssdk.protocols.jsoncore.JsonNode; -import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; +import software.amazon.awssdk.core.exception.SdkClientException; /** * Unit Tests to test the Ec2Metadata Client functionality @@ -50,7 +50,9 @@ public class Ec2MetadataTest { private static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; + private static final String TOKEN_HEADER = "x-aws-ec2-metadata-token"; + private static final String EC2_METADATA_TOKEN_TTL_HEADER = "x-aws-ec2-metadata-token-ttl-seconds"; private static final String EC2_METADATA_ROOT = "/latest/meta-data"; @@ -60,14 +62,9 @@ public class Ec2MetadataTest { @Mock private Ec2Metadata ec2Metadata; - @Rule - public ExpectedException thrown = ExpectedException.none(); - @Rule public WireMockRule mockMetadataEndpoint = new WireMockRule(); - private static final JsonNodeParser jsonParser = JsonNode.parser(); - @Before public void methodSetup() { System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(), "http://localhost:" + mockMetadataEndpoint.port()); @@ -75,7 +72,7 @@ public void methodSetup() { } @Test - public void when_dummy_string_is_returned(){ + public void get_whenDummyResponseIsReturned(){ MetadataResponse metadataResponse = new MetadataResponse("IMDS"); when(ec2Metadata.get("/ami-id")).thenReturn(metadataResponse); @@ -84,7 +81,7 @@ public void when_dummy_string_is_returned(){ } @Test - public void verify_equals_hashcode(){ + public void verifyEc2Metadata_equalsAndHashcode(){ EqualsVerifier.forClass(Ec2Metadata.class) .usingGetClass() @@ -92,7 +89,7 @@ public void verify_equals_hashcode(){ } @Test - public void get_AmiId_onMetadataResource_200_Success() throws IOException { + public void get_shouldSucceedOnFirstAttempt() throws IOException { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); @@ -106,94 +103,282 @@ public void get_AmiId_onMetadataResource_200_Success() throws IOException { } @Test - public void get_AmiId_onMetadataResource_404Error_throws() throws IOException { - - thrown.expect(SdkServiceException.class); - thrown.expectMessage("metadata"); + public void get_failedThriceWith404() throws IOException { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}").withStatus(404))); - Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); - MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("metadata") + .isInstanceOf(SdkClientException.class); } @Test - public void get_AmiId_onMetadataResource_401Error_throws() throws IOException { + public void get_failedThriceWith401() throws IOException { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}").withStatus(401))); - Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); - MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); - assertThat(metadataResponse).isNull(); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Exceeded maximum number of retries.") + .isInstanceOf(SdkClientException.class); } @Test - public void get_AmiId_onMetadataResource_IOException_throws() { + public void get_failedThriceWithFixedDelay() { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withFixedDelay(Integer.MAX_VALUE))); - Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); - - MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); - assertThat(metadataResponse).isNull(); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Exceeded maximum number of retries.") + .isInstanceOf(SdkClientException.class); WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); WireMock.verify(getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)).withHeader(TOKEN_HEADER, equalTo("some-token"))); } @Test - public void get_AmiId_onTokenResource_403Error_throws() throws IOException { - - thrown.expect(SdkServiceException.class); - thrown.expectMessage("token"); + public void getToken_failedThriceWith403() throws IOException { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withStatus(403))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); - Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); - MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("token") + .isInstanceOf(SdkClientException.class); } @Test - public void get_AmiId_onTokenResource_401Error_throws() throws IOException { + public void getToken_failedThriceWith401() throws IOException { stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withStatus(401))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); - Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); - - MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); - assertThat(metadataResponse).isNull(); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Exceeded maximum number of retries.") + .isInstanceOf(SdkClientException.class); WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); } @Test - public void getAmiId_onTokenResource_IOError_throws() throws IOException { + public void getToken_failedThriceWithFixedDelay() throws IOException { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withFixedDelay(Integer.MAX_VALUE))); stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Exceeded maximum number of retries.") + .isInstanceOf(SdkClientException.class); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + } + + @Test + public void getToken_failedOnceWith401_shouldSucceedOnSecondAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Cause Success")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); - assertThat(metadataResponse).isNull(); + assertThat(metadataResponse.asString()).isEqualTo("{}"); WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + } + @Test - public void getAmiId_onTokenResource_200() throws IOException { + public void get_failedOnceWith401_shouldSucceedOnSecondAttempt() throws IOException { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Cause Success")); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("{}"))); + + + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + assertThat(metadataResponse.asString()).isEqualTo("{}"); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + } + + @Test + public void getAndGetToken_failedOnceWith401_shouldSucceedOnSecondAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Cause Success")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Cause Success")); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("{}"))); + + + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + assertThat(metadataResponse.asString()).isEqualTo("{}"); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + } + + @Test + public void getAndGetToken_failedOnceWith403_shouldSucceedOnSecondAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(403)) + .willSetStateTo("Cause Success")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(403)) + .willSetStateTo("Cause Success")); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Cause Success") + .willReturn(aResponse().withBody("{}"))); + + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Could not retrieve token "); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + } + + @Test + public void getToken_failedTwiceWith200_shouldSucceedOnThirdAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(200)) + .willSetStateTo("Try-2")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-2") + .willReturn(aResponse().withStatus(200)) + .willSetStateTo("Try-3")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-3") + .willReturn(aResponse().withBody("some-token"))); + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + assertThat(metadataResponse.asString()).isEqualTo("{}"); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + } + + @Test + public void getToken_failedTwiceWithIOExceptionAnd200_shouldSucceedOnThirdAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK)) + .willSetStateTo("Try-2")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-2") + .willReturn(aResponse().withStatus(200)) + .willSetStateTo("Try-3")); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-3") + .willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).willReturn(aResponse().withBody("{}"))); + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); assertThat(metadataResponse.asString()).isEqualTo("{}"); WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); - WireMock.verify(getRequestedFor(urlPathEqualTo(AMI_ID_RESOURCE)).withHeader(TOKEN_HEADER, equalTo("some-token"))); + + } + @Test + public void get_failedTwiceWith401_shouldFailOnThirdAttempt() throws IOException { + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody("some-token"))); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Try-2")); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-2") + .willReturn(aResponse().withStatus(401)) + .willSetStateTo("Try-3")); + + stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE)).inScenario("Retry Scenario") + .whenScenarioStateIs("Try-3") + .willReturn(aResponse().withStatus(401))); + + + assertThatThrownBy(() -> { + Ec2Metadata ec2Metadata = Ec2Metadata.builder().endpoint(URI.create("http://localhost:8080")).build(); + MetadataResponse metadataResponse = ec2Metadata.get("/latest/meta-data/ami-id"); + }).hasMessageContaining("Exceeded maximum number of retries.") + .isInstanceOf(SdkClientException.class); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)).withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + } + + @Test + public void verifyEc2MetadataRetryPolicy_equalsAndHashcode(){ + + EqualsVerifier.forClass(Ec2MetadataRetryPolicy.class).usingGetClass().verify(); } }