From 56a85ee4bd5efacbc92569b7533593090c153d63 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Wed, 22 Sep 2021 20:36:13 +0200 Subject: [PATCH 1/8] Use `retryable` to retry `prepareUploadParts` --- .../aws-s3-multipart/src/MultipartUploader.js | 28 +++++---- .../@uppy/aws-s3-multipart/src/index.test.js | 60 ++++++++++++++++++- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js index 7ef7af1db3..0b69ffaa18 100644 --- a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js +++ b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js @@ -244,19 +244,25 @@ class MultipartUploader { async #prepareUploadParts (candidates) { this.lockedCandidatesForBatch.push(...candidates) - const result = await this.options.prepareUploadParts({ - key: this.key, - uploadId: this.uploadId, - partNumbers: candidates.map((index) => index + 1), - }) + try { + const result = await this.#retryable({ + attempt: () => this.options.prepareUploadParts({ + key: this.key, + uploadId: this.uploadId, + partNumbers: candidates.map((index) => index + 1), + }), + }) + const valid = typeof result?.presignedUrls === 'object' - const valid = typeof result?.presignedUrls === 'object' - if (!valid) { - throw new TypeError( - 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.' - ) + if (!valid) { + throw new TypeError( + 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.' + ) + } + return result + } catch (error) { + throw new Error(error) } - return result } #uploadPartRetryable (index, prePreparedPart) { diff --git a/packages/@uppy/aws-s3-multipart/src/index.test.js b/packages/@uppy/aws-s3-multipart/src/index.test.js index dd4f151092..d17fe0af39 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.test.js +++ b/packages/@uppy/aws-s3-multipart/src/index.test.js @@ -52,7 +52,7 @@ describe('AwsS3Multipart', () => { }), completeMultipartUpload: jest.fn(() => Promise.resolve({ location: 'test' })), abortMultipartUpload: jest.fn(), - prepareUploadParts: jest.fn(() => { + prepareUploadParts: jest.fn(() => new Promise((resolve) => { const presignedUrls = {} const possiblePartNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] possiblePartNumbers.forEach((partNumber) => { @@ -60,8 +60,8 @@ describe('AwsS3Multipart', () => { partNumber ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` }) - return { presignedUrls } - }), + return resolve({ presignedUrls }) + })), }) awsS3Multipart = core.getPlugin('AwsS3Multipart') }) @@ -161,4 +161,58 @@ describe('AwsS3Multipart', () => { }) }) }) + + describe('MultipartUploader', () => { + let core + let awsS3Multipart + + beforeEach(() => { + core = new Core() + core.use(AwsS3Multipart, { + createMultipartUpload: jest.fn(() => { + return { + uploadId: '6aeb1980f3fc7ce0b5454d25b71992', + key: 'test/upload/multitest.dat', + } + }), + completeMultipartUpload: jest.fn(() => Promise.resolve({ location: 'test' })), + abortMultipartUpload: jest.fn(), + prepareUploadParts: jest + .fn(() => new Promise((resolve) => { + const presignedUrls = {} + const possiblePartNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + + possiblePartNumbers.forEach((partNumber) => { + presignedUrls[ + partNumber + ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` + }) + + return resolve({ presignedUrls }) + })) + // This runs first and only once + // eslint-disable-next-line prefer-promise-reject-errors + .mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })), + }) + awsS3Multipart = core.getPlugin('AwsS3Multipart') + }) + + it('retries prepareUploadParts when it fails once', (done) => { + const fileSize = 5 * MB + 1 * MB + core.addFile({ + source: 'jest', + name: 'multitest.dat', + type: 'application/octet-stream', + data: new File([Buffer.alloc(fileSize)], { + type: 'application/octet-stream', + }), + }) + core.upload().then(() => { + expect( + awsS3Multipart.opts.prepareUploadParts.mock.calls.length + ).toEqual(2) + done() + }) + }) + }) }) From 428b4fc690abd54597aab46fa0cfab8816a17dbf Mon Sep 17 00:00:00 2001 From: Murderlon Date: Thu, 23 Sep 2021 10:49:40 +0200 Subject: [PATCH 2/8] Correctly handle errors in prepareUploadParts --- .../aws-s3-multipart/src/MultipartUploader.js | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js index 0b69ffaa18..31cee2144a 100644 --- a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js +++ b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js @@ -243,26 +243,28 @@ class MultipartUploader { async #prepareUploadParts (candidates) { this.lockedCandidatesForBatch.push(...candidates) + let result try { - const result = await this.#retryable({ + result = await this.#retryable({ attempt: () => this.options.prepareUploadParts({ key: this.key, uploadId: this.uploadId, partNumbers: candidates.map((index) => index + 1), }), }) - const valid = typeof result?.presignedUrls === 'object' - - if (!valid) { - throw new TypeError( - 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.' - ) - } - return result } catch (error) { - throw new Error(error) + this.#onError(error) + } + + const valid = typeof result?.presignedUrls === 'object' + + if (!valid) { + throw new TypeError( + 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.' + ) } + return result } #uploadPartRetryable (index, prePreparedPart) { From fb2971cc46495d631d7f53fccae1c8973fde9cb1 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Thu, 23 Sep 2021 13:43:38 +0200 Subject: [PATCH 3/8] Refactor tests to async/await --- .../@uppy/aws-s3-multipart/src/index.test.js | 96 ++++++++++--------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/index.test.js b/packages/@uppy/aws-s3-multipart/src/index.test.js index d17fe0af39..ba2ac80ca8 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.test.js +++ b/packages/@uppy/aws-s3-multipart/src/index.test.js @@ -66,7 +66,7 @@ describe('AwsS3Multipart', () => { awsS3Multipart = core.getPlugin('AwsS3Multipart') }) - it('Calls the prepareUploadParts function totalChunks / limit times', (done) => { + it('Calls the prepareUploadParts function totalChunks / limit times', async () => { const scope = nock( 'https://bucket.s3.us-east-2.amazonaws.com' ).defaultReplyHeaders({ @@ -74,6 +74,10 @@ describe('AwsS3Multipart', () => { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'ETag', }) + // 6MB file will give us 2 chunks, so there will be 2 PUT and 2 OPTIONS + // calls to the presigned URL from 1 prepareUploadParts calls + const fileSize = 5 * MB + 1 * MB + scope .options((uri) => uri.includes('test/upload/multitest.dat')) .reply(200, '') @@ -87,9 +91,6 @@ describe('AwsS3Multipart', () => { .put((uri) => uri.includes('test/upload/multitest.dat')) .reply(200, '', { ETag: 'test2' }) - // 6MB file will give us 2 chunks, so there will be 2 PUT and 2 OPTIONS - // calls to the presigned URL from 1 prepareUploadParts calls - const fileSize = 5 * MB + 1 * MB core.addFile({ source: 'jest', name: 'multitest.dat', @@ -98,16 +99,17 @@ describe('AwsS3Multipart', () => { type: 'application/octet-stream', }), }) - core.upload().then(() => { - expect( - awsS3Multipart.opts.prepareUploadParts.mock.calls.length - ).toEqual(1) - scope.done() - done() - }) + + await core.upload() + + expect( + awsS3Multipart.opts.prepareUploadParts.mock.calls.length + ).toEqual(1) + + scope.done() }) - it('Calls prepareUploadParts with a Math.ceil(limit / 2) minimum, instead of one at a time for the remaining chunks after the first limit batch', (done) => { + it('Calls prepareUploadParts with a Math.ceil(limit / 2) minimum, instead of one at a time for the remaining chunks after the first limit batch', async () => { const scope = nock( 'https://bucket.s3.us-east-2.amazonaws.com' ).defaultReplyHeaders({ @@ -115,6 +117,13 @@ describe('AwsS3Multipart', () => { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'ETag', }) + // 50MB file will give us 10 chunks, so there will be 10 PUT and 10 OPTIONS + // calls to the presigned URL from 3 prepareUploadParts calls + // + // The first prepareUploadParts call will be for 5 parts, the second + // will be for 3 parts, the third will be for 2 parts. + const fileSize = 50 * MB + scope .options((uri) => uri.includes('test/upload/multitest.dat')) .reply(200, '') @@ -123,12 +132,6 @@ describe('AwsS3Multipart', () => { .reply(200, '', { ETag: 'test' }) scope.persist() - // 50MB file will give us 10 chunks, so there will be 10 PUT and 10 OPTIONS - // calls to the presigned URL from 3 prepareUploadParts calls - // - // The first prepareUploadParts call will be for 5 parts, the second - // will be for 3 parts, the third will be for 2 parts. - const fileSize = 50 * MB core.addFile({ source: 'jest', name: 'multitest.dat', @@ -137,28 +140,28 @@ describe('AwsS3Multipart', () => { type: 'application/octet-stream', }), }) - core.upload().then(() => { - expect( - awsS3Multipart.opts.prepareUploadParts.mock.calls.length - ).toEqual(3) - expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[0][1].partNumbers).toEqual([1, 2, 3, 4, 5]) - expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[1][1].partNumbers).toEqual([6, 7, 8]) - expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[2][1].partNumbers).toEqual([9, 10]) - const completeCall = awsS3Multipart.opts.completeMultipartUpload.mock.calls[0][1] - expect(completeCall.parts).toEqual([ - { ETag: 'test', PartNumber: 1 }, - { ETag: 'test', PartNumber: 2 }, - { ETag: 'test', PartNumber: 3 }, - { ETag: 'test', PartNumber: 4 }, - { ETag: 'test', PartNumber: 5 }, - { ETag: 'test', PartNumber: 6 }, - { ETag: 'test', PartNumber: 7 }, - { ETag: 'test', PartNumber: 8 }, - { ETag: 'test', PartNumber: 9 }, - { ETag: 'test', PartNumber: 10 }, - ]) - done() - }) + + await core.upload() + + expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(3) + expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[0][1].partNumbers).toEqual([1, 2, 3, 4, 5]) + expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[1][1].partNumbers).toEqual([6, 7, 8]) + expect(awsS3Multipart.opts.prepareUploadParts.mock.calls[2][1].partNumbers).toEqual([9, 10]) + + const completeCall = awsS3Multipart.opts.completeMultipartUpload.mock.calls[0][1] + + expect(completeCall.parts).toEqual([ + { ETag: 'test', PartNumber: 1 }, + { ETag: 'test', PartNumber: 2 }, + { ETag: 'test', PartNumber: 3 }, + { ETag: 'test', PartNumber: 4 }, + { ETag: 'test', PartNumber: 5 }, + { ETag: 'test', PartNumber: 6 }, + { ETag: 'test', PartNumber: 7 }, + { ETag: 'test', PartNumber: 8 }, + { ETag: 'test', PartNumber: 9 }, + { ETag: 'test', PartNumber: 10 }, + ]) }) }) @@ -197,8 +200,9 @@ describe('AwsS3Multipart', () => { awsS3Multipart = core.getPlugin('AwsS3Multipart') }) - it('retries prepareUploadParts when it fails once', (done) => { + it('retries prepareUploadParts when it fails once', async () => { const fileSize = 5 * MB + 1 * MB + core.addFile({ source: 'jest', name: 'multitest.dat', @@ -207,12 +211,10 @@ describe('AwsS3Multipart', () => { type: 'application/octet-stream', }), }) - core.upload().then(() => { - expect( - awsS3Multipart.opts.prepareUploadParts.mock.calls.length - ).toEqual(2) - done() - }) + + await core.upload() + + expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(2) }) }) }) From 2286afb9ed3e531ff5f081e91da1e27ae5464577 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Thu, 23 Sep 2021 17:26:22 +0200 Subject: [PATCH 4/8] Throw error in prepareUploadParts --- packages/@uppy/aws-s3-multipart/src/MultipartUploader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js index 31cee2144a..9243ee53a5 100644 --- a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js +++ b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js @@ -254,7 +254,7 @@ class MultipartUploader { }), }) } catch (error) { - this.#onError(error) + throw new Error(error) } const valid = typeof result?.presignedUrls === 'object' From 2a1ac74d1a813d93a0b5d16123a0cb1ba1beab84 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Thu, 23 Sep 2021 17:35:43 +0200 Subject: [PATCH 5/8] Remove unnecessary try/catch block --- .../aws-s3-multipart/src/MultipartUploader.js | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js index 9243ee53a5..a62d42e4c3 100644 --- a/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js +++ b/packages/@uppy/aws-s3-multipart/src/MultipartUploader.js @@ -243,27 +243,21 @@ class MultipartUploader { async #prepareUploadParts (candidates) { this.lockedCandidatesForBatch.push(...candidates) - let result - try { - result = await this.#retryable({ - attempt: () => this.options.prepareUploadParts({ - key: this.key, - uploadId: this.uploadId, - partNumbers: candidates.map((index) => index + 1), - }), - }) - } catch (error) { - throw new Error(error) - } - - const valid = typeof result?.presignedUrls === 'object' + const result = await this.#retryable({ + attempt: () => this.options.prepareUploadParts({ + key: this.key, + uploadId: this.uploadId, + partNumbers: candidates.map((index) => index + 1), + }), + }) - if (!valid) { + if (typeof result?.presignedUrls !== 'object') { throw new TypeError( 'AwsS3/Multipart: Got incorrect result from `prepareUploadParts()`, expected an object `{ presignedUrls }`.' ) } + return result } From de883a8f4e6903038d4a73fb9876e1e3095746fe Mon Sep 17 00:00:00 2001 From: Murderlon Date: Mon, 27 Sep 2021 15:40:07 +0200 Subject: [PATCH 6/8] Use `async` instead of `Promise` --- .../@uppy/aws-s3-multipart/src/index.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/@uppy/aws-s3-multipart/src/index.test.js b/packages/@uppy/aws-s3-multipart/src/index.test.js index ba2ac80ca8..b250b1e5f4 100644 --- a/packages/@uppy/aws-s3-multipart/src/index.test.js +++ b/packages/@uppy/aws-s3-multipart/src/index.test.js @@ -50,9 +50,9 @@ describe('AwsS3Multipart', () => { key: 'test/upload/multitest.dat', } }), - completeMultipartUpload: jest.fn(() => Promise.resolve({ location: 'test' })), + completeMultipartUpload: jest.fn(async () => ({ location: 'test' })), abortMultipartUpload: jest.fn(), - prepareUploadParts: jest.fn(() => new Promise((resolve) => { + prepareUploadParts: jest.fn(async () => { const presignedUrls = {} const possiblePartNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] possiblePartNumbers.forEach((partNumber) => { @@ -60,8 +60,8 @@ describe('AwsS3Multipart', () => { partNumber ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` }) - return resolve({ presignedUrls }) - })), + return { presignedUrls } + }), }) awsS3Multipart = core.getPlugin('AwsS3Multipart') }) @@ -178,10 +178,10 @@ describe('AwsS3Multipart', () => { key: 'test/upload/multitest.dat', } }), - completeMultipartUpload: jest.fn(() => Promise.resolve({ location: 'test' })), + completeMultipartUpload: jest.fn(async () => ({ location: 'test' })), abortMultipartUpload: jest.fn(), prepareUploadParts: jest - .fn(() => new Promise((resolve) => { + .fn(async () => { const presignedUrls = {} const possiblePartNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] @@ -191,8 +191,8 @@ describe('AwsS3Multipart', () => { ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` }) - return resolve({ presignedUrls }) - })) + return { presignedUrls } + }) // This runs first and only once // eslint-disable-next-line prefer-promise-reject-errors .mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })), From 6014cc479a564a56578194b991d367e90053e2ad Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 28 Sep 2021 13:23:11 +0200 Subject: [PATCH 7/8] Update docs about retrying --- website/src/docs/aws-s3-multipart.md | 35 +++++++++++++++++++--------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/website/src/docs/aws-s3-multipart.md b/website/src/docs/aws-s3-multipart.md index e6399de0de..65b58def1e 100644 --- a/website/src/docs/aws-s3-multipart.md +++ b/website/src/docs/aws-s3-multipart.md @@ -47,7 +47,9 @@ For example, with a 50MB file and a `limit` of 5 we end up with 10 chunks. 5 of ### `retryDelays: [0, 1000, 3000, 5000]` -When uploading a chunk to S3 using a presigned URL fails, automatically try again after the millisecond intervals specified in this array. By default, we first retry instantly; if that fails, we retry after 1 second; if that fails, we retry after 3 seconds, etc. +`retryDelays` are the intervals in milliseconds used to retry a failed chunk as well as [`prepareUploadParts`](#prepareUploadParts-file-partData). + +By default, we first retry instantly; if that fails, we retry after 1 second; if that fails, we retry after 3 seconds, etc. Set to `null` to disable automatic retries, and fail instantly if any chunk fails to upload. @@ -109,20 +111,31 @@ A function that generates a batch of signed URLs for the specified part numbers. - `key` - The object key in the S3 bucket. - `partNumbers` - An array of indecies of this part in the file (`PartNumber` in S3 terminology). Note that part numbers are _not_ zero-based. -Return a Promise for an object with keys: +`prepareUploadParts` should return a `Promise` with an `Object` with keys: - `presignedUrls` - A JavaScript object with the part numbers as keys and the presigned URL for each part as the value. An example of what the return value should look like: - - ```js - // for partNumbers [1, 2, 3] - return { - 1: 'https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=1&...', - 2: 'https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=2&...', - 3: 'https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=3&...', - } - ``` - `headers` - **(Optional)** Custom headers that should be sent to the S3 presigned URL. +```json +{ + "presignedUrls": { + "1": "https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=1&...", + "2": "https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=2&...", + "3": "https://bucket.region.amazonaws.com/path/to/file.jpg?partNumber=3&..." + }, + "headers": { "some-header": "value" } +} +``` + +If an error occured, reject the `Promise` with an `Object` with the following keys: + + +```json +{ "source": { "status": 500 } } +``` + +`status` is the HTTP code and is required for determining whether to retry the request. `prepareUploadParts` will be retried if the code is `0`, `409`, `423`, or between `500` and `600`. + ### `abortMultipartUpload(file, { uploadId, key })` A function that calls the S3 Multipart API to abort a Multipart upload, and delete all parts that have been uploaded so far. Receives the `file` object from Uppy's state, and an object with keys: From 2ed4fa57e83128aa6506df85c7389aabb205d4a2 Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Fri, 1 Oct 2021 15:42:14 +0200 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Mikael Finstad --- website/src/docs/aws-s3-multipart.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/docs/aws-s3-multipart.md b/website/src/docs/aws-s3-multipart.md index 65b58def1e..c556c3cfdd 100644 --- a/website/src/docs/aws-s3-multipart.md +++ b/website/src/docs/aws-s3-multipart.md @@ -113,9 +113,10 @@ A function that generates a batch of signed URLs for the specified part numbers. `prepareUploadParts` should return a `Promise` with an `Object` with keys: - - `presignedUrls` - A JavaScript object with the part numbers as keys and the presigned URL for each part as the value. An example of what the return value should look like: + - `presignedUrls` - A JavaScript object with the part numbers as keys and the presigned URL for each part as the value. - `headers` - **(Optional)** Custom headers that should be sent to the S3 presigned URL. +An example of what the return value should look like: ```json { "presignedUrls": {