Skip to content

Fix multipart upload abort on transient part retry (#499) - #526

Open
hanabanaka wants to merge 2 commits into
mainfrom
s3ec-noretries-multipart-fix
Open

hanabanaka wants to merge 2 commits into
mainfrom
s3ec-noretries-multipart-fix

Conversation

@hanabanaka

Copy link
Copy Markdown

Issue #, if available: #499

Description of changes:

In the high-level multipart put path (enableMultipartPutObject(true)), each
already-encrypted ciphertext part was uploaded with its body wrapped in
NoRetriesAsyncRequestBody, which throws "Re-subscription is not supported!"
on any second subscribe(). When the async SDK retried a part after a transient
network failure, it re-subscribed to the body, the wrapper threw, and the entire
multipart upload aborted (issue #499).

That guard exists to stop re-subscription of live AES-GCM cipher streams,
where re-running the cipher would reuse the key/IV. It does not apply here: by
the time parts upload, the object has already been encrypted once and written to
temp ciphertext files on disk (MultipartUploadObjectPipeline.putLocalObject),
so each part is a static file. Re-reading it yields identical bytes with no
cipher re-run, and uploadPart is idempotent per uploadId/partNumber.

Fix: pass the file-based part body straight to the SDK in
UploadObjectObserver.onPartCreate so native per-part retry works. The three
call sites that wrap genuine live-cipher streams
(MultipartUploadObjectPipeline, S3AsyncEncryptionClient) are untouched, so
the GCM protection stays intact.

Tests: adds UploadObjectObserverTest (no AWS; mocks S3AsyncClient) covering
the three branches of onPartCreate — part-body re-subscription on retry (the
regression guard), temp-file cleanup and delete-notification after upload, and
unwrapping an upload failure into S3EncryptionClientException. The
re-subscription and unwrap tests are mutation-verified.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Check any applicable:

  • Were any files moved? Moving files changes their URL, which breaks all hyperlinks to the files.

Each ciphertext part was wrapped in NoRetriesAsyncRequestBody, which throws
on re-subscribe. When the SDK retried a part after a transient failure, the
whole upload aborted.

That guard is only needed for live AES-GCM cipher streams (re-running the
cipher would reuse the key/IV). Parts are static ciphertext files on disk, so
re-reading them is safe. Pass the file body straight through so the SDK can
retry the part natively; the live-cipher call sites keep the guard.

Adds UploadObjectObserverTest (no AWS; mocks S3AsyncClient) covering part-body
re-subscription on retry and temp-file cleanup after upload.
@hanabanaka
hanabanaka requested a review from a team as a code owner October 9, 2026 22:56
* Reads an {@link AsyncRequestBody} fully into a byte array, blocking until done.
* A body that forbids a second subscription throws synchronously on the second call.
*/
private static byte[] drain(AsyncRequestBody body) throws Exception {

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.

Should we add tests which asserts that re-subsription is indeed not supported when it feeds into the crypto pipeline?

From the summary:

The three
call sites that wrap genuine live-cipher streams
(MultipartUploadObjectPipeline, S3AsyncEncryptionClient) are untouched, so
the GCM protection stays intact.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call out. The existing usage of NoRetriesAsyncRequestBody are still in three places after this pr. And two of these are covered by an existing unit test already (case1, case2). The third case doesn't have unit test coverage. Adding it.

Asserts MultipartUploadObjectPipeline.uploadPart wraps the live AES-GCM part stream in NoRetriesAsyncRequestBody, so an SDK retry that re-subscribes to the part body is rejected rather than re-reading (and corrupting) the cipher stream. Deterministic unit test, no AWS, mutation-verified against removal of the guard.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants