From 69a67a2355e4b1ede927d61da0355d6dc046ebc5 Mon Sep 17 00:00:00 2001 From: Zoe Wang <33073555+zoewangg@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:12:48 -0700 Subject: [PATCH] feat(aws-crt-client): add tlsNegotiationTimeout configuration Add tlsNegotiationTimeout(Duration) to AwsCrtAsyncHttpClient.Builder and AwsCrtHttpClient.Builder, mirroring the Netty client. Configures the maximum TLS handshake duration (CLIENT HELLO through key exchange). Defaults to 10 seconds, matching the underlying CRT runtime's native default (AWS_DEFAULT_TLS_TIMEOUT_MS). --- ...re-AWSCommonRuntimeHTTPClient-a093e97.json | 6 + .../http/crt/AwsCrtAsyncHttpClient.java | 13 + .../awssdk/http/crt/AwsCrtHttpClient.java | 13 + .../awssdk/http/crt/AwsCrtHttpClientBase.java | 26 +- .../crt/internal/AwsCrtClientBuilderBase.java | 10 + .../internal/AwsCrtConfigurationUtils.java | 10 + .../http/crt/AwsCrtAsyncHttpClientTest.java | 69 +++++ .../AwsCrtAsyncHttpClientWireMockTest.java | 30 ++- .../awssdk/http/crt/AwsCrtHttpClientTest.java | 73 +++--- .../http/crt/AwsCrtHttpClientTestBase.java | 58 +++++ .../crt/AwsCrtHttpClientWireMockTest.java | 52 +++- .../crt/AwsCrtTlsHandshakeTimeoutTest.java | 237 ++++++++++++++++++ .../awssdk/http/crt/H2BehaviorTest.java | 15 ++ pom.xml | 2 +- 14 files changed, 569 insertions(+), 45 deletions(-) create mode 100644 .changes/next-release/feature-AWSCommonRuntimeHTTPClient-a093e97.json create mode 100644 http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientTest.java create mode 100644 http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTestBase.java create mode 100644 http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtTlsHandshakeTimeoutTest.java diff --git a/.changes/next-release/feature-AWSCommonRuntimeHTTPClient-a093e97.json b/.changes/next-release/feature-AWSCommonRuntimeHTTPClient-a093e97.json new file mode 100644 index 000000000000..75fddca29def --- /dev/null +++ b/.changes/next-release/feature-AWSCommonRuntimeHTTPClient-a093e97.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS Common Runtime HTTP Client", + "contributor": "", + "description": "Added tlsNegotiationTimeout(Duration) configuration to AwsCrtAsyncHttpClient and AwsCrtHttpClient builders, mirroring the option on the Netty client. Configures the maximum amount of time a TLS handshake may take, from CLIENT HELLO through key exchange. Defaults to 10 seconds, matching the underlying CRT runtime's native default." +} diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClient.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClient.java index a5d523d2fbe8..2fc39ac773e5 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClient.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClient.java @@ -196,6 +196,17 @@ AwsCrtAsyncHttpClient.Builder connectionHealthConfiguration(ConsumerBy default, it's 10 seconds. + * + * @param tlsNegotiationTimeout the timeout duration; must be positive + * @return this builder for method chaining. + */ + AwsCrtAsyncHttpClient.Builder tlsNegotiationTimeout(Duration tlsNegotiationTimeout); + /** * Configure whether to enable {@code tcpKeepAlive} and relevant configuration for all connections established by this * client. @@ -269,6 +280,7 @@ public Builder protocol(Protocol protocol) { @Override public SdkAsyncHttpClient build() { return new AwsCrtAsyncHttpClient(this, getAttributeMap().build() + .merge(AwsCrtHttpClientBase.AWS_CRT_HTTP_DEFAULTS) .merge(SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS)); } @@ -276,6 +288,7 @@ public SdkAsyncHttpClient build() { public SdkAsyncHttpClient buildWithDefaults(AttributeMap serviceDefaults) { return new AwsCrtAsyncHttpClient(this, getAttributeMap().build() .merge(serviceDefaults) + .merge(AwsCrtHttpClientBase.AWS_CRT_HTTP_DEFAULTS) .merge(SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS)); } diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClient.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClient.java index 9c6d769e48fa..da6066099f5f 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClient.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClient.java @@ -248,6 +248,17 @@ AwsCrtHttpClient.Builder connectionHealthConfiguration(ConsumerBy default, it's 10 seconds. + * + * @param tlsNegotiationTimeout the timeout duration; must be positive + * @return this builder for method chaining. + */ + AwsCrtHttpClient.Builder tlsNegotiationTimeout(Duration tlsNegotiationTimeout); + /** * Configure whether to enable {@code tcpKeepAlive} and relevant configuration for all connections established by this * client. @@ -307,6 +318,7 @@ private static final class DefaultBuilder @Override public AwsCrtHttpClient build() { return new AwsCrtHttpClient(this, getAttributeMap().build() + .merge(AwsCrtHttpClientBase.AWS_CRT_HTTP_DEFAULTS) .merge(SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS)); } @@ -314,6 +326,7 @@ public AwsCrtHttpClient build() { public AwsCrtHttpClient buildWithDefaults(AttributeMap serviceDefaults) { return new AwsCrtHttpClient(this, getAttributeMap().build() .merge(serviceDefaults) + .merge(AwsCrtHttpClientBase.AWS_CRT_HTTP_DEFAULTS) .merge(SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS)); } } diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientBase.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientBase.java index 50689d2236d5..6e61e60b4ce1 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientBase.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientBase.java @@ -19,14 +19,17 @@ import static software.amazon.awssdk.crtcore.CrtConfigurationUtils.resolveProxy; import static software.amazon.awssdk.http.SdkHttpConfigurationOption.PROTOCOL; import static software.amazon.awssdk.http.crt.internal.AwsCrtConfigurationUtils.buildSocketOptions; +import static software.amazon.awssdk.http.crt.internal.AwsCrtConfigurationUtils.buildTlsConnectionOptions; import static software.amazon.awssdk.http.crt.internal.AwsCrtConfigurationUtils.resolveCipherPreference; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; import java.net.URI; +import java.time.Duration; import java.util.LinkedList; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import software.amazon.awssdk.annotations.SdkProtectedApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; import software.amazon.awssdk.crt.CrtResource; import software.amazon.awssdk.crt.http.Http2StreamManagerOptions; import software.amazon.awssdk.crt.http.HttpClientConnectionManagerOptions; @@ -37,6 +40,7 @@ import software.amazon.awssdk.crt.http.HttpVersion; import software.amazon.awssdk.crt.io.ClientBootstrap; import software.amazon.awssdk.crt.io.SocketOptions; +import software.amazon.awssdk.crt.io.TlsConnectionOptions; import software.amazon.awssdk.crt.io.TlsContext; import software.amazon.awssdk.crt.io.TlsContextOptions; import software.amazon.awssdk.http.Protocol; @@ -54,6 +58,14 @@ */ @SdkProtectedApi abstract class AwsCrtHttpClientBase implements SdkAutoCloseable { + // TLS_NEGOTIATION_TIMEOUT diverges from the SDK global default (5s) for backwards compatibility: + // the underlying CRT has always applied a 10s handshake timeout, so adopting the 5s global would silently tighten the + // effective handshake timeout for existing CRT customers. + static final AttributeMap AWS_CRT_HTTP_DEFAULTS = + AttributeMap.builder() + .put(SdkHttpConfigurationOption.TLS_NEGOTIATION_TIMEOUT, Duration.ofSeconds(10)) + .build(); + private static final Logger log = Logger.loggerFor(AwsCrtHttpClientBase.class); private static final String AWS_COMMON_RUNTIME = "AwsCommonRuntime"; @@ -66,12 +78,14 @@ abstract class AwsCrtHttpClientBase implements SdkAutoCloseable { private final ClientBootstrap bootstrap; private final SocketOptions socketOptions; private final TlsContext tlsContext; + private final TlsConnectionOptions tlsConnectionOptions; private final HttpProxyOptions proxyOptions; private final HttpMonitoringOptions monitoringOptions; private final long maxConnectionIdleInMilliseconds; private final int maxStreamsPerEndpoint; private final long connectionAcquisitionTimeout; private final TlsContextOptions tlsContextOptions; + private final Duration tlsNegotiationTimeout; private boolean isClosed = false; AwsCrtHttpClientBase(AwsCrtClientBuilderBase builder, AttributeMap config) { @@ -93,6 +107,9 @@ abstract class AwsCrtHttpClientBase implements SdkAutoCloseable { this.bootstrap = registerOwnedResource(clientBootstrap); this.socketOptions = registerOwnedResource(clientSocketOptions); this.tlsContext = registerOwnedResource(clientTlsContext); + this.tlsNegotiationTimeout = config.get(SdkHttpConfigurationOption.TLS_NEGOTIATION_TIMEOUT); + this.tlsConnectionOptions = registerOwnedResource( + buildTlsConnectionOptions(clientTlsContext, tlsNegotiationTimeout)); this.readBufferSize = builder.getReadBufferSizeInBytes() == null ? DEFAULT_STREAM_WINDOW_SIZE : builder.getReadBufferSizeInBytes(); this.maxStreamsPerEndpoint = config.get(SdkHttpConfigurationOption.MAX_CONNECTIONS); @@ -122,18 +139,23 @@ String clientName() { return AWS_COMMON_RUNTIME; } + @SdkTestInternalApi + Duration resolvedTlsNegotiationTimeout() { + return tlsNegotiationTimeout; + } + private HttpStreamManager createConnectionPool(URI uri) { log.debug(() -> String.format("Creating ConnectionPool for: URI:%s, MaxConns: %d, MaxStreams: %d", uri, maxStreamsPerEndpoint, maxStreamsPerEndpoint)); boolean isHttps = "https".equalsIgnoreCase(uri.getScheme()); - TlsContext poolTlsContext = isHttps ? tlsContext : null; + TlsConnectionOptions poolTlsConnectionOptions = isHttps ? tlsConnectionOptions : null; HttpClientConnectionManagerOptions h1Options = new HttpClientConnectionManagerOptions() .withClientBootstrap(bootstrap) .withSocketOptions(socketOptions) - .withTlsContext(poolTlsContext) + .withTlsConnectionOptions(poolTlsConnectionOptions) .withUri(uri) .withWindowSize(readBufferSize) .withMaxConnections(maxStreamsPerEndpoint) diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtClientBuilderBase.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtClientBuilderBase.java index b459c7eddb1b..f827cb01b8e8 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtClientBuilderBase.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtClientBuilderBase.java @@ -106,6 +106,16 @@ public BuilderT connectionAcquisitionTimeout(Duration connectionAcquisitionTimeo return thisBuilder(); } + public BuilderT tlsNegotiationTimeout(Duration tlsNegotiationTimeout) { + Validate.isPositive(tlsNegotiationTimeout, "tlsNegotiationTimeout"); + standardOptions.put(SdkHttpConfigurationOption.TLS_NEGOTIATION_TIMEOUT, tlsNegotiationTimeout); + return thisBuilder(); + } + + public void setTlsNegotiationTimeout(Duration tlsNegotiationTimeout) { + tlsNegotiationTimeout(tlsNegotiationTimeout); + } + public BuilderT tcpKeepAliveConfiguration(TcpKeepAliveConfiguration tcpKeepAliveConfiguration) { this.tcpKeepAliveConfiguration = tcpKeepAliveConfiguration; return thisBuilder(); diff --git a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtConfigurationUtils.java b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtConfigurationUtils.java index 0c48ff65b8f0..e4478edab603 100644 --- a/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtConfigurationUtils.java +++ b/http-clients/aws-crt-client/src/main/java/software/amazon/awssdk/http/crt/internal/AwsCrtConfigurationUtils.java @@ -20,6 +20,8 @@ import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.crt.io.SocketOptions; import software.amazon.awssdk.crt.io.TlsCipherPreference; +import software.amazon.awssdk.crt.io.TlsConnectionOptions; +import software.amazon.awssdk.crt.io.TlsContext; import software.amazon.awssdk.http.crt.TcpKeepAliveConfiguration; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.NumericUtils; @@ -53,6 +55,14 @@ public static SocketOptions buildSocketOptions(TcpKeepAliveConfiguration tcpKeep return clientSocketOptions; } + public static TlsConnectionOptions buildTlsConnectionOptions(TlsContext tlsContext, Duration tlsNegotiationTimeout) { + TlsConnectionOptions tlsConnectionOptions = new TlsConnectionOptions(tlsContext); + if (tlsNegotiationTimeout != null) { + tlsConnectionOptions.withTimeoutMs(NumericUtils.saturatedCast(tlsNegotiationTimeout.toMillis())); + } + return tlsConnectionOptions; + } + public static TlsCipherPreference resolveCipherPreference(Boolean postQuantumTlsEnabled) { // As of v0.39.3, aws-crt-java prefers PQ by default, so only return the non-PQ-default policy // below if the caller explicitly disables PQ by passing in false. diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientTest.java new file mode 100644 index 000000000000..f50f5a3aeac4 --- /dev/null +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientTest.java @@ -0,0 +1,69 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.utils.AttributeMap; + +class AwsCrtAsyncHttpClientTest extends AwsCrtHttpClientTestBase { + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidTlsNegotiationTimeouts") + void tlsNegotiationTimeout_invalidDuration_shouldThrowException(String description, Duration input, + String expectedMessageFragment) { + assertThatThrownBy(() -> AwsCrtAsyncHttpClient.builder().tlsNegotiationTimeout(input).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(expectedMessageFragment); + } + + @ParameterizedTest(name = "[async] {0}") + @MethodSource("resolutionMatrix") + void asyncBuilder_resolvedTlsNegotiationTimeout_matchesPathBPrecedence(String description, Duration customer, + Duration serviceDefault, Duration expected) { + AwsCrtAsyncHttpClient.Builder builder = AwsCrtAsyncHttpClient.builder(); + if (customer != null) { + builder.tlsNegotiationTimeout(customer); + } + + try (SdkAsyncHttpClient client = buildAsync(builder, serviceDefault)) { + assertThat(((AwsCrtAsyncHttpClient) client).resolvedTlsNegotiationTimeout()).isEqualTo(expected); + } + } + + @Test + void asyncBuilder_buildWithDefaults_serviceDefaultsLacksTlsNegotiationTimeout_resolvesToCrtDefault10s() { + try (SdkAsyncHttpClient client = AwsCrtAsyncHttpClient.builder().buildWithDefaults(AttributeMap.empty())) { + assertThat(((AwsCrtAsyncHttpClient) client).resolvedTlsNegotiationTimeout()).isEqualTo(CRT_DEFAULT); + } + } + + private static SdkAsyncHttpClient buildAsync(AwsCrtAsyncHttpClient.Builder builder, Duration serviceDefault) { + return serviceDefault == null + ? builder.build() + : builder.buildWithDefaults(serviceDefaultsMap(serviceDefault)); + } + +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientWireMockTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientWireMockTest.java index 2efa2e56e9f2..4990a9fab879 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientWireMockTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtAsyncHttpClientWireMockTest.java @@ -29,6 +29,7 @@ import com.github.tomakehurst.wiremock.junit.WireMockRule; import java.net.URI; +import java.time.Duration; import java.util.concurrent.TimeUnit; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -39,6 +40,7 @@ import software.amazon.awssdk.http.HttpMetric; import software.amazon.awssdk.http.Protocol; import software.amazon.awssdk.http.RecordingResponseHandler; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.http.async.AsyncExecuteRequest; import software.amazon.awssdk.http.async.SdkAsyncHttpClient; @@ -48,7 +50,8 @@ public class AwsCrtAsyncHttpClientWireMockTest { @Rule public WireMockRule mockServer = new WireMockRule(wireMockConfig() - .dynamicPort()); + .dynamicPort() + .dynamicHttpsPort()); @BeforeClass public static void setup() { @@ -90,6 +93,31 @@ public void sharedEventLoopGroup_closeOneClient_shouldNotAffectOtherClients() th } } + @Test + public void tlsNegotiationTimeout_customValue_clientStartsSuccessfully() throws Exception { + AttributeMap defaults = AttributeMap.builder().put(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES, true).build(); + try (SdkAsyncHttpClient client = AwsCrtAsyncHttpClient.builder() + .tlsNegotiationTimeout(Duration.ofSeconds(3)) + .buildWithDefaults(defaults)) { + makeSimpleHttpsRequest(client); + } + } + + private RecordingResponseHandler makeSimpleHttpsRequest(SdkAsyncHttpClient client) throws Exception { + String body = randomAlphabetic(10); + URI uri = URI.create("https://localhost:" + mockServer.httpsPort()); + stubFor(any(urlPathEqualTo("/")).willReturn(aResponse().withBody(body))); + SdkHttpRequest request = createRequest(uri); + RecordingResponseHandler recorder = new RecordingResponseHandler(); + client.execute(AsyncExecuteRequest.builder() + .request(request) + .requestContentPublisher(createProvider("")) + .responseHandler(recorder) + .build()); + recorder.completeFuture().get(5, TimeUnit.SECONDS); + return recorder; + } + /** * Make a simple async request and wait for it to finish. * diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTest.java index d4284e640555..fe497a043a7d 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTest.java @@ -15,42 +15,18 @@ package software.amazon.awssdk.http.crt; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES; - import java.time.Duration; -import org.junit.Test; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; - -import software.amazon.awssdk.crt.CrtResource; -import software.amazon.awssdk.crt.Log; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import software.amazon.awssdk.http.SdkHttpClient; -import software.amazon.awssdk.http.SdkHttpClientTestSuite; import software.amazon.awssdk.utils.AttributeMap; -/** - * Testing the scenario where h1 server sends 5xx errors. - */ -public class AwsCrtHttpClientTest extends SdkHttpClientTestSuite { - @BeforeAll - public static void beforeAll() { - System.setProperty("aws.crt.debugnative", "true"); - Log.initLoggingToStdout(Log.LogLevel.Warn); - } - - /** - * default value of connectionAcquisitionTimeout of 10 will fail validatesHttpsCertificateIssuer() test - * */ - @Override - protected SdkHttpClient createSdkHttpClient(SdkHttpClientOptions options) { - boolean trustAllCerts = options.trustAll(); - return AwsCrtHttpClient.builder() - .connectionAcquisitionTimeout(Duration.ofSeconds(40)) - .buildWithDefaults(AttributeMap.builder().put(TRUST_ALL_CERTIFICATES, trustAllCerts).build()); - } +public class AwsCrtHttpClientTest extends AwsCrtHttpClientTestBase { @Test public void negativeConnectionAcquisitionTimeout_shouldFail() { @@ -62,13 +38,40 @@ public void negativeConnectionAcquisitionTimeout_shouldFail() { }).hasMessage("connectionAcquisitionTimeout must be positive"); } - // Empty test; behavior not supported when using custom factory - @Override - public void testCustomTlsTrustManagerAndTrustAllFails() { + @ParameterizedTest(name = "{0}") + @MethodSource("invalidTlsNegotiationTimeouts") + void tlsNegotiationTimeout_invalidDuration_shouldThrowException(String description, Duration input, + String expectedMessageFragment) { + assertThatThrownBy(() -> AwsCrtAsyncHttpClient.builder().tlsNegotiationTimeout(input).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(expectedMessageFragment); + } + + @Test + void syncBuilder_buildWithDefaults_serviceDefaultsLacksTlsNegotiationTimeout_resolvesToCrtDefault10s() { + try (SdkHttpClient client = AwsCrtHttpClient.builder().buildWithDefaults(AttributeMap.empty())) { + assertThat(((AwsCrtHttpClient) client).resolvedTlsNegotiationTimeout()).isEqualTo(CRT_DEFAULT); + } + } + + + @ParameterizedTest(name = "[sync] {0}") + @MethodSource("resolutionMatrix") + void syncBuilder_resolvedTlsNegotiationTimeout_matchesPathBPrecedence(String description, Duration customer, + Duration serviceDefault, Duration expected) { + AwsCrtHttpClient.Builder builder = AwsCrtHttpClient.builder(); + if (customer != null) { + builder.tlsNegotiationTimeout(customer); + } + + try (SdkHttpClient client = buildSync(builder, serviceDefault)) { + assertThat(((AwsCrtHttpClient) client).resolvedTlsNegotiationTimeout()).isEqualTo(expected); + } } - // Empty test; behavior not supported when using custom factory - @Override - public void testCustomTlsTrustManager() { + private static SdkHttpClient buildSync(AwsCrtHttpClient.Builder builder, Duration serviceDefault) { + return serviceDefault == null + ? builder.build() + : builder.buildWithDefaults(serviceDefaultsMap(serviceDefault)); } } diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTestBase.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTestBase.java new file mode 100644 index 000000000000..b3daf18578a8 --- /dev/null +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientTestBase.java @@ -0,0 +1,58 @@ +/* + * 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 java.util.stream.Stream; +import org.junit.jupiter.params.provider.Arguments; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.utils.AttributeMap; + + +public class AwsCrtHttpClientTestBase { + static final Duration CRT_DEFAULT = Duration.ofSeconds(10); + static final Duration CUSTOMER = Duration.ofSeconds(3); + static final Duration SERVICE_DEFAULT = Duration.ofSeconds(7); + + static Stream invalidTlsNegotiationTimeouts() { + return Stream.of( + Arguments.of("null duration -> rejected by paramNotNull", null, "tlsNegotiationTimeout"), + Arguments.of("zero duration -> rejected by isPositive", Duration.ZERO, "must be positive"), + Arguments.of("negative duration -> rejected by isPositive", Duration.ofSeconds(-1), "must be positive") + ); + } + + + static Stream resolutionMatrix() { + return Stream.of( + Arguments.of("customer unset, no service default -> CRT default (10s) beats GLOBAL (5s)", + null, null, CRT_DEFAULT), + Arguments.of("customer unset, service default 7s -> service default beats CRT default", + null, SERVICE_DEFAULT, SERVICE_DEFAULT), + Arguments.of("customer set 3s, no service default -> customer wins", + CUSTOMER, null, CUSTOMER), + Arguments.of("customer set 3s, service default 7s -> customer beats service default", + CUSTOMER, SERVICE_DEFAULT, CUSTOMER) + ); + } + + + static AttributeMap serviceDefaultsMap(Duration tlsNegotiationTimeout) { + return AttributeMap.builder() + .put(SdkHttpConfigurationOption.TLS_NEGOTIATION_TIMEOUT, tlsNegotiationTimeout) + .build(); + } +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientWireMockTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientWireMockTest.java index ce5d778f06a1..0678d2b9039a 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientWireMockTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtHttpClientWireMockTest.java @@ -24,20 +24,20 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static software.amazon.awssdk.http.SdkHttpConfigurationOption.PROTOCOL; +import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES; import static software.amazon.awssdk.http.crt.CrtHttpClientTestUtils.createRequest; import com.github.tomakehurst.wiremock.junit.WireMockRule; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.junit.AfterClass; import org.junit.BeforeClass; -import org.junit.Rule; import org.junit.Test; -import software.amazon.awssdk.crt.CrtResource; import software.amazon.awssdk.crt.Log; import software.amazon.awssdk.http.ExecutableHttpRequest; import software.amazon.awssdk.http.HttpExecuteRequest; @@ -45,15 +45,14 @@ import software.amazon.awssdk.http.HttpMetric; import software.amazon.awssdk.http.Protocol; import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpClientTestSuite; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.metrics.MetricCollection; import software.amazon.awssdk.metrics.MetricCollector; import software.amazon.awssdk.utils.AttributeMap; -public class AwsCrtHttpClientWireMockTest { - @Rule - public WireMockRule mockServer = new WireMockRule(wireMockConfig() - .dynamicPort()); +public class AwsCrtHttpClientWireMockTest extends SdkHttpClientTestSuite { private static ScheduledExecutorService executorService; @@ -113,6 +112,26 @@ public void sharedEventLoopGroup_closeOneClient_shouldNotAffectOtherClients() th } } + @Test + public void tlsNegotiationTimeout_customValue_clientStartsSuccessfully() throws Exception { + AttributeMap defaults = AttributeMap.builder().put(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES, true).build(); + try (SdkHttpClient client = AwsCrtHttpClient.builder() + .tlsNegotiationTimeout(Duration.ofSeconds(3)) + .buildWithDefaults(defaults)) { + String body = randomAlphabetic(10); + URI uri = URI.create("https://localhost:" + mockServer.httpsPort()); + stubFor(any(urlPathEqualTo("/")).willReturn(aResponse().withBody(body))); + SdkHttpRequest request = createRequest(uri); + + HttpExecuteRequest.Builder executeRequestBuilder = HttpExecuteRequest.builder(); + executeRequestBuilder.request(request) + .contentStreamProvider(() -> new ByteArrayInputStream(new byte[0])); + ExecutableHttpRequest executableRequest = client.prepareRequest(executeRequestBuilder.build()); + HttpExecuteResponse response = executableRequest.call(); + assertThat(response.httpResponse().statusCode()).isEqualTo(200); + } + } + @Test public void abortRequest_shouldFailTheExceptionWithIOException() throws Exception { try (SdkHttpClient client = AwsCrtHttpClient.create()) { @@ -151,4 +170,25 @@ private HttpExecuteResponse makeSimpleRequest(SdkHttpClient client, MetricCollec ExecutableHttpRequest executableRequest = client.prepareRequest(executeRequestBuilder.build()); return executableRequest.call(); } + + /** + * default value of connectionAcquisitionTimeout of 10 will fail validatesHttpsCertificateIssuer() test + * */ + @Override + protected SdkHttpClient createSdkHttpClient(SdkHttpClientOptions options) { + boolean trustAllCerts = options.trustAll(); + return AwsCrtHttpClient.builder() + .connectionAcquisitionTimeout(Duration.ofSeconds(40)) + .buildWithDefaults(AttributeMap.builder().put(TRUST_ALL_CERTIFICATES, trustAllCerts).build()); + } + + // Empty test; behavior not supported when using custom factory + @Override + public void testCustomTlsTrustManagerAndTrustAllFails() { + } + + // Empty test; behavior not supported when using custom factory + @Override + public void testCustomTlsTrustManager() { + } } diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtTlsHandshakeTimeoutTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtTlsHandshakeTimeoutTest.java new file mode 100644 index 000000000000..0dc33938e43a --- /dev/null +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/AwsCrtTlsHandshakeTimeoutTest.java @@ -0,0 +1,237 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.http.HttpTestUtils.createProvider; +import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES; +import static software.amazon.awssdk.http.crt.CrtHttpClientTestUtils.createRequest; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +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 software.amazon.awssdk.crt.Log; +import software.amazon.awssdk.crt.http.HttpException; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.RecordingResponseHandler; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.async.AsyncExecuteRequest; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.utils.AttributeMap; + +/** + * Functional test that exercises the CRT runtime's TLS-handshake-timeout machinery end-to-end. Stands up a raw + * {@link ServerSocket} (NOT an {@code SSLServerSocket}) on loopback that accepts the TCP connection, drains a + * little of the ClientHello into a discard buffer, and never writes a ServerHello. The CRT TLS-negotiation timer + * fires before the connectionTimeout, completing the request future exceptionally. + */ +class AwsCrtTlsHandshakeTimeoutTest { + + private static final Duration CONNECTION_TIMEOUT = Duration.ofSeconds(15); + private static final Duration ASSERTION_UPPER_BOUND = Duration.ofSeconds(10); + + private TlsStallingServer stallingServer; + + @BeforeAll + static void beforeAll() { + System.setProperty("aws.crt.debugnative", "true"); + Log.initLoggingToStdout(Log.LogLevel.Warn); + } + + @BeforeEach + void setUp() throws IOException { + stallingServer = new TlsStallingServer(); + stallingServer.start(); + } + + @AfterEach + void tearDown() { + if (stallingServer != null) { + stallingServer.stop(); + } + } + + @Test + void asyncClient_serverWithholdsServerHello_failsWithTlsNegotiationTimeoutDrivenByConfiguredValue() throws Exception { + Duration configuredTimeout = Duration.ofSeconds(3); + Duration elapsedFloor = Duration.ofMillis(1500); + + AttributeMap defaults = AttributeMap.builder().put(TRUST_ALL_CERTIFICATES, true).build(); + try (SdkAsyncHttpClient client = AwsCrtAsyncHttpClient.builder() + .tlsNegotiationTimeout(configuredTimeout) + .connectionTimeout(CONNECTION_TIMEOUT) + .buildWithDefaults(defaults)) { + + URI uri = URI.create("https://localhost:" + stallingServer.port()); + SdkHttpRequest request = createRequest(uri); + RecordingResponseHandler recorder = new RecordingResponseHandler(); + + long start = System.nanoTime(); + client.execute(AsyncExecuteRequest.builder() + .request(request) + .requestContentPublisher(createProvider("")) + .responseHandler(recorder) + .build()); + + assertCompletedWithTlsNegotiationTimeout(recorder.completeFuture(), ASSERTION_UPPER_BOUND); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + assertThat(elapsed).as("configured timeout %s should drive the deadline (elapsed=%s)", configuredTimeout, elapsed) + .isGreaterThanOrEqualTo(elapsedFloor); + } + } + + @Test + void syncClient_serverWithholdsServerHello_failsWithTlsNegotiationTimeoutDrivenByConfiguredValue() { + Duration configuredTimeout = Duration.ofSeconds(3); + Duration elapsedFloor = Duration.ofMillis(1500); + + AttributeMap defaults = AttributeMap.builder().put(TRUST_ALL_CERTIFICATES, true).build(); + try (SdkHttpClient client = AwsCrtHttpClient.builder() + .tlsNegotiationTimeout(configuredTimeout) + .connectionTimeout(CONNECTION_TIMEOUT) + .buildWithDefaults(defaults)) { + + URI uri = URI.create("https://localhost:" + stallingServer.port()); + SdkHttpRequest request = createRequest(uri); + HttpExecuteRequest httpExecuteRequest = HttpExecuteRequest.builder() + .request(request) + .contentStreamProvider(() -> new ByteArrayInputStream(new byte[0])) + .build(); + ExecutableHttpRequest executableRequest = client.prepareRequest(httpExecuteRequest); + + long start = System.nanoTime(); + assertThatThrownBy(executableRequest::call) + .isInstanceOf(IOException.class) + .hasCauseInstanceOf(HttpException.class) + .hasMessageContaining("tls negotiation timeout"); + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + assertThat(elapsed).as("configured timeout %s should drive the deadline (elapsed=%s)", configuredTimeout, elapsed) + .isBetween(elapsedFloor, ASSERTION_UPPER_BOUND); + } + } + + private static void assertCompletedWithTlsNegotiationTimeout(CompletableFuture future, Duration upperBound) throws Exception { + try { + future.get(upperBound.toMillis(), TimeUnit.MILLISECONDS); + throw new AssertionError("Expected TLS-negotiation-timeout failure but the future completed successfully"); + } catch (TimeoutException e) { + future.cancel(true); + throw new AssertionError("Future did not complete within " + upperBound + " - the TLS-handshake-timeout timer " + + "did not fire (or the SDK did not surface it).", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + assertThat(cause).isInstanceOf(IOException.class); + assertThat(cause).hasCauseInstanceOf(HttpException.class); + assertThat(cause).hasMessageContaining("tls negotiation timeout"); + } + } + + /** + * Raw {@link ServerSocket} on loopback that accepts TCP connections and never completes the TLS handshake. + * For each accepted client, a short-lived reader drains up to one buffer's worth of bytes (typically the + * ClientHello) so the kernel send buffer doesn't back-pressure the client into a write block, then the socket + * is held open until {@link #stop()} closes the listener. The client's TLS handshake timer fires while waiting + * for a ServerHello that never arrives. + */ + private static final class TlsStallingServer { + private ServerSocket serverSocket; + private Thread listenerThread; + private volatile boolean running; + + void start() throws IOException { + serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + running = true; + listenerThread = new Thread(this::acceptLoop, "AwsCrtTlsHandshakeTimeoutTest-StallingServer"); + listenerThread.setDaemon(true); + listenerThread.start(); + } + + int port() { + return serverSocket.getLocalPort(); + } + + void stop() { + running = false; + try { + serverSocket.close(); + } catch (IOException ignored) { + // best-effort + } + if (listenerThread != null) { + listenerThread.interrupt(); + try { + listenerThread.join(TimeUnit.SECONDS.toMillis(2)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private void acceptLoop() { + while (running && !Thread.currentThread().isInterrupted()) { + Socket client; + try { + client = serverSocket.accept(); + } catch (IOException e) { + return; + } + Thread worker = new Thread(() -> drainAndStall(client), + "AwsCrtTlsHandshakeTimeoutTest-StallingClient"); + worker.setDaemon(true); + worker.start(); + } + } + + private void drainAndStall(Socket client) { + try (InputStream in = client.getInputStream()) { + byte[] discard = new byte[4096]; + client.setSoTimeout(200); + try { + in.read(discard); + } catch (IOException ignored) { + // expected: read times out or the client side closes when the TLS-negotiation timer fires. + } + while (running) { + Thread.sleep(50); + } + } catch (IOException | InterruptedException ignored) { + // teardown path + } finally { + try { + client.close(); + } catch (IOException ignored) { + // best-effort + } + } + } + } +} diff --git a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/H2BehaviorTest.java b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/H2BehaviorTest.java index 8a8ec2415982..6e3ba8d56559 100644 --- a/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/H2BehaviorTest.java +++ b/http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/H2BehaviorTest.java @@ -49,6 +49,7 @@ import io.netty.handler.ssl.SslProvider; import io.netty.handler.ssl.SupportedCipherSuiteFilter; import io.netty.handler.ssl.util.SelfSignedCertificate; +import java.time.Duration; import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -85,12 +86,26 @@ public void teardown() throws InterruptedException { crt = null; } + @Test public void sendH2Request_overTls() throws Exception { CompletableFuture request = sendGetRequest(server.port(), crt); request.join(); } + @Test + public void buildH2HttpsClient_withCustomTlsNegotiationTimeout_doesNotNpeAndRequestSucceeds() throws Exception { + try (SdkAsyncHttpClient h2Client = AwsCrtAsyncHttpClient.builder() + .tlsNegotiationTimeout(Duration.ofSeconds(3)) + .buildWithDefaults(AttributeMap.builder() + .put(TRUST_ALL_CERTIFICATES, true) + .put(PROTOCOL, Protocol.HTTP2) + .build())) { + CompletableFuture request = sendGetRequest(server.port(), h2Client); + request.join(); + } + } + @Test public void sendH2Request_overPlaintext_usesPriorKnowledge() throws Exception { H2Server h2cServer = new H2Server(false); diff --git a/pom.xml b/pom.xml index 6e77468114f2..377a875152c6 100644 --- a/pom.xml +++ b/pom.xml @@ -131,7 +131,7 @@ 3.1.5 1.17.1 1.37 - 0.46.1 + 0.47.2 5.10.3