From 39e42f3cf6a8fb81f927787a9b3a8abaa5c870d4 Mon Sep 17 00:00:00 2001 From: Ferhat Elmas Date: Mon, 21 Sep 2026 10:42:33 +0200 Subject: [PATCH] @tus/s3-store: avoid false completion on generic 404 related to #859 Signed-off-by: Ferhat Elmas --- .changeset/quiet-parts-listing.md | 5 ++++ packages/s3-store/src/index.ts | 14 +++++------ packages/s3-store/src/test/index.ts | 39 ++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 .changeset/quiet-parts-listing.md diff --git a/.changeset/quiet-parts-listing.md b/.changeset/quiet-parts-listing.md new file mode 100644 index 00000000..73258705 --- /dev/null +++ b/.changeset/quiet-parts-listing.md @@ -0,0 +1,5 @@ +--- +"@tus/s3-store": patch +--- + +Stop reporting an upload as complete when listing its parts fails with a generic `NotFound`, which the AWS SDK assigns to any 404 without an S3 error code. Only `NoSuchUpload` and `NoSuchKey` indicate a finished multipart upload, as before 2.0.6. diff --git a/packages/s3-store/src/index.ts b/packages/s3-store/src/index.ts index 20482e77..ae6f2909 100644 --- a/packages/s3-store/src/index.ts +++ b/packages/s3-store/src/index.ts @@ -60,19 +60,17 @@ function calcOffsetFromParts(parts?: Array) { } const S3_NOT_FOUND_ERROR_CODES = new Set(['NotFound', 'NoSuchKey', 'NoSuchUpload']) +// Excludes `NotFound`, which the AWS SDK also assigns to any 404 without an S3 error code. +const S3_MISSING_UPLOAD_ERROR_CODES = new Set(['NoSuchKey', 'NoSuchUpload']) -function isS3NotFoundError(error: unknown): boolean { +function isS3NotFoundError(error: unknown, codes = S3_NOT_FOUND_ERROR_CODES): boolean { if (typeof error !== 'object' || error === null) { return false } return ( - ('name' in error && - typeof error.name === 'string' && - S3_NOT_FOUND_ERROR_CODES.has(error.name)) || - ('Code' in error && - typeof error.Code === 'string' && - S3_NOT_FOUND_ERROR_CODES.has(error.Code)) + ('name' in error && typeof error.name === 'string' && codes.has(error.name)) || + ('Code' in error && typeof error.Code === 'string' && codes.has(error.Code)) ) } @@ -679,7 +677,7 @@ export class S3Store extends DataStore { // completed and therefore can ensure the the offset is the size. // AWS S3 returns NoSuchUpload, but other implementations, such as DigitalOcean // Spaces, can also return NoSuchKey. - if (isS3NotFoundError(error)) { + if (isS3NotFoundError(error, S3_MISSING_UPLOAD_ERROR_CODES)) { return new Upload({ ...metadata.file, offset: metadata.file.size as number, diff --git a/packages/s3-store/src/test/index.ts b/packages/s3-store/src/test/index.ts index 4efa6118..23574f65 100644 --- a/packages/s3-store/src/test/index.ts +++ b/packages/s3-store/src/test/index.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import {Readable} from 'node:stream' import stream from 'node:stream/promises' -import {NoSuchUpload} from '@aws-sdk/client-s3' +import {NoSuchKey, NoSuchUpload, NotFound} from '@aws-sdk/client-s3' import sinon from 'sinon' import {S3Store} from '@tus/s3-store' @@ -305,6 +305,43 @@ describe('S3DataStore', () => { await assert.rejects(store.remove(id), {status_code: 404}) }) + for (const [S3Error, completed] of [ + [NoSuchUpload, true], + [NoSuchKey, true], + [NotFound, false], + ] as const) { + for (const errorField of ['name', 'Code']) { + const outcome = completed ? 'report a completed upload' : 'propagate the error' + + it(`should ${outcome} when listing parts fails with ${S3Error.name} (via ${errorField})`, async function () { + const store = this.datastore as S3Store + const id = shared.testId('missing-multipart-upload') + + // @ts-expect-error protected method + sinon.stub(store, 'getMetadata').resolves({ + file: new Upload({id, size: 10, offset: 0}), + 'upload-id': 'missing-upload-id', + 'tus-version': '1.0.0', + }) + + const error = + errorField === 'name' + ? new S3Error({$metadata: {httpStatusCode: 404}, message: S3Error.name}) + : {Code: S3Error.name} + + // @ts-expect-error protected property + sinon.stub(store.client, 'listParts').rejects(error) + + if (completed) { + const upload = await store.getUpload(id) + assert.equal(upload.offset, 10) + } else { + await assert.rejects(store.getUpload(id), (thrown) => thrown === error) + } + }) + } + } + it('should use default maxMultipartParts when not specified', () => { const store = new S3Store({ s3ClientConfig,