Skip to content

Create EC2MetadataClientException for IMDS 4XX errors #5947

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changes/next-release/feature-AWSSDKforJavav2-8a464f3.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"type": "feature",
"category": "AWS SDK for Java v2",
"category": "EC2 Metadata Client",
"contributor": "",
"description": "Added new Ec2MetadataClientException that preserves HTTP status codes from IMDS error responses (4XX), enabling specific error handling based on status codes."
"description": "Added new Ec2MetadataClientException that preserves HTTP status codes from IMDS error responses (4XX), enabling specific error handling based on status codes. See #5786"
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import software.amazon.awssdk.annotations.Immutable;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.annotations.ThreadSafe;
import software.amazon.awssdk.core.exception.RetryableException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
import software.amazon.awssdk.imds.internal.DefaultEc2MetadataAsyncClient;
Expand Down Expand Up @@ -68,6 +69,11 @@ public interface Ec2MetadataAsyncClient extends SdkAutoCloseable {
*
* @param path Input path
* @return A CompletableFuture that completes when the MetadataResponse is made available.
* @throws Ec2MetadataClientException if the request returns a 4XX error response. The exception includes
* the HTTP status code, headers, and error response body
* @throws RetryableException if the request returns a 5XX error response and should be retried
* @throws SdkClientException if the maximum number of retries is reached, if there's an IO error during
* the request, or if the response is empty when success is expected
*/
CompletableFuture<Ec2MetadataResponse> get(String path);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import software.amazon.awssdk.annotations.Immutable;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.annotations.ThreadSafe;
import software.amazon.awssdk.core.exception.RetryableException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.imds.internal.DefaultEc2MetadataClient;
Expand Down Expand Up @@ -66,6 +67,11 @@ public interface Ec2MetadataClient extends SdkAutoCloseable {
*
* @param path Input path
* @return Instance metadata value as part of MetadataResponse Object
* @throws Ec2MetadataClientException if the request returns a 4XX error response. The exception includes
* the HTTP status code, headers, and error response body
* @throws RetryableException if the request returns a 5XX error response and should be retried
* @throws SdkClientException if the maximum number of retries is reached, if there's an IO error during
* the request, or if the response is empty when success is expected
*/
Ec2MetadataResponse get(String path);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,35 @@
* permissions and limitations under the License.
*/

package software.amazon.awssdk.core.exception;
package software.amazon.awssdk.imds;


import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.http.SdkHttpResponse;

/**
* Extends {@link SdkClientException} for EC2 Instance Metadata Service (IMDS) non-successful
* responses (4XX codes). Includes HTTP status codes to enable detailed error handling.
* <p>The status code is accessible via {@link #statusCode()} for specific error handling.
* responses (4XX codes). Provides detailed error information through:
* <p>
* - HTTP status code via {@link #statusCode()} for specific error handling
* - Raw response content via {@link #rawResponse()} containing the error response body
* - HTTP headers via {@link #sdkHttpResponse()} providing additional error context from the response
*/

@SdkPublicApi
public class Ec2MetadataClientException extends SdkClientException {
public final class Ec2MetadataClientException extends SdkClientException {

private final int statusCode;
private final SdkBytes rawResponse;
private final SdkHttpResponse sdkHttpResponse;

private Ec2MetadataClientException(BuilderImpl builder) {
super(builder);
this.statusCode = builder.statusCode;
this.rawResponse = builder.rawResponse;
this.sdkHttpResponse = builder.sdkHttpResponse;
}

/**
Expand All @@ -41,6 +51,19 @@ public int statusCode() {
return statusCode;
}

/**
* Returns the response payload as bytes.
*/
public SdkBytes rawResponse() {
return rawResponse;
}

/**
* Returns a map of HTTP headers associated with the error response.
*/
public SdkHttpResponse sdkHttpResponse() {
return sdkHttpResponse;
}

public static Builder builder() {
return new BuilderImpl();
Expand All @@ -49,19 +72,37 @@ public static Builder builder() {
public interface Builder extends SdkClientException.Builder {
Builder statusCode(int statusCode);

Builder rawResponse(SdkBytes rawResponse);

Builder sdkHttpResponse(SdkHttpResponse sdkHttpResponse);

@Override
Ec2MetadataClientException build();
}

private static final class BuilderImpl extends SdkClientException.BuilderImpl implements Builder {
private int statusCode;
private SdkBytes rawResponse;
private SdkHttpResponse sdkHttpResponse;

@Override
public Builder statusCode(int statusCode) {
this.statusCode = statusCode;
return this;
}

@Override
public Builder rawResponse(SdkBytes rawResponse) {
this.rawResponse = rawResponse;
return this;
}

@Override
public Builder sdkHttpResponse(SdkHttpResponse sdkHttpResponse) {
this.sdkHttpResponse = sdkHttpResponse;
return this;
}

@Override
public Ec2MetadataClientException build() {
return new Ec2MetadataClientException(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.core.exception.Ec2MetadataClientException;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.core.exception.RetryableException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.http.HttpResponseHandler;
Expand All @@ -38,6 +38,7 @@
import software.amazon.awssdk.http.async.AsyncExecuteRequest;
import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
import software.amazon.awssdk.http.async.SdkHttpContentPublisher;
import software.amazon.awssdk.imds.Ec2MetadataClientException;
import software.amazon.awssdk.utils.CompletableFutureUtils;

@SdkInternalApi
Expand Down Expand Up @@ -88,9 +89,12 @@ private static String handleResponse(SdkHttpFullResponse response, ExecutionAttr
// non-retryable error
if (statusCode.isOneOf(HttpStatusFamily.CLIENT_ERROR)) {
throw Ec2MetadataClientException.builder()
.statusCode(response.statusCode())
.message("IMDS service returned an error response: " + responseContent)
.build();
.statusCode(response.statusCode())
.sdkHttpResponse(response)
.rawResponse(SdkBytes.fromUtf8String(responseContent))
.message(String.format("The requested metadata returned Http code %s",
response.statusCode()))
.build();
}

// retryable error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import software.amazon.awssdk.annotations.Immutable;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.annotations.ThreadSafe;
import software.amazon.awssdk.core.exception.Ec2MetadataClientException;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.core.exception.RetryableException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.internal.http.loader.DefaultSdkHttpClientBuilder;
Expand All @@ -41,6 +41,7 @@
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;
Expand Down Expand Up @@ -150,6 +151,27 @@ public Ec2MetadataResponse get(String path) {
throw sdkClientExceptionBuilder.build();
}

private void handleUnsuccessfulResponse(int statusCode, Optional<AbortableInputStream> responseBody,
HttpExecuteResponse response, String path, boolean isTokenRequest) {
String responseContent = responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
.orElse("");

log.debug(() -> String.format("%s request response body: %s",
isTokenRequest ? "Token" : "Metadata",
responseContent));

String errorMessage = isTokenRequest
? "Could not retrieve token, " + statusCode + " error occurred"
: String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode);

throw Ec2MetadataClientException.builder()
.statusCode(statusCode)
.sdkHttpResponse(response.httpResponse())
.rawResponse(SdkBytes.fromUtf8String(responseContent))
.message(errorMessage)
.build();
}

private Ec2MetadataResponse sendRequest(String path, String token) throws IOException {

HttpExecuteRequest httpExecuteRequest =
Expand All @@ -170,25 +192,8 @@ private Ec2MetadataResponse sendRequest(String path, String token) throws IOExce
.build();
}

if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.CLIENT_ERROR)) {
String responseContent = responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
.orElse("");
log.debug(() -> "Metadata request response body: " + responseContent);
throw Ec2MetadataClientException
.builder()
.statusCode(statusCode)
.message(String.format("The requested metadata at path '%s' returned Http code %s: %s",
path, statusCode, responseContent))
.build();
}

if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) {
responseBody.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
.ifPresent(str -> log.debug(() -> "Metadata request response body: " + str));
throw SdkClientException
.builder()
.message(String.format("The requested metadata at path '%s' returned Http code %s", path, statusCode))
.build();
handleUnsuccessfulResponse(statusCode, responseBody, response, path, false);
}

AbortableInputStream abortableInputStream = responseBody.orElseThrow(
Expand Down Expand Up @@ -231,23 +236,8 @@ private Token getToken() {
.message("Could not retrieve token, " + statusCode + " error occurred").build();
}

if (HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.CLIENT_ERROR)) {
String responseContent = response.responseBody()
.map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
.orElse("");
log.debug(() -> "Token request response body: " + responseContent);
throw Ec2MetadataClientException.builder()
.statusCode(statusCode)
.message("Could not retrieve token, " + statusCode + " error occurred: " + responseContent)
.build();
}

if (!HttpStatusFamily.of(statusCode).isOneOf(HttpStatusFamily.SUCCESSFUL)) {
response.responseBody().map(BaseEc2MetadataClient::uncheckedInputStreamToUtf8)
.ifPresent(body -> log.debug(() -> "Token request response body: " + body));
throw SdkClientException.builder()
.message("Could not retrieve token, " + statusCode + " error occurred.")
.build();
handleUnsuccessfulResponse(statusCode, response.responseBody(), response, null, true);
}

String ttl = response.httpResponse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy;
import software.amazon.awssdk.imds.Ec2MetadataClientBuilder;
import software.amazon.awssdk.imds.Ec2MetadataClientException;
import software.amazon.awssdk.imds.Ec2MetadataResponse;
import software.amazon.awssdk.imds.Ec2MetadataRetryPolicy;
import software.amazon.awssdk.imds.EndpointMode;
Expand Down Expand Up @@ -356,4 +357,49 @@ void get_successOnFirstTry_shouldNotRetryAndSucceed_whenConnectionTakesMoreThanO
.withHeader(TOKEN_HEADER, equalTo("some-token")));
});
}

@Test
void clientError_throwsEc2MetadataClientExceptionWithFullResponse() {
stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH))
.willReturn(aResponse()
.withBody("token")
.withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600")));

stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE))
.willReturn(aResponse()
.withStatus(400)
.withHeader("x-custom-header", "custom-value")
.withBody("Bad Request")));

failureAssertions(
AMI_ID_RESOURCE,
Ec2MetadataClientException.class,
exception -> {
assertThat(exception.statusCode()).isEqualTo(400);
assertThat(exception.sdkHttpResponse().firstMatchingHeader("x-custom-header"))
.hasValue("custom-value");
assertThat(exception.rawResponse().asUtf8String()).isEqualTo("Bad Request");
}
);
}

@Test
void tokenRequest_clientError_throwsEc2MetadataClientExceptionWithFullResponse() {
stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH))
.willReturn(aResponse()
.withStatus(403)
.withHeader("x-error-type", "AccessDenied")
.withBody("Forbidden")));

failureAssertions(
AMI_ID_RESOURCE,
Ec2MetadataClientException.class,
exception -> {
assertThat(exception.statusCode()).isEqualTo(403);
assertThat(exception.sdkHttpResponse().firstMatchingHeader("x-error-type"))
.hasValue("AccessDenied");
assertThat(exception.rawResponse().asUtf8String()).isEqualTo("Forbidden");
}
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.core.exception.Ec2MetadataClientException;
import software.amazon.awssdk.imds.Ec2MetadataClientException;
import software.amazon.awssdk.core.internal.http.loader.DefaultSdkAsyncHttpClientBuilder;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
import software.amazon.awssdk.imds.Ec2MetadataAsyncClient;
Expand Down Expand Up @@ -140,28 +140,4 @@ void builder_httpClientAndHttpBuilder_shouldThrowException() {
.isInstanceOf(IllegalArgumentException.class);
}

@Test
void async_clientError_throwsEc2MetadataClientExceptionWithStatusCode() {
stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH))
.willReturn(aResponse()
.withBody("token")
.withHeader(EC2_METADATA_TOKEN_TTL_HEADER, "21600")));

String errorMessage = "Invalid request";
stubFor(get(urlPathEqualTo(AMI_ID_RESOURCE))
.willReturn(aResponse()
.withStatus(400)
.withBody(errorMessage)));

failureAssertions(
AMI_ID_RESOURCE,
Ec2MetadataClientException.class,
exception -> {
assertThat(exception.statusCode()).isEqualTo(400);
assertThat(exception.getMessage())
.isEqualTo("IMDS service returned an error response: " + errorMessage);
}
);
}

}
Loading
Loading