Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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-AmazonS3-bb097c9.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "bugfix",
"category": "Amazon S3",
"contributor": "",
"description": "Fix StreamingRequestInterceptor to skip Expect: 100-continue header when PutObject or UploadPart requests have zero content length."
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: StreamingRequestInterceptor is an internal class, can we remove the mention of it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import software.amazon.awssdk.core.interceptor.Context;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.SdkHttpRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
Expand All @@ -32,9 +33,38 @@ public final class StreamingRequestInterceptor implements ExecutionInterceptor {
@Override
public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context,
ExecutionAttributes executionAttributes) {
if (context.request() instanceof PutObjectRequest || context.request() instanceof UploadPartRequest) {
if (shouldAddExpectContinueHeader(context)) {
return context.httpRequest().toBuilder().putHeader("Expect", "100-continue").build();
}
return context.httpRequest();
}

/**
* Determines whether to add 'Expect: 100-continue' header to streaming requests.
*
* Per RFC 9110 Section 10.1.1, clients MUST NOT send 100-continue for requests without content.
*
* Note: Empty Content length check currently applies to sync clients only. Sync HTTP clients (e.g., Apache HttpClient) may
* reuse connections, and sending empty content with Expect header can cause issues if the server has already closed the
* connection.
*
* @param context the HTTP request modification context
* @return true if Expect header should be added, false otherwise
*/
private boolean shouldAddExpectContinueHeader(Context.ModifyHttpRequest context) {
// Must be a streaming request type
if (context.request() instanceof PutObjectRequest
|| context.request() instanceof UploadPartRequest) {
// Zero Content length check
return context.requestBody()
.flatMap(RequestBody::optionalContentLength)
.map(length -> length != 0L)
.orElse(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

What about async request body? The length could also be sourced from the request itself, i.e, putObjectRequest.contentLength(). Should we check header instead? It could be content-length or x-amz-decoded-content-length

Copy link
Contributor

Choose a reason for hiding this comment

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

+1, we should probably give priority to x-amz-decoded-content-length and just fall back to content-length. If the decoded length is present and 0, then it's just the trailer and I don't think we need to use expect: 100-continue for that.

Copy link
Contributor Author

@joviegas joviegas Oct 7, 2025

Choose a reason for hiding this comment

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

Thanks @zoewangg @dagnir Initially I was only planning to support for Sync client since Apache client was erring out while Async client open new connection in these cases
Will update code based on above recommendations.

}
return false;
}




}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import software.amazon.awssdk.services.s3.model.UploadPartRequest;

public class StreamingRequestInterceptorTest {
private final StreamingRequestInterceptor interceptor = new StreamingRequestInterceptor();
private StreamingRequestInterceptor interceptor = new StreamingRequestInterceptor();

@Test
public void modifyHttpRequest_setsExpect100Continue_whenSdkRequestIsPutObject() {
Expand Down Expand Up @@ -55,4 +55,44 @@ public void modifyHttpRequest_doesNotSetExpect_whenSdkRequestIsNotPutObject() {

assertThat(modifiedRequest.firstMatchingHeader("Expect")).isNotPresent();
}

@Test
public void modifyHttpRequest_doesNotSetExpect_whenPutObjectHasZeroContentLength() {
SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest(
modifyHttpRequestContext(PutObjectRequest.builder().build(), 0L),
new ExecutionAttributes());

assertThat(modifiedRequest.firstMatchingHeader("Expect"))
.as("Expect header should not be present for zero-length content per RFC 9110")
.isNotPresent();
}

@Test
public void modifyHttpRequest_doesNotSetExpect_whenUploadPartHasZeroContentLength() {
SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest(
modifyHttpRequestContext(UploadPartRequest.builder().build(), 0L),
new ExecutionAttributes());

assertThat(modifiedRequest.firstMatchingHeader("Expect"))
.as("Expect header should not be present for zero-length content per RFC 9110")
.isNotPresent();
}

@Test
public void modifyHttpRequest_setsExpect_whenPutObjectHasNonZeroContentLength() {
SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest(
modifyHttpRequestContext(PutObjectRequest.builder().build(), 1024L),
new ExecutionAttributes());

assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue");
}

@Test
public void modifyHttpRequest_setsExpect_whenUploadPartHasNonZeroContentLength() {
SdkHttpRequest modifiedRequest = interceptor.modifyHttpRequest(
modifyHttpRequestContext(UploadPartRequest.builder().build(), 5242880L),
new ExecutionAttributes());

assertThat(modifiedRequest.firstMatchingHeader("Expect")).hasValue("100-continue");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,41 @@ public SdkRequest request() {
};
}

/**
* Creates a ModifyHttpRequest context with a specific content length.
* Useful for testing scenarios where content length matters (e.g., RFC 9110 compliance).
*
* @param request the SDK request
* @param contentLength the content length in bytes
* @return a ModifyHttpRequest context with the specified content length
*/
public static Context.ModifyHttpRequest modifyHttpRequestContext(SdkRequest request, long contentLength) {
Optional<RequestBody> requestBody = Optional.of(RequestBody.fromBytes(new byte[(int) contentLength]));
Optional<AsyncRequestBody> asyncRequestBody = Optional.of(AsyncRequestBody.fromBytes(new byte[(int) contentLength]));

return new Context.ModifyHttpRequest() {
@Override
public SdkHttpRequest httpRequest() {
return sdkHttpFullRequest();
}

@Override
public Optional<RequestBody> requestBody() {
return requestBody;
}

@Override
public Optional<AsyncRequestBody> asyncRequestBody() {
return asyncRequestBody;
}

@Override
public SdkRequest request() {
return request;
}
};
}

public static Context.ModifyResponse modifyResponseContext(SdkRequest request, SdkResponse response, SdkHttpResponse sdkHttpResponse) {
return new Context.ModifyResponse() {
@Override
Expand Down
Loading