From 0952ae384cbb1ce2409f30f61708ee711d399653 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Thu, 16 Jul 2026 12:27:40 -0700 Subject: [PATCH 1/4] fix: Preserve error cause in CRT http client TLS and socket timeout failures --- .../bugfix-AWSCRTHTTPClient-1ad80f1.json | 6 + .../ApacheSslHandshakeBehaviorTest.java | 26 +++++ .../awssdk/http/crt/internal/CrtUtils.java | 8 +- .../http/crt/CrtSslHandshakeBehaviorTest.java | 31 +++++ .../internal/CrtAsyncRequestExecutorTest.java | 3 +- .../crt/internal/CrtRequestExecutorTest.java | 3 +- .../http/crt/internal/CrtUtilsTest.java | 34 ++++++ ...tpClientSslHandshakeBehaviorTestSuite.java | 107 ++++++++++++++++++ 8 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 .changes/next-release/bugfix-AWSCRTHTTPClient-1ad80f1.json create mode 100644 http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSslHandshakeBehaviorTest.java create mode 100644 http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java create mode 100644 test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java diff --git a/.changes/next-release/bugfix-AWSCRTHTTPClient-1ad80f1.json b/.changes/next-release/bugfix-AWSCRTHTTPClient-1ad80f1.json new file mode 100644 index 000000000000..655786a81b68 --- /dev/null +++ b/.changes/next-release/bugfix-AWSCRTHTTPClient-1ad80f1.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "AWS CRT HTTP Client", + "contributor": "", + "description": "Preserve the underlying CRT HttpException (including the CRT error code) as the cause of the SSLHandshakeException and ConnectException surfaced for TLS negotiation failures and socket timeouts, so callers can differentiate transient failures from persistent ones by inspecting the exception cause chain." +} diff --git a/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSslHandshakeBehaviorTest.java b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..4e0b979db02f --- /dev/null +++ b/http-clients/apache-client/src/test/java/software/amazon/awssdk/http/apache/ApacheSslHandshakeBehaviorTest.java @@ -0,0 +1,26 @@ +/* + * 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.http.apache; + +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSslHandshakeBehaviorTestSuite; + +public class ApacheSslHandshakeBehaviorTest extends SdkHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkHttpClient createSdkHttpClient() { + return ApacheHttpClient.builder().build(); + } +} diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/CrtUtils.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/CrtUtils.java index 8e1650c56057..a3c16f3f1387 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/CrtUtils.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/CrtUtils.java @@ -94,11 +94,15 @@ public static Throwable wrapCrtException(Throwable throwable) { int httpErrorCode = httpException.getErrorCode(); if (httpErrorCode == CRT_TLS_NEGOTIATION_ERROR_CODE) { - return new SSLHandshakeException(httpException.getMessage()); + SSLHandshakeException sslHandshakeException = new SSLHandshakeException(httpException.getMessage()); + sslHandshakeException.initCause(httpException); + return sslHandshakeException; } if (httpErrorCode == CRT_SOCKET_TIMEOUT) { - return new ConnectException(httpException.getMessage()); + ConnectException connectException = new ConnectException(httpException.getMessage()); + connectException.initCause(httpException); + return connectException; } return wrapWithIoExceptionIfRetryable((HttpException) throwable); diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..dca247db1bab --- /dev/null +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java @@ -0,0 +1,31 @@ +/* + * 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.http.crt; + +import java.time.Duration; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSslHandshakeBehaviorTestSuite; + +public class CrtSslHandshakeBehaviorTest extends SdkHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkHttpClient createSdkHttpClient() { + // The default connectionAcquisitionTimeout is too aggressive for TLS negotiation failures, + // see AwsCrtHttpClientWireMockTest for the same accommodation. + return AwsCrtHttpClient.builder() + .connectionAcquisitionTimeout(Duration.ofSeconds(30)) + .build(); + } +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java index 89d5e2fb2f65..f37af1b28279 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java @@ -184,7 +184,8 @@ public void execute_httpException_mapsToCorrectException(Entry executeFuture = requestExecutor.execute(context); - assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass); + assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass) + .hasRootCause(exception); } @Test diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java index 456000ac1150..437c56f87495 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java @@ -134,7 +134,8 @@ public void execute_httpException_mapsToCorrectException(Entry executeFuture = requestExecutor.execute(context); - assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass); + assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass) + .hasRootCause(exception); } @Test diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java index 5cee0bb4770a..42869c5c64dc 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java @@ -18,6 +18,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.net.ConnectException; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import software.amazon.awssdk.crt.http.HttpException; @@ -70,4 +73,35 @@ public void wrapWithIoExceptionIfRetryable_nonRetryableCode_returnsHttpException assertThat(result).isSameAs(httpException); } + + /** + * Locks in that TLS negotiation failures surface as {@link SSLHandshakeException} with the original + * {@link HttpException} (which carries the CRT error code) chained as the cause, so that callers can + * differentiate transient handshake interruptions from persistent certificate failures. + */ + @Test + public void wrapCrtException_tlsNegotiationError_wrapsInSslHandshakeExceptionWithCause() { + HttpException httpException = new HttpException(CrtUtils.CRT_TLS_NEGOTIATION_ERROR_CODE); + + Throwable result = CrtUtils.wrapCrtException(httpException); + + assertThat(result).isInstanceOf(SSLHandshakeException.class) + .hasMessage(httpException.getMessage()) + .hasCause(httpException); + } + + /** + * Locks in that socket timeouts during connection acquisition surface as {@link ConnectException} with + * the original {@link HttpException} chained as the cause. + */ + @Test + public void wrapCrtException_socketTimeout_wrapsInConnectExceptionWithCause() { + HttpException httpException = new HttpException(CrtUtils.CRT_SOCKET_TIMEOUT); + + Throwable result = CrtUtils.wrapCrtException(httpException); + + assertThat(result).isInstanceOf(ConnectException.class) + .hasMessage(httpException.getMessage()) + .hasCause(httpException); + } } diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java new file mode 100644 index 000000000000..f817e173a954 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java @@ -0,0 +1,107 @@ +/* + * 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.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * A set of tests validating how an {@link SdkHttpClient} surfaces TLS handshake failures. + * + *

The server presents a self-signed certificate that is not trusted by the client, so every + * request fails during TLS negotiation. The suite asserts that the failure is surfaced as + * {@link SSLHandshakeException} and that the exception carries a diagnosable cause chain, so that + * callers (and retry predicates) can differentiate transient handshake interruptions from + * persistent certificate failures. + * + *

This is used by an HTTP plugin implementation by extending this class and implementing the + * abstract methods to provide this suite with a testable HTTP client implementation. + */ +public abstract class SdkHttpClientSslHandshakeBehaviorTestSuite { + private WireMockServer selfSignedServer; + + /** + * Implemented by a child class to create an HTTP client to validate, without any extra options. + */ + protected abstract SdkHttpClient createSdkHttpClient(); + + @BeforeEach + public void setup() { + selfSignedServer = HttpTestUtils.createSelfSignedServer(); + selfSignedServer.start(); + } + + @AfterEach + public void teardown() { + if (selfSignedServer != null) { + selfSignedServer.stop(); + selfSignedServer = null; + } + } + + @Test + public void sslHandshakeFailure_surfacesAsSslHandshakeException() { + Throwable thrown = executeRequestAgainstUntrustedServer(); + + assertThat(thrown).isInstanceOf(SSLHandshakeException.class); + } + + @Test + public void sslHandshakeFailure_exceptionCarriesDiagnosableCause() { + Throwable thrown = executeRequestAgainstUntrustedServer(); + + assertThat(thrown).isInstanceOf(SSLHandshakeException.class); + assertThat(thrown.getCause()) + .withFailMessage("The SSLHandshakeException surfaced by the HTTP client must chain the underlying " + + "failure as its cause. Without a cause chain, callers cannot differentiate " + + "transient TLS handshake interruptions from persistent certificate failures. " + + "Surfaced exception: %s", thrown) + .isNotNull(); + } + + private Throwable executeRequestAgainstUntrustedServer() { + try (SdkHttpClient client = createSdkHttpClient()) { + SdkHttpFullRequest request = httpsRequest(selfSignedServer.httpsPort()); + return catchThrowable(client.prepareRequest(HttpExecuteRequest.builder() + .request(request) + .contentStreamProvider( + request.contentStreamProvider().orElse(null)) + .build()) + ::call); + } + } + + private static SdkHttpFullRequest httpsRequest(int httpsPort) { + URI uri = URI.create("https://localhost:" + httpsPort); + byte[] content = "Body".getBytes(StandardCharsets.UTF_8); + return SdkHttpFullRequest.builder() + .uri(uri) + .method(SdkHttpMethod.POST) + .putHeader("Host", uri.getHost()) + .putHeader("Content-Length", Integer.toString(content.length)) + .contentStreamProvider(() -> new ByteArrayInputStream(content)) + .build(); + } +} From 93ce8b0bb2e3839341741634012dfabfaeea37a9 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Thu, 16 Jul 2026 15:57:32 -0700 Subject: [PATCH 2/4] Updated test cases --- .../Apache5SslHandshakeBehaviorTest.java | 26 +++++++ .../crt/CrtAsyncSslHandshakeBehaviorTest.java | 31 ++++++++ .../http/crt/CrtSslHandshakeBehaviorTest.java | 4 +- .../internal/CrtAsyncRequestExecutorTest.java | 1 + .../crt/internal/CrtRequestExecutorTest.java | 1 + .../http/crt/internal/CrtUtilsTest.java | 12 +-- .../netty/NettySslHandshakeBehaviorTest.java | 26 +++++++ ...UrlConnectionSslHandshakeBehaviorTest.java | 26 +++++++ ...tpClientSslHandshakeBehaviorTestSuite.java | 78 +++++++++++++++++++ ...tpClientSslHandshakeBehaviorTestSuite.java | 21 ++--- 10 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SslHandshakeBehaviorTest.java create mode 100644 http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtAsyncSslHandshakeBehaviorTest.java create mode 100644 http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/NettySslHandshakeBehaviorTest.java create mode 100644 http-clients/url-connection-client/src/test/java/software/amazon/awssdk/http/urlconnection/UrlConnectionSslHandshakeBehaviorTest.java create mode 100644 test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientSslHandshakeBehaviorTestSuite.java diff --git a/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SslHandshakeBehaviorTest.java b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..cfa80a7491b9 --- /dev/null +++ b/http-clients/apache5-client/src/test/java/software/amazon/awssdk/http/apache5/Apache5SslHandshakeBehaviorTest.java @@ -0,0 +1,26 @@ +/* + * 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.http.apache5; + +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSslHandshakeBehaviorTestSuite; + +public class Apache5SslHandshakeBehaviorTest extends SdkHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkHttpClient createSdkHttpClient() { + return Apache5HttpClient.builder().build(); + } +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtAsyncSslHandshakeBehaviorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtAsyncSslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..2666ea96d406 --- /dev/null +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtAsyncSslHandshakeBehaviorTest.java @@ -0,0 +1,31 @@ +/* + * 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.http.crt; + +import java.time.Duration; +import software.amazon.awssdk.http.SdkAsyncHttpClientSslHandshakeBehaviorTestSuite; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; + +public class CrtAsyncSslHandshakeBehaviorTest extends SdkAsyncHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkAsyncHttpClient createSdkAsyncHttpClient() { + // CRT negotiates TLS during connection acquisition; the default 10s acquisition timeout can + // expire first and surface an acquisition-timeout exception instead of SSLHandshakeException. + return AwsCrtAsyncHttpClient.builder() + .connectionAcquisitionTimeout(Duration.ofSeconds(30)) + .build(); + } +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java index dca247db1bab..c797c227dcab 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/CrtSslHandshakeBehaviorTest.java @@ -22,8 +22,8 @@ public class CrtSslHandshakeBehaviorTest extends SdkHttpClientSslHandshakeBehaviorTestSuite { @Override protected SdkHttpClient createSdkHttpClient() { - // The default connectionAcquisitionTimeout is too aggressive for TLS negotiation failures, - // see AwsCrtHttpClientWireMockTest for the same accommodation. + // CRT negotiates TLS during connection acquisition; the default 10s acquisition timeout can + // expire first and surface an acquisition-timeout exception instead of SSLHandshakeException. return AwsCrtHttpClient.builder() .connectionAcquisitionTimeout(Duration.ofSeconds(30)) .build(); diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java index f37af1b28279..0c3c99267fdd 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtAsyncRequestExecutorTest.java @@ -185,6 +185,7 @@ public void execute_httpException_mapsToCorrectException(Entry executeFuture = requestExecutor.execute(context); assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass) + .hasCauseInstanceOf(IOException.class) .hasRootCause(exception); } diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java index 437c56f87495..b1374d050304 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtRequestExecutorTest.java @@ -135,6 +135,7 @@ public void execute_httpException_mapsToCorrectException(Entry executeFuture = requestExecutor.execute(context); assertThatThrownBy(executeFuture::join).hasCauseInstanceOf(expectedExceptionClass) + .hasCauseInstanceOf(IOException.class) .hasRootCause(exception); } diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java index 42869c5c64dc..c9c43f238941 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/internal/CrtUtilsTest.java @@ -75,9 +75,8 @@ public void wrapWithIoExceptionIfRetryable_nonRetryableCode_returnsHttpException } /** - * Locks in that TLS negotiation failures surface as {@link SSLHandshakeException} with the original - * {@link HttpException} (which carries the CRT error code) chained as the cause, so that callers can - * differentiate transient handshake interruptions from persistent certificate failures. + * TLS negotiation failures must chain the original {@link HttpException} (with its CRT error code) + * as cause, so callers can differentiate transient from persistent failures. */ @Test public void wrapCrtException_tlsNegotiationError_wrapsInSslHandshakeExceptionWithCause() { @@ -88,11 +87,12 @@ public void wrapCrtException_tlsNegotiationError_wrapsInSslHandshakeExceptionWit assertThat(result).isInstanceOf(SSLHandshakeException.class) .hasMessage(httpException.getMessage()) .hasCause(httpException); + assertThat(((HttpException) result.getCause()).getErrorCode()) + .isEqualTo(CrtUtils.CRT_TLS_NEGOTIATION_ERROR_CODE); } /** - * Locks in that socket timeouts during connection acquisition surface as {@link ConnectException} with - * the original {@link HttpException} chained as the cause. + * Socket timeouts must chain the original {@link HttpException} as cause. */ @Test public void wrapCrtException_socketTimeout_wrapsInConnectExceptionWithCause() { @@ -103,5 +103,7 @@ public void wrapCrtException_socketTimeout_wrapsInConnectExceptionWithCause() { assertThat(result).isInstanceOf(ConnectException.class) .hasMessage(httpException.getMessage()) .hasCause(httpException); + assertThat(((HttpException) result.getCause()).getErrorCode()) + .isEqualTo(CrtUtils.CRT_SOCKET_TIMEOUT); } } diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/NettySslHandshakeBehaviorTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/NettySslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..9140a6edf92f --- /dev/null +++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/NettySslHandshakeBehaviorTest.java @@ -0,0 +1,26 @@ +/* + * 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.http.nio.netty; + +import software.amazon.awssdk.http.SdkAsyncHttpClientSslHandshakeBehaviorTestSuite; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; + +public class NettySslHandshakeBehaviorTest extends SdkAsyncHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkAsyncHttpClient createSdkAsyncHttpClient() { + return NettyNioAsyncHttpClient.builder().build(); + } +} diff --git a/http-clients/url-connection-client/src/test/java/software/amazon/awssdk/http/urlconnection/UrlConnectionSslHandshakeBehaviorTest.java b/http-clients/url-connection-client/src/test/java/software/amazon/awssdk/http/urlconnection/UrlConnectionSslHandshakeBehaviorTest.java new file mode 100644 index 000000000000..13ee6c728f97 --- /dev/null +++ b/http-clients/url-connection-client/src/test/java/software/amazon/awssdk/http/urlconnection/UrlConnectionSslHandshakeBehaviorTest.java @@ -0,0 +1,26 @@ +/* + * 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.http.urlconnection; + +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientSslHandshakeBehaviorTestSuite; + +public class UrlConnectionSslHandshakeBehaviorTest extends SdkHttpClientSslHandshakeBehaviorTestSuite { + @Override + protected SdkHttpClient createSdkHttpClient() { + return UrlConnectionHttpClient.builder().build(); + } +} diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientSslHandshakeBehaviorTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientSslHandshakeBehaviorTestSuite.java new file mode 100644 index 000000000000..923ed5152069 --- /dev/null +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkAsyncHttpClientSslHandshakeBehaviorTestSuite.java @@ -0,0 +1,78 @@ +/* + * 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.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.util.concurrent.CompletionException; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; + +/** + * Validates that a TLS handshake failure against an untrusted (self-signed) certificate surfaces as + * {@link SSLHandshakeException} with a cause chain, so callers can differentiate transient + * interruptions from persistent certificate failures. + */ +public abstract class SdkAsyncHttpClientSslHandshakeBehaviorTestSuite { + private WireMockServer selfSignedServer; + + protected abstract SdkAsyncHttpClient createSdkAsyncHttpClient(); + + @BeforeEach + public void setup() { + selfSignedServer = HttpTestUtils.createSelfSignedServer(); + selfSignedServer.start(); + } + + @AfterEach + public void teardown() { + if (selfSignedServer != null) { + selfSignedServer.stop(); + selfSignedServer = null; + } + } + + @Test + public void sslHandshakeFailure_surfacesAsSslHandshakeException() { + Throwable thrown = executeRequestAgainstUntrustedServer(); + + assertThat(thrown).isInstanceOf(SSLHandshakeException.class); + } + + @Test + public void sslHandshakeFailure_exceptionCarriesDiagnosableCause() { + Throwable thrown = executeRequestAgainstUntrustedServer(); + + assertThat(thrown).isInstanceOf(SSLHandshakeException.class); + assertThat(thrown.getCause()) + .withFailMessage("SSLHandshakeException must chain the underlying failure as its cause " + + "so callers can differentiate transient from persistent TLS failures. Was: %s", thrown) + .isNotNull(); + } + + private Throwable executeRequestAgainstUntrustedServer() { + try (SdkAsyncHttpClient client = createSdkAsyncHttpClient()) { + Throwable thrown = catchThrowable( + () -> HttpTestUtils.sendGetRequest(selfSignedServer.httpsPort(), client, true).join()); + return thrown instanceof CompletionException ? thrown.getCause() : thrown; + } + } +} diff --git a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java index f817e173a954..23ab7d1c33c7 100644 --- a/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java +++ b/test/http-client-tests/src/main/java/software/amazon/awssdk/http/SdkHttpClientSslHandshakeBehaviorTestSuite.java @@ -28,22 +28,15 @@ import org.junit.jupiter.api.Test; /** - * A set of tests validating how an {@link SdkHttpClient} surfaces TLS handshake failures. - * - *

The server presents a self-signed certificate that is not trusted by the client, so every - * request fails during TLS negotiation. The suite asserts that the failure is surfaced as - * {@link SSLHandshakeException} and that the exception carries a diagnosable cause chain, so that - * callers (and retry predicates) can differentiate transient handshake interruptions from - * persistent certificate failures. - * - *

This is used by an HTTP plugin implementation by extending this class and implementing the - * abstract methods to provide this suite with a testable HTTP client implementation. + * Validates that a TLS handshake failure against an untrusted (self-signed) certificate surfaces as + * {@link SSLHandshakeException} with a cause chain, so callers can differentiate transient + * interruptions from persistent certificate failures. */ public abstract class SdkHttpClientSslHandshakeBehaviorTestSuite { private WireMockServer selfSignedServer; /** - * Implemented by a child class to create an HTTP client to validate, without any extra options. + * Implemented by a child class to create an HTTP client to validate */ protected abstract SdkHttpClient createSdkHttpClient(); @@ -74,10 +67,8 @@ public void sslHandshakeFailure_exceptionCarriesDiagnosableCause() { assertThat(thrown).isInstanceOf(SSLHandshakeException.class); assertThat(thrown.getCause()) - .withFailMessage("The SSLHandshakeException surfaced by the HTTP client must chain the underlying " - + "failure as its cause. Without a cause chain, callers cannot differentiate " - + "transient TLS handshake interruptions from persistent certificate failures. " - + "Surfaced exception: %s", thrown) + .withFailMessage("SSLHandshakeException must chain the underlying failure as its cause " + + "so callers can differentiate transient from persistent TLS failures. Was: %s", thrown) .isNotNull(); } From 7ec98b929fd8c6b3395c7a0f7bb7bdc670e1993c Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 17 Jul 2026 08:55:13 -0700 Subject: [PATCH 3/4] handle checkstyle failure --- .../software/amazon/awssdk/checkstyle-suppressions.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml index 4e81373b0be3..ca2a282124ba 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml @@ -72,4 +72,9 @@ + + + From 40d717496e8827f30cbca19c1211ff12a7730344 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 17 Jul 2026 10:43:35 -0700 Subject: [PATCH 4/4] handle PR comments --- .../software/amazon/awssdk/checkstyle-suppressions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml index ca2a282124ba..ff9c1056bec2 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/checkstyle-suppressions.xml @@ -76,5 +76,5 @@ + files=".*[\\/]http-client-tests[\\/]src[\\/]main[\\/]java[\\/].*TestSuite\.java$"/>