diff --git a/.changes/next-release/feature-AWSSDKforJavav2-8c69562.json b/.changes/next-release/feature-AWSSDKforJavav2-8c69562.json
new file mode 100644
index 000000000000..dc9a27333838
--- /dev/null
+++ b/.changes/next-release/feature-AWSSDKforJavav2-8c69562.json
@@ -0,0 +1,6 @@
+{
+ "type": "feature",
+ "category": "AWS SDK for Java v2",
+ "contributor": "",
+ "description": "Updated AutoDefaultsModeDiscovery from using EC2MetadataUtils to Ec2MetadataClient"
+}
diff --git a/core/aws-core/pom.xml b/core/aws-core/pom.xml
index 72f3c2004bbd..2b46f89ef8ac 100644
--- a/core/aws-core/pom.xml
+++ b/core/aws-core/pom.xml
@@ -206,6 +206,12 @@
rxjava
test
+
+ software.amazon.awssdk
+ imds
+ ${awsjavasdk.version}
+ compile
+
diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscovery.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscovery.java
index 5f4193147857..e79a1dff0a95 100644
--- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscovery.java
+++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscovery.java
@@ -19,8 +19,11 @@
import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.imds.Ec2MetadataClient;
+import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy;
+import software.amazon.awssdk.imds.internal.Ec2MetadataSharedClient;
import software.amazon.awssdk.regions.Region;
-import software.amazon.awssdk.regions.internal.util.EC2MetadataUtils;
import software.amazon.awssdk.utils.JavaSystemSetting;
import software.amazon.awssdk.utils.OptionalUtils;
import software.amazon.awssdk.utils.SystemSetting;
@@ -81,12 +84,25 @@ private static DefaultsMode compareRegion(String region, Region clientRegion) {
}
private static Optional queryImdsV2() {
+
+ if (SdkSystemSetting.AWS_EC2_METADATA_DISABLED.getBooleanValueOrThrow()) {
+ return Optional.empty();
+ }
+
+ Ec2MetadataClient client = null;
try {
- String ec2InstanceRegion = EC2MetadataUtils.fetchData(EC2_METADATA_REGION_PATH, false, 1);
- // ec2InstanceRegion could be null
+ client = Ec2MetadataSharedClient.builder()
+ .retryPolicy(Ec2MetadataRetryPolicy.none())
+ .build();
+
+ String ec2InstanceRegion = client.get(EC2_METADATA_REGION_PATH).asString();
return Optional.ofNullable(ec2InstanceRegion);
- } catch (Exception exception) {
+ } catch (SdkClientException e) {
return Optional.empty();
+ } finally {
+ if (client != null) {
+ Ec2MetadataSharedClient.decrementAndClose();
+ }
}
}
diff --git a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscoveryEc2MetadataClientTest.java b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscoveryEc2MetadataClientTest.java
new file mode 100644
index 000000000000..b76324b47073
--- /dev/null
+++ b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/internal/defaultsmode/AutoDefaultsModeDiscoveryEc2MetadataClientTest.java
@@ -0,0 +1,276 @@
+/*
+ * 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.awscore.internal.defaultsmode;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import software.amazon.awssdk.awscore.defaultsmode.DefaultsMode;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.testutils.EnvironmentVariableHelper;
+
+/**
+ * Tests specifically for AutoDefaultsModeDiscovery's migration to use Ec2MetadataClient.
+ * These tests verify that the migration from EC2MetadataUtils to Ec2MetadataClient works correctly.
+ */
+public class AutoDefaultsModeDiscoveryEc2MetadataClientTest {
+ private static final EnvironmentVariableHelper ENVIRONMENT_VARIABLE_HELPER = new EnvironmentVariableHelper();
+
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort().dynamicPort())
+ .configureStaticDsl(true)
+ .build();
+
+ @BeforeAll
+ static void setupClass() {
+ System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(),
+ "http://localhost:" + wireMock.getPort());
+ }
+
+ @AfterAll
+ static void cleanupClass() {
+ System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property());
+ }
+
+ @BeforeEach
+ public void setup() {
+ clearEnvironmentVariable("AWS_EXECUTION_ENV");
+ clearEnvironmentVariable("AWS_REGION");
+ clearEnvironmentVariable("AWS_DEFAULT_REGION");
+ }
+
+ @AfterEach
+ public void cleanup() {
+ wireMock.resetAll();
+ ENVIRONMENT_VARIABLE_HELPER.reset();
+ }
+
+ // Clear an environment variable by setting it to null.
+ private void clearEnvironmentVariable(String name) {
+ try {
+ ENVIRONMENT_VARIABLE_HELPER.set(name, null);
+ } catch (Exception e) {
+ // Ignore
+ }
+ }
+
+ @Test
+ public void autoDefaultsModeDiscovery_shouldUseSharedHttpClient() throws Exception {
+ // Stub successful IMDS responses
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .withBody("test-token")));
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(200).withBody("us-east-1")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should return IN_REGION since client region matches IMDS region
+ assertThat(result).isEqualTo(DefaultsMode.IN_REGION);
+
+ // Verify token request was made
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+
+ // Verify region request was made with token header - IMDSv2
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching("test-token")));
+
+ // Verify no IMDSv1 requests were made
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+ }
+
+ @Test
+ public void multipleDiscoveryInstances_shouldShareSameHttpClient() throws Exception {
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .withBody("test-token")));
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(200).withBody("us-west-2")));
+
+ // Create multiple discovery instances
+ AutoDefaultsModeDiscovery discovery1 = new AutoDefaultsModeDiscovery();
+ AutoDefaultsModeDiscovery discovery2 = new AutoDefaultsModeDiscovery();
+
+ // Both should use the same shared HTTP client
+ DefaultsMode result1 = discovery1.discover(Region.US_EAST_1);
+ DefaultsMode result2 = discovery2.discover(Region.US_EAST_1);
+
+ // Both should return CROSS_REGION
+ assertThat(result1).isEqualTo(DefaultsMode.CROSS_REGION);
+ assertThat(result2).isEqualTo(DefaultsMode.CROSS_REGION);
+
+ // Verify token request was made
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+
+ // Verify region request was made with token header - IMDSv2
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching("test-token")));
+
+ // Verify no IMDSv1 requests were made
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+ }
+
+ @Test
+ public void awsEc2MetadataDisabled_shouldSkipImdsAndUseStandardMode() {
+ // Disable IMDS
+ ENVIRONMENT_VARIABLE_HELPER.set(SdkSystemSetting.AWS_EC2_METADATA_DISABLED.environmentVariable(), "true");
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should return STANDARD mode without making IMDS calls
+ assertThat(result).isEqualTo(DefaultsMode.STANDARD);
+
+ // Verify no IMDS requests were made
+ verify(0, putRequestedFor(urlEqualTo("/latest/api/token")));
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region")));
+ }
+
+ @Test
+ public void imdsFailure_shouldFallbackToStandardMode() {
+ // Stub IMDS to fail
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should fall back to STANDARD mode when IMDS fails
+ assertThat(result).isEqualTo(DefaultsMode.STANDARD);
+
+ // Verify IMDS requests were attempted
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ }
+
+ @Test
+ public void noRetryPolicy_shouldBeUsedByDefault() {
+ // Stub token to succeed but region to fail with retryable error
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .withBody("test-token")));
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should fail immediately without retries and fallback to STANDARD
+ assertThat(result).isEqualTo(DefaultsMode.STANDARD);
+
+ // Verify requests were made once (no retries)
+ verify(1, putRequestedFor(urlEqualTo("/latest/api/token")));
+
+ // Verify region request was made with token header - IMDSv2
+ verify(1, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching("test-token")));
+
+ // Verify no IMDSv1 requests were made
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+ }
+
+ @Test
+ public void imdsV1Fallback_shouldWorkWhenTokenFails() {
+ // Stub token request to fail
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ // Stub successful IMDSv1 request
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(200).withBody("us-east-1")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should fall back to IMDSv1 and return IN_REGION
+ assertThat(result).isEqualTo(DefaultsMode.IN_REGION);
+
+ // Verify token request was attempted
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+
+ // Verify region request was made without token header - IMDSv1 fallback
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+
+ // Verify no IMDSv2 requests were made
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching(".*")));
+ }
+
+ @Test
+ public void imdsV1Fallback_shouldNotWorkWhenV1Disabled() {
+ // Disable IMDSv1 fallback
+ ENVIRONMENT_VARIABLE_HELPER.set(SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.environmentVariable(), "true");
+
+ // Stub token request to fail
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should fail without fallback to IMDSv1 and return STANDARD
+ assertThat(result).isEqualTo(DefaultsMode.STANDARD);
+
+ // Verify only token request was made
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ }
+
+ @Test
+ public void tokenRequest400Error_shouldNotFallbackToV1() {
+ // Stub token request to fail with 400
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(400).withBody("Bad Request")));
+
+ AutoDefaultsModeDiscovery discovery = new AutoDefaultsModeDiscovery();
+ DefaultsMode result = discovery.discover(Region.US_EAST_1);
+
+ // Should fail without attempting IMDSv1 fallback and return STANDARD
+ assertThat(result).isEqualTo(DefaultsMode.STANDARD);
+
+ // Verify only token request was made
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ }
+}
diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java
index 2698ad083fcb..fbb7d24bb2cf 100644
--- a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java
+++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/BaseEc2MetadataClient.java
@@ -44,8 +44,8 @@ public abstract class BaseEc2MetadataClient {
protected final RequestMarshaller requestMarshaller;
protected final Duration tokenTtl;
- private BaseEc2MetadataClient(Ec2MetadataRetryPolicy retryPolicy, Duration tokenTtl, URI endpoint,
- EndpointMode endpointMode) {
+ protected BaseEc2MetadataClient(Ec2MetadataRetryPolicy retryPolicy, Duration tokenTtl, URI endpoint,
+ EndpointMode endpointMode) {
this.retryPolicy = Validate.getOrDefault(retryPolicy, Ec2MetadataRetryPolicy.builder()::build);
this.tokenTtl = Validate.getOrDefault(tokenTtl, () -> DEFAULT_TOKEN_TTL);
this.endpoint = getEndpoint(endpoint, endpointMode);
@@ -60,6 +60,7 @@ protected BaseEc2MetadataClient(DefaultEc2MetadataAsyncClient.Ec2MetadataAsyncBu
this(builder.getRetryPolicy(), builder.getTokenTtl(), builder.getEndpoint(), builder.getEndpointMode());
}
+
private URI getEndpoint(URI builderEndpoint, EndpointMode builderEndpointMode) {
Validate.mutuallyExclusive("Only one of 'endpoint' or 'endpointMode' must be specified, but not both",
builderEndpoint, builderEndpointMode);
diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallback.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallback.java
new file mode 100644
index 000000000000..3f1a1f7c4d45
--- /dev/null
+++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallback.java
@@ -0,0 +1,405 @@
+/*
+ * 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.internal;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import software.amazon.awssdk.annotations.Immutable;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.ThreadSafe;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.core.exception.RetryableException;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
+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;
+import software.amazon.awssdk.http.HttpStatusFamily;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.imds.Ec2MetadataClient;
+import software.amazon.awssdk.imds.Ec2MetadataClientException;
+import software.amazon.awssdk.imds.Ec2MetadataResponse;
+import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy;
+import software.amazon.awssdk.imds.EndpointMode;
+import software.amazon.awssdk.profiles.ProfileProperty;
+import software.amazon.awssdk.utils.Either;
+import software.amazon.awssdk.utils.Logger;
+import software.amazon.awssdk.utils.OptionalUtils;
+import software.amazon.awssdk.utils.ToString;
+import software.amazon.awssdk.utils.Validate;
+import software.amazon.awssdk.utils.cache.CachedSupplier;
+import software.amazon.awssdk.utils.cache.RefreshResult;
+
+/**
+ * An Implementation of the Ec2Metadata Interface with IMDSv1 fallback support.
+ * This client is identical to DefaultEc2MetadataClient but provides automatic fallback
+ * to IMDSv1 when IMDSv2 token retrieval fails (except for 400 errors).
+ */
+@SdkInternalApi
+@Immutable
+@ThreadSafe
+public final class DefaultEc2MetadataClientWithFallback extends BaseEc2MetadataClient implements Ec2MetadataClient {
+
+ private static final Logger log = Logger.loggerFor(DefaultEc2MetadataClientWithFallback.class);
+
+ private final SdkHttpClient httpClient;
+ private final Supplier tokenCache;
+ private final boolean httpClientIsInternal;
+ private final boolean imdsV1FallbackEnabled;
+
+ private DefaultEc2MetadataClientWithFallback(Ec2MetadataBuilder builder) {
+ super(builder.getRetryPolicy(), builder.getTokenTtl(), builder.getEndpoint(), builder.getEndpointMode());
+
+ Validate.isTrue(builder.httpClient == null || builder.httpClientBuilder == null,
+ "The httpClient and the httpClientBuilder can't both be configured.");
+ this.httpClient = Either
+ .fromNullable(builder.httpClient, builder.httpClientBuilder)
+ .map(e -> e.map(Function.identity(), SdkHttpClient.Builder::build))
+ .orElseGet(() -> new DefaultSdkHttpClientBuilder().buildWithDefaults(imdsHttpDefaults()));
+ this.httpClientIsInternal = builder.httpClient == null;
+
+ this.imdsV1FallbackEnabled = !resolveImdsV1Disabled();
+
+ this.tokenCache = CachedSupplier.builder(() -> RefreshResult.builder(this.getTokenWithFallback())
+ .staleTime(Instant.now().plus(tokenTtl))
+ .build())
+ .cachedValueName(toString())
+ .build();
+ }
+
+ @Override
+ public String toString() {
+ return ToString.create("Ec2MetadataClientWithFallback");
+ }
+
+ @Override
+ public void close() {
+ if (httpClientIsInternal) {
+ httpClient.close();
+ }
+ }
+
+ public static Ec2MetadataBuilder builder() {
+ return new DefaultEc2MetadataClientWithFallback.Ec2MetadataBuilder();
+ }
+
+ /**
+ * Gets the specified instance metadata value by the given path with IMDSv1 fallback support.
+ * Will retry based on the {@link Ec2MetadataRetryPolicy retry policy} provided.
+ * Follows the same behavior as EC2MetadataUtils: if IMDSv2 token retrieval fails with 400 error,
+ * throws exception; otherwise falls back to IMDSv1 (null token).
+ *
+ * @param path Input path of the resource to get.
+ * @return Instance metadata value as part of MetadataResponse Object
+ * @throws SdkClientException if the request fails after all retries and fallback attempts
+ */
+ @Override
+ public Ec2MetadataResponse get(String path) {
+ Throwable lastCause = null;
+ // 3 retries means 4 total attempts
+ Token token = null;
+ for (int attempt = 0; attempt < retryPolicy.numRetries() + 1; attempt++) {
+ RetryPolicyContext retryPolicyContext = RetryPolicyContext.builder().retriesAttempted(attempt).build();
+ try {
+ if (token == null || token.isExpired()) {
+ token = tokenCache.get();
+ }
+ return sendRequest(path, token == null ? null : token.value());
+ } catch (UncheckedIOException | RetryableException e) {
+ lastCause = e;
+ int currentTry = attempt;
+ log.debug(() -> "Error while executing EC2Metadata request, attempting retry. Current attempt: " + currentTry);
+ } catch (SdkClientException sdkClientException) {
+ int totalTries = attempt + 1;
+ log.debug(() -> String.format("Error while executing EC2Metadata request. Total attempts: %d. %s",
+ totalTries,
+ sdkClientException.getMessage()));
+ throw sdkClientException;
+ } catch (IOException ioe) {
+ lastCause = new UncheckedIOException(ioe);
+ int currentTry = attempt;
+ log.debug(() -> "Error while executing EC2Metadata request, attempting retry. Current attempt: " + currentTry);
+ }
+ pauseBeforeRetryIfNeeded(retryPolicyContext);
+ }
+
+ SdkClientException.Builder sdkClientExceptionBuilder = SdkClientException
+ .builder()
+ .message("Exceeded maximum number of retries. Total retry attempts: " + retryPolicy.numRetries());
+ if (lastCause != null) {
+ String msg = sdkClientExceptionBuilder.message();
+ sdkClientExceptionBuilder.cause(lastCause).message(msg);
+ }
+ throw sdkClientExceptionBuilder.build();
+ }
+
+ /**
+ * Gets token with fallback logic that can be cached.
+ * If token retrieval fails with 400 error, throws exception.
+ * Otherwise, returns null to indicate fallback to IMDSv1.
+ */
+ private Token getTokenWithFallback() {
+ try {
+ return getToken();
+ } catch (Exception e) {
+ boolean is400ServiceException = e instanceof Ec2MetadataClientException
+ && ((Ec2MetadataClientException) e).statusCode() == 400;
+
+ // metadata resolution must not continue to the token-less flow for a 400
+ if (is400ServiceException) {
+ throw SdkClientException.builder()
+ .message("Unable to fetch metadata token")
+ .cause(e)
+ .build();
+ }
+ return handleTokenErrorResponse(e);
+ }
+ }
+
+ /**
+ * Handles token error response following EC2MetadataUtils pattern.
+ */
+ private Token handleTokenErrorResponse(Exception e) {
+ if (!imdsV1FallbackEnabled) {
+ String message = String.format("Failed to retrieve IMDS token, and fallback to IMDS v1 is disabled via the "
+ + "%s system property, %s environment variable, or %s configuration file profile"
+ + " setting.",
+ SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.environmentVariable(),
+ SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.property(),
+ ProfileProperty.EC2_METADATA_V1_DISABLED);
+ throw SdkClientException.builder()
+ .message(message)
+ .cause(e)
+ .build();
+ }
+ return null; // null token indicates fallback to IMDSv1
+ }
+
+
+ /**
+ * Resolves whether IMDSv1 is disabled from system settings and profile file.
+ */
+ private boolean resolveImdsV1Disabled() {
+ return OptionalUtils.firstPresent(
+ fromSystemSettingsMetadataV1Disabled(),
+ () -> fromProfileFileMetadataV1Disabled()
+ )
+ .orElse(false);
+ }
+
+ private Optional fromSystemSettingsMetadataV1Disabled() {
+ return SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.getStringValue()
+ .map(Boolean::parseBoolean);
+ }
+
+ private Optional fromProfileFileMetadataV1Disabled() {
+ return Ec2MetadataConfigProvider.instance()
+ .resolveProfile()
+ .flatMap(p -> p.property(ProfileProperty.EC2_METADATA_V1_DISABLED))
+ .map(Boolean::parseBoolean);
+ }
+
+ private void handleUnsuccessfulResponse(int statusCode, Optional responseBody,
+ HttpExecuteResponse response, Supplier errorMessageSupplier) {
+ String responseContent = responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
+ .orElse("");
+
+ throw Ec2MetadataClientException.builder()
+ .statusCode(statusCode)
+ .sdkHttpResponse(response.httpResponse())
+ .rawResponse(SdkBytes.fromUtf8String(responseContent))
+ .message(errorMessageSupplier.get())
+ .build();
+ }
+
+ private Ec2MetadataResponse sendRequest(String path, String token) throws IOException {
+ HttpExecuteRequest httpExecuteRequest =
+ HttpExecuteRequest.builder()
+ .request(requestMarshaller.createDataRequest(path, token, tokenTtl))
+ .build();
+ HttpExecuteResponse response = httpClient.prepareRequest(httpExecuteRequest).call();
+
+ int statusCode = response.httpResponse().statusCode();
+ Optional responseBody = response.responseBody();
+
+ if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SERVER_ERROR)) {
+ responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
+ .ifPresent(str -> log.debug(() -> "Metadata request response body: " + str));
+ throw RetryableException
+ .builder()
+ .message(String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode))
+ .build();
+ }
+
+ if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) {
+ handleUnsuccessfulResponse(statusCode, responseBody, response,
+ () -> String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode)
+ );
+ }
+
+ AbortableInputStream abortableInputStream = responseBody.orElseThrow(
+ SdkClientException.builder().message("Response body empty with Status Code " + statusCode)::build);
+ String data = uncheckedInputStreamToUtf8(abortableInputStream);
+ return Ec2MetadataResponse.create(data);
+ }
+
+ private void pauseBeforeRetryIfNeeded(RetryPolicyContext retryPolicyContext) {
+ long backoffTimeMillis = retryPolicy.backoffStrategy()
+ .computeDelayBeforeNextRetry(retryPolicyContext)
+ .toMillis();
+ if (backoffTimeMillis > 0) {
+ try {
+ TimeUnit.MILLISECONDS.sleep(backoffTimeMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw SdkClientException.builder().message("Thread interrupted while trying to sleep").cause(e).build();
+ }
+ }
+ }
+
+ private Token getToken() {
+ HttpExecuteRequest httpExecuteRequest = HttpExecuteRequest.builder()
+ .request(requestMarshaller.createTokenRequest(tokenTtl))
+ .build();
+ HttpExecuteResponse response = null;
+ try {
+ response = httpClient.prepareRequest(httpExecuteRequest).call();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+
+ int statusCode = response.httpResponse().statusCode();
+
+ if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SERVER_ERROR)) {
+ response.responseBody().map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
+ .ifPresent(str -> log.debug(() -> "Metadata request response body: " + str));
+ throw RetryableException.builder()
+ .message("Could not retrieve token, " + statusCode + " error occurred").build();
+ }
+
+ if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) {
+ handleUnsuccessfulResponse(statusCode, response.responseBody(), response,
+ () -> String.format("Could not retrieve token, %d error occurred", statusCode)
+ );
+ }
+
+ String ttl = response.httpResponse()
+ .firstMatchingHeader(RequestMarshaller.EC2_METADATA_TOKEN_TTL_HEADER)
+ .orElseThrow(() -> SdkClientException
+ .builder()
+ .message(RequestMarshaller.EC2_METADATA_TOKEN_TTL_HEADER + " header not found in token response")
+ .build());
+ java.time.Duration ttlDuration;
+ try {
+ ttlDuration = java.time.Duration.ofSeconds(Long.parseLong(ttl));
+ } catch (NumberFormatException nfe) {
+ throw SdkClientException.create("Invalid token format received from IMDS server", nfe);
+ }
+
+ AbortableInputStream abortableInputStream = response.responseBody().orElseThrow(
+ SdkClientException.builder().message("Empty response body")::build);
+
+ String value = uncheckedInputStreamToUtf8(abortableInputStream);
+ return new Token(value, ttlDuration);
+ }
+
+ protected static final class Ec2MetadataBuilder implements Ec2MetadataClient.Builder {
+
+ private Ec2MetadataRetryPolicy retryPolicy;
+ private URI endpoint;
+ private Duration tokenTtl;
+ private EndpointMode endpointMode;
+ private SdkHttpClient httpClient;
+ private SdkHttpClient.Builder> httpClientBuilder;
+
+ private Ec2MetadataBuilder() {
+ }
+
+ @Override
+ public Ec2MetadataBuilder retryPolicy(Ec2MetadataRetryPolicy retryPolicy) {
+ this.retryPolicy = retryPolicy;
+ return this;
+ }
+
+ @Override
+ public Builder retryPolicy(java.util.function.Consumer builderConsumer) {
+ Validate.notNull(builderConsumer, "builderConsumer must not be null");
+ Ec2MetadataRetryPolicy.Builder builder = Ec2MetadataRetryPolicy.builder();
+ builderConsumer.accept(builder);
+ return retryPolicy(builder.build());
+ }
+
+ @Override
+ public Ec2MetadataBuilder endpoint(java.net.URI endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ @Override
+ public Ec2MetadataBuilder tokenTtl(java.time.Duration tokenTtl) {
+ this.tokenTtl = tokenTtl;
+ return this;
+ }
+
+ @Override
+ public Ec2MetadataBuilder endpointMode(EndpointMode endpointMode) {
+ this.endpointMode = endpointMode;
+ return this;
+ }
+
+ @Override
+ public Ec2MetadataBuilder httpClient(SdkHttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ @Override
+ public Builder httpClient(SdkHttpClient.Builder> builder) {
+ this.httpClientBuilder = builder;
+ return this;
+ }
+
+ public Ec2MetadataRetryPolicy getRetryPolicy() {
+ return this.retryPolicy;
+ }
+
+ public java.net.URI getEndpoint() {
+ return this.endpoint;
+ }
+
+ public java.time.Duration getTokenTtl() {
+ return this.tokenTtl;
+ }
+
+ public EndpointMode getEndpointMode() {
+ return this.endpointMode;
+ }
+
+ @Override
+ public Ec2MetadataClient build() {
+ return new DefaultEc2MetadataClientWithFallback(this);
+ }
+ }
+}
diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataSharedClient.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataSharedClient.java
new file mode 100644
index 000000000000..a9eebadb9d70
--- /dev/null
+++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/Ec2MetadataSharedClient.java
@@ -0,0 +1,114 @@
+/*
+ * 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.internal;
+
+import java.time.Duration;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import software.amazon.awssdk.annotations.SdkProtectedApi;
+import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+import software.amazon.awssdk.imds.Ec2MetadataClient;
+import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy;
+import software.amazon.awssdk.utils.AttributeMap;
+
+/**
+ * Creates Ec2MetadataClient instances using a shared HTTP client internally.
+ * This provides resource efficiency by sharing a single HTTP client across all IMDS-backed providers
+ */
+@SdkProtectedApi
+public final class Ec2MetadataSharedClient {
+
+ private static final Lock LOCK = new ReentrantLock();
+ private static volatile SdkHttpClient sharedHttpClient;
+ private static int referenceCount = 0;
+
+ private Ec2MetadataSharedClient() {
+ // Prevent instantiation
+ }
+
+ /**
+ * Creates a builder for configuring Ec2MetadataClient with shared HTTP client.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates a new Ec2MetadataClient instance using the shared HTTP client
+ * with default configuration.
+ */
+ public static Ec2MetadataClient create() {
+ return builder().build();
+ }
+
+ /**
+ * Decrements the reference count and closes the shared HTTP client if no more references exist.
+ */
+ public static void decrementAndClose() {
+ LOCK.lock();
+ try {
+ referenceCount--;
+ if (referenceCount == 0 && sharedHttpClient != null) {
+ sharedHttpClient.close();
+ sharedHttpClient = null;
+ }
+ } finally {
+ LOCK.unlock();
+ }
+ }
+
+ private static SdkHttpClient createImdsHttpClient() {
+ Duration metadataServiceTimeout = Ec2MetadataConfigProvider.instance().resolveServiceTimeout();
+ AttributeMap imdsHttpDefaults = AttributeMap.builder()
+ .put(SdkHttpConfigurationOption.CONNECTION_TIMEOUT, metadataServiceTimeout)
+ .put(SdkHttpConfigurationOption.READ_TIMEOUT, metadataServiceTimeout)
+ .build();
+
+ return new DefaultSdkHttpClientBuilder().buildWithDefaults(imdsHttpDefaults);
+ }
+
+ public static final class Builder {
+ private Ec2MetadataRetryPolicy retryPolicy;
+
+ private Builder() {
+ }
+
+ public Builder retryPolicy(Ec2MetadataRetryPolicy retryPolicy) {
+ this.retryPolicy = retryPolicy;
+ return this;
+ }
+
+ public Ec2MetadataClient build() {
+ LOCK.lock();
+ try {
+ if (sharedHttpClient == null) {
+ sharedHttpClient = createImdsHttpClient();
+ }
+
+ referenceCount++;
+
+ return DefaultEc2MetadataClientWithFallback.builder()
+ .httpClient(sharedHttpClient)
+ .retryPolicy(retryPolicy)
+ .build();
+ } finally {
+ LOCK.unlock();
+ }
+ }
+ }
+}
diff --git a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java
index 516def7dd985..cc721143da36 100644
--- a/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java
+++ b/core/imds/src/main/java/software/amazon/awssdk/imds/internal/RequestMarshaller.java
@@ -50,7 +50,7 @@ public RequestMarshaller(URI basePath) {
}
public SdkHttpFullRequest createTokenRequest(Duration tokenTtl) {
- return defaulttHttpBuilder()
+ return defaultHttpBuilder()
.method(SdkHttpMethod.PUT)
.uri(tokenPath)
.putHeader(EC2_METADATA_TOKEN_TTL_HEADER, String.valueOf(tokenTtl.getSeconds()))
@@ -59,15 +59,17 @@ public SdkHttpFullRequest createTokenRequest(Duration tokenTtl) {
public SdkHttpFullRequest createDataRequest(String path, String token, Duration tokenTtl) {
URI resourcePath = URI.create(basePath + path);
- return defaulttHttpBuilder()
+ SdkHttpFullRequest.Builder builder = defaultHttpBuilder()
.method(SdkHttpMethod.GET)
.uri(resourcePath)
- .putHeader(EC2_METADATA_TOKEN_TTL_HEADER, String.valueOf(tokenTtl.getSeconds()))
- .putHeader(TOKEN_HEADER, token)
- .build();
+ .putHeader(EC2_METADATA_TOKEN_TTL_HEADER, String.valueOf(tokenTtl.getSeconds()));
+ if (token != null) {
+ builder.putHeader(TOKEN_HEADER, token);
+ }
+ return builder.build();
}
- private SdkHttpFullRequest.Builder defaulttHttpBuilder() {
+ private SdkHttpFullRequest.Builder defaultHttpBuilder() {
return SdkHttpFullRequest.builder()
.putHeader(USER_AGENT, SystemUserAgent.getOrCreate().userAgentString())
.putHeader(ACCEPT, "*/*")
diff --git a/core/imds/src/test/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallbackTest.java b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallbackTest.java
new file mode 100644
index 000000000000..0d1a70747b62
--- /dev/null
+++ b/core/imds/src/test/java/software/amazon/awssdk/imds/internal/DefaultEc2MetadataClientWithFallbackTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.internal;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.put;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static com.github.tomakehurst.wiremock.client.WireMock.matching;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import java.net.URI;
+import java.time.Duration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.imds.Ec2MetadataClient;
+import software.amazon.awssdk.imds.Ec2MetadataResponse;
+import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy;
+import software.amazon.awssdk.testutils.EnvironmentVariableHelper;
+
+/**
+ * Tests for DefaultEc2MetadataClientWithFallback to verify IMDSv1 fallback behavior.
+ */
+public class DefaultEc2MetadataClientWithFallbackTest {
+ private static final EnvironmentVariableHelper ENVIRONMENT_VARIABLE_HELPER = new EnvironmentVariableHelper();
+
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort().dynamicPort())
+ .configureStaticDsl(true)
+ .build();
+
+ private Ec2MetadataClient client;
+
+ @BeforeEach
+ public void setup() {
+ clearEnvironmentVariable(SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.environmentVariable());
+
+ // Create client with WireMock endpoint
+ client = DefaultEc2MetadataClientWithFallback.builder()
+ .endpoint(URI.create("http://localhost:" + wireMock.getPort()))
+ .retryPolicy(Ec2MetadataRetryPolicy.builder().numRetries(0).build())
+ .tokenTtl(Duration.ofSeconds(21600))
+ .build();
+ }
+
+ @AfterEach
+ public void cleanup() {
+ if (client != null) {
+ client.close();
+ }
+ wireMock.resetAll();
+ ENVIRONMENT_VARIABLE_HELPER.reset();
+ }
+
+ private void clearEnvironmentVariable(String name) {
+ try {
+ ENVIRONMENT_VARIABLE_HELPER.set(name, null);
+ } catch (Exception e) {
+ //Ignore
+ }
+ }
+
+ @Test
+ public void imdsV2Success_shouldUseTokenAndReturnData() {
+ // Stub successful IMDSv2 flow
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .withBody("test-token")));
+
+ stubFor(get("/latest/meta-data/placement/region")
+ .withHeader("x-aws-ec2-metadata-token", matching("test-token"))
+ .willReturn(aResponse().withStatus(200).withBody("us-east-1")));
+
+ Ec2MetadataResponse response = client.get("/latest/meta-data/placement/region");
+
+ assertThat(response.asString()).isEqualTo("us-east-1");
+
+ // Verify both token and data requests were made
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching("test-token")));
+ }
+
+ @Test
+ public void imdsV1Fallback_shouldWorkWhenTokenFails() {
+ // Stub token request to fail with 500
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ // Stub successful IMDSv1 request
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(200).withBody("us-west-2")));
+
+ Ec2MetadataResponse response = client.get("/latest/meta-data/placement/region");
+
+ assertThat(response.asString()).isEqualTo("us-west-2");
+
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+
+ // Verify no IMDSv2 requests were made
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching(".*")));
+ }
+
+ @Test
+ public void imdsV1Fallback_shouldNotWorkWith400Error() {
+ // Stub token request to fail with 400
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(400).withBody("Bad Request")));
+
+ assertThatThrownBy(() -> client.get("/latest/meta-data/placement/region"))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("Unable to fetch metadata token");
+
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region")));
+ }
+
+ @Test
+ public void imdsV1Fallback_shouldNotWorkWhenDisabled() {
+ // Disable IMDSv1 fallback
+ ENVIRONMENT_VARIABLE_HELPER.set(SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.environmentVariable(), "true");
+
+ // Recreate client to pick up environment variable
+ client.close();
+ client = DefaultEc2MetadataClientWithFallback.builder()
+ .endpoint(URI.create("http://localhost:" + wireMock.getPort()))
+ .retryPolicy(Ec2MetadataRetryPolicy.builder().numRetries(0).build())
+ .tokenTtl(Duration.ofSeconds(21600))
+ .build();
+
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ // Should throw exception without attempting IMDSv1 fallback
+ assertThatThrownBy(() -> client.get("/latest/meta-data/placement/region"))
+ .isInstanceOf(SdkClientException.class)
+ .hasMessageContaining("fallback to IMDS v1 is disabled");
+
+
+ verify(putRequestedFor(urlEqualTo("/latest/api/token")));
+ verify(0, getRequestedFor(urlEqualTo("/latest/meta-data/placement/region")));
+ }
+
+ @Test
+ public void tokenCaching_shouldReuseValidToken() {
+ // Stub successful token request
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .withBody("cached-token")));
+
+ // Stub successful data requests
+ stubFor(get("/latest/meta-data/placement/region")
+ .withHeader("x-aws-ec2-metadata-token", matching("cached-token"))
+ .willReturn(aResponse().withStatus(200).withBody("us-east-1")));
+
+ stubFor(get("/latest/meta-data/instance-id")
+ .withHeader("x-aws-ec2-metadata-token", matching("cached-token"))
+ .willReturn(aResponse().withStatus(200).withBody("i1234")));
+
+ // Make two requests
+ Ec2MetadataResponse response1 = client.get("/latest/meta-data/placement/region");
+ Ec2MetadataResponse response2 = client.get("/latest/meta-data/instance-id");
+
+ assertThat(response1.asString()).isEqualTo("us-east-1");
+ assertThat(response2.asString()).isEqualTo("i1234");
+
+ // Verify token was requested only once
+ verify(1, putRequestedFor(urlEqualTo("/latest/api/token")));
+
+ // Verify both data requests were made with the same token
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withHeader("x-aws-ec2-metadata-token", matching("cached-token")));
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/instance-id"))
+ .withHeader("x-aws-ec2-metadata-token", matching("cached-token")));
+ }
+
+ @Test
+ public void fallbackAfterTokenFailure_shouldUseImdsV1ForSubsequentRequests() {
+ // Stub token request to fail
+ stubFor(put("/latest/api/token")
+ .willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ // Stub successful IMDSv1 requests
+ stubFor(get("/latest/meta-data/placement/region")
+ .willReturn(aResponse().withStatus(200).withBody("us-west-2")));
+
+ stubFor(get("/latest/meta-data/instance-id")
+ .willReturn(aResponse().withStatus(200).withBody("i-123")));
+
+ // Make two requests - both should use IMDSv1 fallback
+ Ec2MetadataResponse response1 = client.get("/latest/meta-data/placement/region");
+ Ec2MetadataResponse response2 = client.get("/latest/meta-data/instance-id");
+
+ assertThat(response1.asString()).isEqualTo("us-west-2");
+ assertThat(response2.asString()).isEqualTo("i-123");
+
+ // Verify token was requested only once
+ verify(1, putRequestedFor(urlEqualTo("/latest/api/token")));
+
+
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/placement/region"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+ verify(getRequestedFor(urlEqualTo("/latest/meta-data/instance-id"))
+ .withoutHeader("x-aws-ec2-metadata-token"));
+ }
+}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java
index 5fc4b4b45ec7..00539ab7b506 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/DefaultSdkHttpClientBuilder.java
@@ -15,7 +15,7 @@
package software.amazon.awssdk.core.internal.http.loader;
-import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.SdkProtectedApi;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.http.SdkHttpService;
@@ -23,8 +23,11 @@
/**
* Utility to load the default HTTP client factory and create an instance of {@link SdkHttpClient}.
+ *
+ * Implementation notes: this class should've been outside internal package,
+ * but we can't fix it due to backwards compatibility reasons.
*/
-@SdkInternalApi
+@SdkProtectedApi
public final class DefaultSdkHttpClientBuilder implements SdkHttpClient.Builder {
private static final SdkHttpServiceProvider DEFAULT_CHAIN = new CachingSdkHttpServiceProvider<>(
diff --git a/test/architecture-tests/archunit_store/c898eee1-aeb5-4355-bb3b-ea56bf58cacb b/test/architecture-tests/archunit_store/c898eee1-aeb5-4355-bb3b-ea56bf58cacb
index 62572136f3db..afb6739bc1fe 100644
--- a/test/architecture-tests/archunit_store/c898eee1-aeb5-4355-bb3b-ea56bf58cacb
+++ b/test/architecture-tests/archunit_store/c898eee1-aeb5-4355-bb3b-ea56bf58cacb
@@ -27,3 +27,5 @@ Class does not reside outsid
Class does not reside outside of package '..internal..' in (EnumUtils.java:0)
Class does not reside outside of package '..internal..' in (MappingSubscriber.java:0)
Class does not reside outside of package '..internal..' in (SystemSettingUtils.java:0)
+Class does not reside outside of package '..internal..' in (DefaultSdkHttpClientBuilder.java:0)
+Class does not reside outside of package '..internal..' in (Ec2MetadataSharedClient.java:0)