Skip to content

Commit f95d484

Browse files
authored
Merge pull request #4612 from aws/zoewang/backfill6641
Fixed the issue where S3 multipart client failed to download zero-bye file, causing Content range header is missing to throw.
2 parents 2354d20 + a3744f6 commit f95d484

File tree

6 files changed

+148
-4
lines changed

6 files changed

+148
-4
lines changed

.changes/2.40.14.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
"category": "Amazon GuardDuty",
2626
"contributor": "",
2727
"description": "Make accountIds a required field in GetRemainingFreeTrialDays API to reflect service behavior."
28+
},
29+
{
30+
"type": "bugfix",
31+
"category": "Amazon S3",
32+
"contributor": "",
33+
"description": "Fixed the issue where S3 multipart client failed to download zero-byte file, causing `Content range header is missing` exception to throw."
2834
}
2935
]
3036
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "Amazon S3",
4+
"contributor": "",
5+
"description": "Fixed the issue where S3 multipart client failed to download zero-byte file, causing `Content range header is missing` exception to throw."
6+
}

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
- ### Features
1717
- Make accountIds a required field in GetRemainingFreeTrialDays API to reflect service behavior.
1818

19+
## __Amazon S3__
20+
- ### Bugfixes
21+
- Fixed the issue where S3 multipart client failed to download zero-byte file, causing `Content range header is missing` exception to throw.
22+
1923
# __2.40.13__ __2025-12-19__
2024
## __ARC - Region switch__
2125
- ### Features

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/async/FileAsyncResponseTransformerPublisher.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,21 @@ public void onResponse(T response) {
104104
if (!contentRangeOpt.isPresent()) {
105105
contentRangeOpt = response.sdkHttpResponse().firstMatchingHeader("content-range");
106106
if (!contentRangeOpt.isPresent()) {
107-
// Bad state! This is intended to cancel everything
108-
if (subscriber != null) {
107+
Optional<String> contentLength = response.sdkHttpResponse().firstMatchingHeader("content-length");
108+
long transformerCount = FileAsyncResponseTransformerPublisher.this.transformerCount.get();
109+
// Error out if content range header is missing and this is not the initial request
110+
if (subscriber != null && transformerCount > 0) {
109111
subscriber.onError(new IllegalStateException("Content range header is missing"));
112+
return;
110113
}
111-
return;
114+
115+
if (!contentLength.isPresent()) {
116+
subscriber.onError(new IllegalStateException("Content length header is missing"));
117+
return;
118+
}
119+
String totalLength = contentLength.get();
120+
long endByte = Long.parseLong(totalLength) - 1;
121+
contentRangeOpt = Optional.of("bytes 0-" + endByte + "/" + totalLength);
112122
}
113123
}
114124

core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/async/FileAsyncResponseTransformerPublisherTest.java

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import software.amazon.awssdk.core.async.SdkPublisher;
5252
import software.amazon.awssdk.http.SdkHttpResponse;
5353
import software.amazon.awssdk.utils.CompletableFutureUtils;
54+
import software.amazon.awssdk.utils.ContentRangeParser;
5455

5556
class FileAsyncResponseTransformerPublisherTest {
5657

@@ -239,11 +240,17 @@ public void onComplete() {
239240
}
240241

241242
private SdkResponse createMockResponseWithRange(String contentRange) {
243+
return createMockResponseWithRange(contentRange,
244+
ContentRangeParser.totalBytes(contentRange).getAsLong());
245+
}
246+
247+
private SdkResponse createMockResponseWithRange(String contentRange, Long contentLength) {
242248
SdkResponse mockResponse = mock(SdkResponse.class);
243249
SdkHttpResponse mockHttpResponse = mock(SdkHttpResponse.class);
244250

245251
when(mockResponse.sdkHttpResponse()).thenReturn(mockHttpResponse);
246-
when(mockHttpResponse.firstMatchingHeader("x-amz-content-range")).thenReturn(Optional.of(contentRange));
252+
when(mockHttpResponse.firstMatchingHeader("content-length")).thenReturn(Optional.ofNullable(String.valueOf(contentLength)));
253+
when(mockHttpResponse.firstMatchingHeader("x-amz-content-range")).thenReturn(Optional.ofNullable(contentRange));
247254

248255
return mockResponse;
249256
}
@@ -298,7 +305,101 @@ void createOrAppendToExisting_shouldThrowException() {
298305
assertThatThrownBy(() -> new FileAsyncResponseTransformerPublisher<>((FileAsyncResponseTransformer<?>) initialTransformer))
299306
.isInstanceOf(IllegalArgumentException.class)
300307
.hasMessageContaining("CREATE_OR_APPEND_TO_EXISTING");
308+
}
309+
310+
@Test
311+
void singleDemand_contentRangeMissing_shouldSucceed() throws Exception {
312+
AsyncResponseTransformer<SdkResponse, SdkResponse> initialTransformer = AsyncResponseTransformer.toFile(testFile);
313+
FileAsyncResponseTransformerPublisher<SdkResponse> publisher =
314+
new FileAsyncResponseTransformerPublisher<>((FileAsyncResponseTransformer<SdkResponse>) initialTransformer);
315+
316+
CountDownLatch latch = new CountDownLatch(1);
317+
AtomicReference<AsyncResponseTransformer<SdkResponse, SdkResponse>> receivedTransformer = new AtomicReference<>();
318+
CompletableFuture<SdkResponse> future = new CompletableFuture<>();
301319

320+
publisher.subscribe(new Subscriber<AsyncResponseTransformer<SdkResponse, SdkResponse>>() {
321+
private Subscription subscription;
322+
323+
@Override
324+
public void onSubscribe(Subscription s) {
325+
this.subscription = s;
326+
s.request(1);
327+
}
328+
329+
@Override
330+
public void onNext(AsyncResponseTransformer<SdkResponse, SdkResponse> transformer) {
331+
receivedTransformer.set(transformer);
332+
333+
// Simulate response with content-range header
334+
SdkResponse mockResponse = createMockResponseWithRange(null, 0L);
335+
CompletableFuture<SdkResponse> prepareFuture = transformer.prepare();
336+
CompletableFutureUtils.forwardResultTo(prepareFuture, future);
337+
transformer.onResponse(mockResponse);
338+
339+
// Simulate stream data
340+
SdkPublisher<ByteBuffer> mockPublisher = createMockPublisher();
341+
transformer.onStream(mockPublisher);
342+
343+
latch.countDown();
344+
}
345+
346+
@Override
347+
public void onError(Throwable t) {
348+
fail("Unexpected error with exception: " + t.getMessage());
349+
}
350+
351+
@Override
352+
public void onComplete() {
353+
latch.countDown();
354+
}
355+
});
356+
357+
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
358+
assertThat(receivedTransformer.get()).isNotNull();
359+
assertThat(Files.exists(testFile)).isTrue();
360+
assertThat(future).succeedsWithin(10, TimeUnit.SECONDS);
361+
}
362+
363+
@Test
364+
void multipleTransformers_contentRangeMissingOnSecondRequest_shouldFail() throws Exception {
365+
AsyncResponseTransformer<SdkResponse, SdkResponse> initialTransformer = AsyncResponseTransformer.toFile(testFile);
366+
FileAsyncResponseTransformerPublisher<SdkResponse> publisher =
367+
new FileAsyncResponseTransformerPublisher<>((FileAsyncResponseTransformer<SdkResponse>) initialTransformer);
368+
369+
CountDownLatch latch = new CountDownLatch(1);
370+
CompletableFuture<SdkResponse> future = new CompletableFuture<>();
371+
AtomicReference<Throwable> exception = new AtomicReference<>();
372+
373+
publisher.subscribe(new Subscriber<AsyncResponseTransformer<SdkResponse, SdkResponse>>() {
374+
375+
@Override
376+
public void onSubscribe(Subscription s) {
377+
s.request(2);
378+
}
379+
380+
@Override
381+
public void onNext(AsyncResponseTransformer<SdkResponse, SdkResponse> transformer) {
382+
SdkResponse mockResponse = createMockResponseWithRange(null, 0L);
383+
384+
CompletableFuture<SdkResponse> prepareFuture = transformer.prepare();
385+
CompletableFutureUtils.forwardResultTo(prepareFuture, future);
386+
transformer.onResponse(mockResponse);
387+
}
388+
389+
@Override
390+
public void onError(Throwable t) {
391+
exception.set(t);
392+
latch.countDown();
393+
}
394+
395+
@Override
396+
public void onComplete() {
397+
fail("Unexpected onComplete");
398+
}
399+
});
400+
401+
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
402+
assertThat(exception.get()).hasMessageContaining("Content range header is missing");
302403
}
303404

304405
}

services/s3/src/it/java/software/amazon/awssdk/services/s3/multipart/S3MultipartClientFileDownloadIntegrationTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public class S3MultipartClientFileDownloadIntegrationTest extends S3IntegrationT
5555
private static final int MIB = 1024 * 1024;
5656
private static final String TEST_BUCKET = temporaryBucketName(S3MultipartClientFileDownloadIntegrationTest.class);
5757
private static final String TEST_KEY = "testfile.dat";
58+
private static final String ZERO_BYTE_KEY = "zero.dat";
5859
private static final int OBJ_SIZE = 100 * MIB;
5960
private static final long PART_SIZE = 5 * MIB;
6061

@@ -108,6 +109,22 @@ void download_defaultCreateNewFile_shouldSucceed() throws Exception {
108109
path.toFile().delete();
109110
}
110111

112+
@Test
113+
void download_emptyFile_shouldSucceed() throws Exception {
114+
Path path = tmpPath().resolve(UUID.randomUUID().toString());
115+
s3Client.putObject(b -> b.bucket(TEST_BUCKET).key(ZERO_BYTE_KEY), AsyncRequestBody.empty()).join();
116+
CompletableFuture<GetObjectResponse> future = s3Client.getObject(
117+
req -> req.bucket(TEST_BUCKET).key(ZERO_BYTE_KEY),
118+
AsyncResponseTransformer.toFile(path, FileTransformerConfiguration.defaultCreateNew()));
119+
future.join();
120+
MessageDigest md = MessageDigest.getInstance("SHA-256");
121+
byte[] downloadedHash = md.digest(Files.readAllBytes(path));
122+
md.reset();
123+
byte[] originalHash = md.digest(new byte[0]);
124+
assertThat(downloadedHash).isEqualTo(originalHash);
125+
}
126+
127+
111128
private Path tmpPath() {
112129
return Paths.get(JavaSystemSetting.TEMP_DIRECTORY.getStringValueOrThrow());
113130
}

0 commit comments

Comments
 (0)