Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changes/next-release/bugfix-AWSCRTHTTPClient-f299170.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "bugfix",
"category": "AWS CRT HTTP Client",
"contributor": "",
"description": "Enabled default connection health monitoring for the AWS CRT HTTP client. Connections that remain stalled below 1 byte per second for the duration the read/write timeout (default 30 seconds) are now automatically terminated. This behavior can be overridden via ConnectionHealthConfiguration."
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ public interface Builder extends SdkAsyncHttpClient.Builder<AwsCrtAsyncHttpClien
* then the connection is considered unhealthy and will be shut down.
*
* <p>
* By default, monitoring options are disabled. You can enable {@code healthChecks} by providing this configuration
* and specifying the options for monitoring for the connection manager.
* If not explicitly configured, a default health configuration is applied with a minimum throughput of 1 byte per
* second and a throughput failure interval of 30 seconds. The failure interval is derived from the read/write timeout
* settings and will change if those are overridden by service specific defaults.
*
* @param healthChecksConfiguration The health checks config to use
* @return The builder of the method chaining.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,10 @@ public interface Builder extends SdkHttpClient.Builder<AwsCrtHttpClient.Builder>
* then the connection is considered unhealthy and will be shut down.
*
* <p>
* By default, monitoring options are disabled. You can enable {@code healthChecks} by providing this configuration
* and specifying the options for monitoring for the connection manager.
* If not explicitly configured, a default health configuration is applied with a minimum throughput of 1 byte per
* second and a throughput failure interval of 30 seconds. The failure interval is derived from the read/write timeout
* settings and will change if those are overridden by service specific defaults.
*
* @param healthChecksConfiguration The health checks config to use
* @return The builder of the method chaining.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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.defaultConnectionHealthConfiguration;
import static software.amazon.awssdk.http.crt.internal.AwsCrtConfigurationUtils.resolveCipherPreference;
import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely;

Expand Down Expand Up @@ -90,7 +91,9 @@ abstract class AwsCrtHttpClientBase implements SdkAutoCloseable {
this.readBufferSize = builder.getReadBufferSizeInBytes() == null ?
DEFAULT_STREAM_WINDOW_SIZE : builder.getReadBufferSizeInBytes();
this.maxConnectionsPerEndpoint = config.get(SdkHttpConfigurationOption.MAX_CONNECTIONS);
this.monitoringOptions = resolveHttpMonitoringOptions(builder.getConnectionHealthConfiguration()).orElse(null);
this.monitoringOptions =
resolveHttpMonitoringOptions(builder.getConnectionHealthConfiguration())
.orElseGet(() -> defaultConnectionHealthConfiguration(config));
this.maxConnectionIdleInMilliseconds = config.get(SdkHttpConfigurationOption.CONNECTION_MAX_IDLE_TIMEOUT).toMillis();
this.connectionAcquisitionTimeout = config.get(SdkHttpConfigurationOption.CONNECTION_ACQUIRE_TIMEOUT).toMillis();
this.proxyOptions = resolveProxy(builder.getProxyConfiguration(), tlsContext).orElse(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@

import java.time.Duration;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.crt.http.HttpMonitoringOptions;
import software.amazon.awssdk.crt.io.SocketOptions;
import software.amazon.awssdk.crt.io.TlsCipherPreference;
import software.amazon.awssdk.http.SdkHttpConfigurationOption;
import software.amazon.awssdk.http.crt.AwsCrtAsyncHttpClient;
import software.amazon.awssdk.http.crt.TcpKeepAliveConfiguration;
import software.amazon.awssdk.utils.AttributeMap;
import software.amazon.awssdk.utils.Logger;
import software.amazon.awssdk.utils.NumericUtils;

Expand Down Expand Up @@ -70,4 +73,14 @@ public static TlsCipherPreference resolveCipherPreference(Boolean postQuantumTls
return pqTls;
}

public static HttpMonitoringOptions defaultConnectionHealthConfiguration(AttributeMap config) {
HttpMonitoringOptions httpMonitoringOptions = new HttpMonitoringOptions();
httpMonitoringOptions.setMinThroughputBytesPerSecond(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly worried about this configuration - but I also don't understand the details of how this is measured and applied. I guess what I'm worried about is cases where healthy connections for say, a response with only a few bytes might round down to 0 and be < 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! Kiro didn't think it's a risk (response below). 😛 cc @TingDaoK to help confirm.


Traced through the CRT C code to understand exactly how this works. The short answer is: minThroughputBytesPerSecond=1 is safe and won't kill healthy connections with small responses.

How throughput is measured

The monitor runs every 1 second. It doesn't simply divide bytes by 1 second — it divides bytes by the time a stream was actually active during that interval (throughput calculation):

   bytespersecond = bytesread * 1000 / pendingreadintervalms
                    + byteswritten * 1000 / pendingwriteintervalms

So if a 5-byte response completes in 50ms, throughput = 5 * 1000 / 50 = 100 bytes/sec. Even the worst case — 1 byte over a full 1000ms — gives exactly 1
byte/sec, which still passes the threshold.

When throughput is checked

There are also guards that prevent false positives:

  • HTTP/1: Throughput is only checked if the same stream ID was active in both the current and previous 1-second tick. A short-lived request that starts and completes within a single tick is never checked. A new stream that just started is also
    skipped (first tick → no previous ID match).
  • HTTP/2: Throughput is only checked if there was always at least one active stream throughout the entire interval (was_inactive == false). If the last stream completes mid-interval, the check is skipped.

So for a small/fast response, either:

  1. It completes within one tick → not checked at all (stream ID mismatch for H1, or was_inactive=true for H2)
  2. It spans two ticks → the bytes transferred will produce a non-zero throughput since pending_ms is proportionally small

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! In that case I think the 1 byte setting makes sense :-)

long readTimeout = config.get(SdkHttpConfigurationOption.READ_TIMEOUT).getSeconds();
long writeTimeout = config.get(SdkHttpConfigurationOption.WRITE_TIMEOUT).getSeconds();
int maxTimeout = NumericUtils.saturatedCast(Math.max(readTimeout, writeTimeout));
httpMonitoringOptions.setAllowableThroughputFailureIntervalSeconds(maxTimeout);
return httpMonitoringOptions;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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.assertThatThrownBy;
import static software.amazon.awssdk.http.crt.CrtHttpClientTestUtils.createRequest;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.CompletionException;

Check warning on line 27 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'java.util.concurrent.CompletionException'.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9c&open=AZ0nexNGApNLOrO5Ng9c&pullRequest=6818
import java.util.concurrent.ExecutionException;

Check warning on line 28 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'java.util.concurrent.ExecutionException'.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9d&open=AZ0nexNGApNLOrO5Ng9d&pullRequest=6818
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.crt.Log;
import software.amazon.awssdk.http.ExecutableHttpRequest;
import software.amazon.awssdk.http.HttpExecuteRequest;
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;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.utils.AttributeMap;

/**
* Functional tests verifying that the default connection health configuration
* (applied when no explicit {@link ConnectionHealthConfiguration} is set)
* correctly terminates connections to non-responding servers.
*/
class NonResponsiveServerTest {

private static final Duration SHORT_TIMEOUT = Duration.ofSeconds(2);
private static final AttributeMap SHORT_TIMEOUTS = AttributeMap.builder()
.put(SdkHttpConfigurationOption.READ_TIMEOUT, SHORT_TIMEOUT)
.put(SdkHttpConfigurationOption.WRITE_TIMEOUT, SHORT_TIMEOUT)
.build();

private ServerSocket serverSocket;

@BeforeEach
void setUp() throws IOException {
Log.initLoggingToStdout(Log.LogLevel.Warn);
serverSocket = new ServerSocket(0);
// Accept connections in a daemon thread but never respond
Thread acceptThread = new Thread(() -> {
while (!serverSocket.isClosed()) {
try {
Socket socket = serverSocket.accept();

Check warning on line 68 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused "socket" local variable.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9Y&open=AZ0nexNGApNLOrO5Ng9Y&pullRequest=6818
// Hold the connection open, never send a response
Thread.sleep(Long.MAX_VALUE);

Check warning on line 70 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of "Thread.sleep()".

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9Z&open=AZ0nexNGApNLOrO5Ng9Z&pullRequest=6818
} catch (Exception e) {
// Server shutting down
}
}
});
acceptThread.setDaemon(true);
acceptThread.start();
}

@AfterEach
void tearDown() throws IOException {
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
}

@Test
void syncClient_noExplicitHealthConfig_serverNeverResponds_shouldThrow() {
try (SdkHttpClient client = AwsCrtHttpClient.builder().buildWithDefaults(SHORT_TIMEOUTS)) {
URI uri = URI.create("http://localhost:" + serverSocket.getLocalPort());
SdkHttpRequest request = createRequest(uri);
ExecutableHttpRequest executableRequest = client.prepareRequest(
HttpExecuteRequest.builder().request(request)
.contentStreamProvider(() -> new ByteArrayInputStream(new byte[0]))
.build());
assertThatThrownBy(executableRequest::call).isInstanceOf(IOException.class)
.hasMessageContaining("failure to meet throughput minimum");
}
}

@Test
void asyncClient_noExplicitHealthConfig_serverNeverResponds_shouldCompleteExceptionally()
throws InterruptedException, TimeoutException {

Check warning on line 103 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.lang.InterruptedException', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9a&open=AZ0nexNGApNLOrO5Ng9a&pullRequest=6818

Check warning on line 103 in http-clients/aws-crt-client/src/test/java/software/amazon/awssdk/http/crt/NonResponsiveServerTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.util.concurrent.TimeoutException', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=aws_aws-sdk-java-v2&issues=AZ0nexNGApNLOrO5Ng9b&open=AZ0nexNGApNLOrO5Ng9b&pullRequest=6818
try (SdkAsyncHttpClient client = AwsCrtAsyncHttpClient.builder().buildWithDefaults(SHORT_TIMEOUTS)) {
URI uri = URI.create("http://localhost:" + serverSocket.getLocalPort());
SdkHttpRequest request = createRequest(uri);
RecordingResponseHandler recorder = new RecordingResponseHandler();

client.execute(AsyncExecuteRequest.builder()
.request(request)
.requestContentPublisher(new EmptyPublisher())
.responseHandler(recorder)
.build());

assertThatThrownBy(() -> recorder.completeFuture().get(10, TimeUnit.SECONDS))
.hasCauseInstanceOf(IOException.class)
.hasMessageContaining("failure to meet throughput minimum");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@

import java.time.Duration;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
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.crt.CrtResource;
import software.amazon.awssdk.crt.http.HttpMonitoringOptions;
import software.amazon.awssdk.crt.io.SocketOptions;
import software.amazon.awssdk.crt.io.TlsCipherPreference;
import software.amazon.awssdk.http.SdkHttpConfigurationOption;
import software.amazon.awssdk.http.crt.TcpKeepAliveConfiguration;
import software.amazon.awssdk.utils.AttributeMap;

class AwsCrtConfigurationUtilsTest {
@ParameterizedTest
Expand Down Expand Up @@ -103,4 +104,31 @@ private static Stream<Arguments> tcpKeepAliveConfiguration() {
);
}

@ParameterizedTest
@MethodSource("defaultConnectionHealthConfigurationCases")
void defaultConnectionHealthConfiguration_shouldUseMaxOfReadWriteTimeout(Duration readTimeout,
Duration writeTimeout,
int expectedInterval) {
AttributeMap config = AttributeMap.builder()
.put(SdkHttpConfigurationOption.READ_TIMEOUT, readTimeout)
.put(SdkHttpConfigurationOption.WRITE_TIMEOUT, writeTimeout)
.build();

HttpMonitoringOptions result = AwsCrtConfigurationUtils.defaultConnectionHealthConfiguration(config);

assertThat(result.getMinThroughputBytesPerSecond()).isEqualTo(1);
assertThat(result.getAllowableThroughputFailureIntervalSeconds()).isEqualTo(expectedInterval);
}

private static Stream<Arguments> defaultConnectionHealthConfigurationCases() {
return Stream.of(
Arguments.of(Duration.ofSeconds(30), Duration.ofSeconds(30), 30),
Arguments.of(Duration.ofSeconds(60), Duration.ofSeconds(10), 60),
Arguments.of(Duration.ofSeconds(10), Duration.ofSeconds(45), 45),
// overflow: value exceeding Integer.MAX_VALUE should saturate
Arguments.of(Duration.ofSeconds((long) Integer.MAX_VALUE + 1), Duration.ofSeconds(1), Integer.MAX_VALUE),
Arguments.of(Duration.ofSeconds(1), Duration.ofSeconds((long) Integer.MAX_VALUE + 1), Integer.MAX_VALUE)
);
}

}
Loading