Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/feature-AmazonS3-08f1e6b.json
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> pathStyleAccessEnabled;
private final FieldWithDefault<Boolean> accelerateModeEnabled;
private final FieldWithDefault<Boolean> dualstackEnabled;
private final FieldWithDefault<Boolean> checksumValidationEnabled;
private final FieldWithDefault<Boolean> chunkedEncodingEnabled;
private final FieldWithDefault<Boolean> expectContinueEnabled;
private final FieldWithDefault<Long> expectContinueThresholdInBytes;
private final Boolean useArnRegionEnabled;
private final Boolean multiRegionEnabled;
private final FieldWithDefault<Supplier<ProfileFile>> profileFile;
Expand All @@ -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());
}
Comment on lines +109 to +113
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.

Forgot to raise this earlier... should we throw error if expectContinueEnabled is false and expectContinueThresholdInBytes is configured? Don't remember how we handle it for mutlipartEnabled vs multipartConfiguration, let me check

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.

Yeah - good call out - I believe for multipartEnable + multipartConfiguration we do not throw - we just silently ignore the multipartConfiguration.

this.profileFile = FieldWithDefault.create(builder.profileFile, ProfileFile::defaultProfileFile);
this.profileName = FieldWithDefault.create(builder.profileName,
ProfileFileSystemSetting.AWS_PROFILE.getStringValueOrThrow());
Expand Down Expand Up @@ -247,6 +261,26 @@ 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.
* <p>
* The default value is 1048576 bytes (1 MB).
* <p>
* <b>Note:</b> 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)
*/
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.
Expand Down Expand Up @@ -278,6 +312,7 @@ public Builder toBuilder() {
.checksumValidationEnabled(checksumValidationEnabled.valueOrNullIfDefault())
.chunkedEncodingEnabled(chunkedEncodingEnabled.valueOrNullIfDefault())
.expectContinueEnabled(expectContinueEnabled.valueOrNullIfDefault())
.expectContinueThresholdInBytes(expectContinueThresholdInBytes.valueOrNullIfDefault())
.useArnRegionEnabled(useArnRegionEnabled)
.profileFile(profileFile.valueOrNullIfDefault())
.profileName(profileName.valueOrNullIfDefault());
Expand Down Expand Up @@ -407,6 +442,32 @@ public interface Builder extends CopyableBuilder<Builder, S3Configuration> {
*/
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.
* <p>
* 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.
* <p>
* This setting only takes effect when {@link #expectContinueEnabled(Boolean)} is {@code true} (the default).
* <p>
* When content length is not known, the {@code Expect: 100-continue} header will always be added
* when {@link #expectContinueEnabled(Boolean)} is {@code true}.
* <p>
* <b>Note:</b> 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();

/**
Expand Down Expand Up @@ -476,6 +537,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> profileFile;
Expand Down Expand Up @@ -571,6 +633,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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.

Trying to find a better way to define DEFAULT_EXPECT_CONTINUE_THRESHOLD_IN_BYTES in only one place in case of when changing values, we only changed one of them. But seems difficult.

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.

Do you think it is better to create a new S3configuration using builder.build() and get the default value out of it when needed at line 66 instead of defining another one here?

Copy link
Copy Markdown
Contributor Author

@alextwoods alextwoods Apr 16, 2026

Choose a reason for hiding this comment

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

Yeah good call - I think the right place for the default is in S3Configuration as thats how we do other defaults.

The previous logic was handling the case where S3config isn't available - I'm not sure when that would ever actually happen. I've updated this logic to just default to using 0 when there isn't a config, effectively disabling the threshold.


@Override
public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context,
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

}
Loading
Loading