From b5444df499501ab2721ec4ffe275f91c66087c43 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 15 Apr 2026 09:09:06 -0700 Subject: [PATCH 1/2] Add expectContinueThresholdInBytes config to S3 --- .../feature-AmazonS3-08f1e6b.json | 6 + .../awssdk/services/s3/S3Configuration.java | 68 ++++++++++ .../handlers/StreamingRequestInterceptor.java | 17 ++- .../services/s3/S3ConfigurationTest.java | 52 +++++++ .../StreamingRequestInterceptorTest.java | 127 +++++++++++++++++- 5 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 .changes/next-release/feature-AmazonS3-08f1e6b.json diff --git a/.changes/next-release/feature-AmazonS3-08f1e6b.json b/.changes/next-release/feature-AmazonS3-08f1e6b.json new file mode 100644 index 000000000000..b3f438426576 --- /dev/null +++ b/.changes/next-release/feature-AmazonS3-08f1e6b.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon S3", + "contributor": "", + "description": "Add configurable `expectContinueThresholdInBytes` to S3Configuration (default 1 MB). The Expect: 100-continue header is now only added to PutObject and UploadPart requests when the content-length meets or exceeds the threshold, reducing latency overhead for small uploads." +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java index c5d9a863e216..4757b7deebab 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java @@ -77,12 +77,19 @@ public final class S3Configuration implements ServiceConfiguration, ToCopyableBu */ private static final boolean DEFAULT_EXPECT_CONTINUE_ENABLED = true; + /** + * The default minimum content-length in bytes at which the {@code Expect: 100-continue} header is added. + * Requests with a content-length below this threshold will not include the header. + */ + private static final long DEFAULT_EXPECT_CONTINUE_THRESHOLD_IN_BYTES = 1_048_576L; + private final FieldWithDefault pathStyleAccessEnabled; private final FieldWithDefault accelerateModeEnabled; private final FieldWithDefault dualstackEnabled; private final FieldWithDefault checksumValidationEnabled; private final FieldWithDefault chunkedEncodingEnabled; private final FieldWithDefault expectContinueEnabled; + private final FieldWithDefault expectContinueThresholdInBytes; private final Boolean useArnRegionEnabled; private final Boolean multiRegionEnabled; private final FieldWithDefault> profileFile; @@ -97,6 +104,13 @@ private S3Configuration(DefaultS3ServiceConfigurationBuilder builder) { this.chunkedEncodingEnabled = FieldWithDefault.create(builder.chunkedEncodingEnabled, DEFAULT_CHUNKED_ENCODING_ENABLED); this.expectContinueEnabled = FieldWithDefault.create(builder.expectContinueEnabled, DEFAULT_EXPECT_CONTINUE_ENABLED); + this.expectContinueThresholdInBytes = FieldWithDefault.create(builder.expectContinueThresholdInBytes, + DEFAULT_EXPECT_CONTINUE_THRESHOLD_IN_BYTES); + if (this.expectContinueThresholdInBytes.value() < 0) { + throw new IllegalArgumentException( + "expectContinueThresholdInBytes must not be negative, but was: " + + this.expectContinueThresholdInBytes.value()); + } this.profileFile = FieldWithDefault.create(builder.profileFile, ProfileFile::defaultProfileFile); this.profileName = FieldWithDefault.create(builder.profileName, ProfileFileSystemSetting.AWS_PROFILE.getStringValueOrThrow()); @@ -247,6 +261,20 @@ public boolean expectContinueEnabled() { return expectContinueEnabled.value(); } + /** + * Returns the minimum content-length in bytes at which the {@code Expect: 100-continue} header is added to + * {@link PutObjectRequest} and {@link UploadPartRequest}. Requests with a content-length below this threshold + * will not include the header. + *

+ * The default value is 1048576 bytes (1 MB). + * + * @return The threshold in bytes. + * @see S3Configuration.Builder#expectContinueThresholdInBytes(Long) + */ + public long expectContinueThresholdInBytes() { + return expectContinueThresholdInBytes.value(); + } + /** * Returns whether the client is allowed to make cross-region calls when an S3 Access Point ARN has a different * region to the one configured on the client. @@ -278,6 +306,7 @@ public Builder toBuilder() { .checksumValidationEnabled(checksumValidationEnabled.valueOrNullIfDefault()) .chunkedEncodingEnabled(chunkedEncodingEnabled.valueOrNullIfDefault()) .expectContinueEnabled(expectContinueEnabled.valueOrNullIfDefault()) + .expectContinueThresholdInBytes(expectContinueThresholdInBytes.valueOrNullIfDefault()) .useArnRegionEnabled(useArnRegionEnabled) .profileFile(profileFile.valueOrNullIfDefault()) .profileName(profileName.valueOrNullIfDefault()); @@ -407,6 +436,29 @@ public interface Builder extends CopyableBuilder { */ Builder expectContinueEnabled(Boolean expectContinueEnabled); + Long expectContinueThresholdInBytes(); + + /** + * Option to configure the minimum content-length in bytes at which the {@code Expect: 100-continue} header + * is added to {@link PutObjectRequest} and {@link UploadPartRequest}. Requests with a content-length below + * this threshold will not include the header, reducing latency for small uploads where the round-trip cost + * of the 100-continue handshake outweighs the benefit. + *

+ * The default value is 1048576 bytes (1 MB). Setting this to 0 restores the pre-threshold behavior where + * the header is added for all non-zero content-length requests. + *

+ * This setting only takes effect when {@link #expectContinueEnabled(Boolean)} is {@code true} (the default). + *

+ * Note: When using the {@code ApacheHttpClient} (Apache 4), the Apache 4 client also independently adds the + * {@code Expect: 100-continue} header by default via its own {@code expectContinueEnabled} setting. This threshold + * only controls the SDK's own header addition; it does not affect the Apache client's behavior. + * + * @param expectContinueThresholdInBytes The threshold in bytes, or {@code null} to use the default (1048576). + * @return This builder for method chaining. + * @see S3Configuration#expectContinueThresholdInBytes() + */ + Builder expectContinueThresholdInBytes(Long expectContinueThresholdInBytes); + Boolean useArnRegionEnabled(); /** @@ -476,6 +528,7 @@ static final class DefaultS3ServiceConfigurationBuilder implements Builder { private Boolean checksumValidationEnabled; private Boolean chunkedEncodingEnabled; private Boolean expectContinueEnabled; + private Long expectContinueThresholdInBytes; private Boolean useArnRegionEnabled; private Boolean multiRegionEnabled; private Supplier profileFile; @@ -571,6 +624,21 @@ public void setExpectContinueEnabled(Boolean expectContinueEnabled) { expectContinueEnabled(expectContinueEnabled); } + @Override + public Long expectContinueThresholdInBytes() { + return expectContinueThresholdInBytes; + } + + @Override + public Builder expectContinueThresholdInBytes(Long expectContinueThresholdInBytes) { + this.expectContinueThresholdInBytes = expectContinueThresholdInBytes; + return this; + } + + public void setExpectContinueThresholdInBytes(Long expectContinueThresholdInBytes) { + expectContinueThresholdInBytes(expectContinueThresholdInBytes); + } + @Override public Boolean useArnRegionEnabled() { return useArnRegionEnabled; diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptor.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptor.java index b0a40aac4492..92886b07ff02 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptor.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptor.java @@ -37,6 +37,7 @@ public final class StreamingRequestInterceptor implements ExecutionInterceptor { private static final String DECODED_CONTENT_LENGTH_HEADER = "x-amz-decoded-content-length"; private static final String CONTENT_LENGTH_HEADER = "Content-Length"; + private static final long DEFAULT_EXPECT_CONTINUE_THRESHOLD_IN_BYTES = 1_048_576L; @Override public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, @@ -55,22 +56,24 @@ private boolean shouldAddExpectContinueHeader(Context.ModifyHttpRequest context, return false; } - if (isExpect100ContinueDisabled(executionAttributes)) { + S3Configuration s3Config = getS3Configuration(executionAttributes); + + if (s3Config != null && !s3Config.expectContinueEnabled()) { return false; } + long threshold = s3Config != null ? s3Config.expectContinueThresholdInBytes() + : DEFAULT_EXPECT_CONTINUE_THRESHOLD_IN_BYTES; + return getContentLengthHeader(context.httpRequest()) .map(Long::parseLong) - .map(length -> length != 0L) + .map(length -> length >= threshold && length != 0L) .orElse(true); } - private boolean isExpect100ContinueDisabled(ExecutionAttributes executionAttributes) { + private S3Configuration getS3Configuration(ExecutionAttributes executionAttributes) { ServiceConfiguration serviceConfig = executionAttributes.getAttribute(SdkExecutionAttribute.SERVICE_CONFIG); - if (serviceConfig instanceof S3Configuration) { - return !((S3Configuration) serviceConfig).expectContinueEnabled(); - } - return false; + return serviceConfig instanceof S3Configuration ? (S3Configuration) serviceConfig : null; } /** diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3ConfigurationTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3ConfigurationTest.java index 96053027cc27..ff88677e0072 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3ConfigurationTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3ConfigurationTest.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.s3; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static software.amazon.awssdk.profiles.ProfileFileSystemSetting.AWS_CONFIG_FILE; import static software.amazon.awssdk.services.s3.S3SystemSetting.AWS_S3_DISABLE_MULTIREGION_ACCESS_POINTS; import static software.amazon.awssdk.services.s3.S3SystemSetting.AWS_S3_USE_ARN_REGION; @@ -47,6 +48,7 @@ public void createConfiguration_minimal() { assertThat(config.pathStyleAccessEnabled()).isFalse(); assertThat(config.useArnRegionEnabled()).isFalse(); assertThat(config.expectContinueEnabled()).isTrue(); + assertThat(config.expectContinueThresholdInBytes()).isEqualTo(1048576L); } @Test @@ -116,5 +118,55 @@ public void useArnRegionEnabled_enabledInCProfile_shouldResolveToConfigCorrectly assertThat(config.useArnRegionEnabled()).isEqualTo(false); } + // ----------------------------------------------------------------------- + // expectContinueThresholdInBytes + // ----------------------------------------------------------------------- + + @Test + public void expectContinueThresholdInBytes_defaultValue_is1MB() { + S3Configuration config = S3Configuration.builder().build(); + assertThat(config.expectContinueThresholdInBytes()).isEqualTo(1048576L); + } + + @Test + public void expectContinueThresholdInBytes_customValue_isPreserved() { + S3Configuration config = S3Configuration.builder() + .expectContinueThresholdInBytes(2_097_152L) + .build(); + assertThat(config.expectContinueThresholdInBytes()).isEqualTo(2_097_152L); + } + + @Test + public void expectContinueThresholdInBytes_toBuilder_preservesUserSetValue() { + S3Configuration config = S3Configuration.builder() + .expectContinueThresholdInBytes(512L) + .build(); + S3Configuration rebuilt = config.toBuilder().build(); + assertThat(rebuilt.expectContinueThresholdInBytes()).isEqualTo(512L); + } + + @Test + public void expectContinueThresholdInBytes_toBuilder_returnsNullForDefault() { + S3Configuration config = S3Configuration.builder().build(); + S3Configuration.Builder builder = config.toBuilder(); + assertThat(builder.expectContinueThresholdInBytes()).isNull(); + } + + @Test + public void expectContinueThresholdInBytes_negativeValue_throwsException() { + assertThatThrownBy(() -> S3Configuration.builder() + .expectContinueThresholdInBytes(-1L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expectContinueThresholdInBytes must not be negative"); + } + + @Test + public void expectContinueThresholdInBytes_zeroValue_isAccepted() { + S3Configuration config = S3Configuration.builder() + .expectContinueThresholdInBytes(0L) + .build(); + assertThat(config.expectContinueThresholdInBytes()).isEqualTo(0L); + } } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptorTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptorTest.java index 8cd9776afbe1..457750c925cf 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptorTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/internal/handlers/StreamingRequestInterceptorTest.java @@ -148,7 +148,7 @@ private static Stream expectContinueConfigProvider() { ExecutionAttributes defaultConfigAttrs = new ExecutionAttributes(); defaultConfigAttrs.putAttribute(SdkExecutionAttribute.SERVICE_CONFIG, S3Configuration.builder().build()); ExecutionAttributes noConfigAttrs = new ExecutionAttributes(); - SdkHttpRequest nonZeroContentLength = buildHttpRequest("Content-Length", "1024"); + SdkHttpRequest nonZeroContentLength = buildHttpRequest("Content-Length", "2097152"); SdkHttpRequest zeroContentLength = buildHttpRequest("Content-Length", "0"); return Stream.of( @@ -186,13 +186,13 @@ private static Stream zeroContentLengthProvider() { private static Stream nonZeroContentLengthProvider() { return Stream.of( - Arguments.of("PutObject", "Content-Length", "1024", + Arguments.of("PutObject", "Content-Length", "2097152", PutObjectRequest.builder().build()), - Arguments.of("PutObject", "x-amz-decoded-content-length", "1024", + Arguments.of("PutObject", "x-amz-decoded-content-length", "2097152", PutObjectRequest.builder().build()), - Arguments.of("UploadPart", "Content-Length", "1024", + Arguments.of("UploadPart", "Content-Length", "2097152", UploadPartRequest.builder().build()), - Arguments.of("UploadPart", "x-amz-decoded-content-length", "1024", + Arguments.of("UploadPart", "x-amz-decoded-content-length", "2097152", UploadPartRequest.builder().build()) ); } @@ -217,4 +217,121 @@ private static SdkHttpRequest buildHttpRequest(String headerName, String headerV .putHeader(headerName, headerValue) .build(); } + + // ----------------------------------------------------------------------- + // Threshold behavior + // ----------------------------------------------------------------------- + + @Test + void modifyHttpRequest_putObject_contentLengthAboveThreshold_shouldAddExpectHeader() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "2097152"); + ExecutionAttributes attrs = withExpectContinue(true); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue"); + } + + @Test + void modifyHttpRequest_putObject_contentLengthBelowThreshold_shouldNotAddExpectHeader() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "1024"); + ExecutionAttributes attrs = withExpectContinue(true); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).isNotPresent(); + } + + @Test + void modifyHttpRequest_contentLengthExactlyAtThreshold_shouldAddExpectHeader() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "1048576"); + ExecutionAttributes attrs = withExpectContinue(true); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue"); + } + + @Test + void modifyHttpRequest_expectContinueDisabled_contentLengthAboveThreshold_shouldNotAddExpectHeader() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "2097152"); + ExecutionAttributes attrs = withExpectContinue(false); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).isNotPresent(); + } + + @Test + void modifyHttpRequest_noContentLengthHeader_shouldAddExpectHeaderRegardlessOfThreshold() { + ExecutionAttributes attrs = withThreshold(999_999_999L); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContext(PutObjectRequest.builder().build()), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue"); + } + + @Test + void modifyHttpRequest_zeroContentLength_shouldNotAddHeaderRegardlessOfThreshold() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "0"); + ExecutionAttributes attrs = withThreshold(0L); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).isNotPresent(); + } + + @Test + void modifyHttpRequest_customThreshold_shouldBeRespected() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "500"); + ExecutionAttributes attrs = withThreshold(100L); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue"); + } + + @Test + void modifyHttpRequest_customThreshold_belowThreshold_shouldNotAddHeader() { + SdkHttpRequest httpRequest = buildHttpRequest("Content-Length", "50"); + ExecutionAttributes attrs = withThreshold(100L); + + SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), httpRequest), attrs); + + assertThat(modifiedRequest.firstMatchingHeader("Expect")).isNotPresent(); + } + + @Test + void modifyHttpRequest_noS3Configuration_shouldUseDefaultThreshold() { + // Content-length above default threshold (1 MB) → header added + SdkHttpRequest aboveThreshold = buildHttpRequest("Content-Length", "2097152"); + SdkHttpRequest modifiedAbove = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), aboveThreshold), + new ExecutionAttributes()); + assertThat(modifiedAbove.firstMatchingHeader("Expect")).hasValue("100-continue"); + + // Content-length below default threshold (1 MB) → header not added + SdkHttpRequest belowThreshold = buildHttpRequest("Content-Length", "1024"); + SdkHttpRequest modifiedBelow = interceptor.modifyHttpRequest( + modifyHttpRequestContextWithHttpRequest(PutObjectRequest.builder().build(), belowThreshold), + new ExecutionAttributes()); + assertThat(modifiedBelow.firstMatchingHeader("Expect")).isNotPresent(); + } + + private static ExecutionAttributes withThreshold(long threshold) { + ExecutionAttributes attrs = new ExecutionAttributes(); + attrs.putAttribute(SdkExecutionAttribute.SERVICE_CONFIG, + S3Configuration.builder() + .expectContinueThresholdInBytes(threshold) + .build()); + return attrs; + } } From b15b9841f5eea5866568a83a72d69b9de3b57368 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 15 Apr 2026 09:31:46 -0700 Subject: [PATCH 2/2] Add more docs --- .../software/amazon/awssdk/services/s3/S3Configuration.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java index 4757b7deebab..ca3f256bb832 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Configuration.java @@ -267,6 +267,12 @@ public boolean expectContinueEnabled() { * will not include the header. *

* The default value is 1048576 bytes (1 MB). + *

+ * Note: When using the {@code ApacheHttpClient} (Apache 4), the Apache 4 client also independently adds the + * {@code Expect: 100-continue} header by default without any threshold via its own {@code expectContinueEnabled} + * setting. To benefit from the `expectContinueThresholdInBytes` you must disable {@code expectContinueEnabled} + * on the Apache4 HTTP client builder using {@code ApacheHttpClient.builder().expectContinueEnabled(false)}. + * This does NOT apply to the {@code Apache5HttpClient} which defaults {@code expectContinueEnabled} to false. * * @return The threshold in bytes. * @see S3Configuration.Builder#expectContinueThresholdInBytes(Long)