Skip to content

Commit fbbb7c2

Browse files
authored
fix: gracefully handle S3 NoSuchUpload error when removing multipart upload, improve S3 error logging (#1199)
1 parent d54b900 commit fbbb7c2

6 files changed

Lines changed: 233 additions & 17 deletions

File tree

src/internal/errors/codes.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@ describe('normalizeRawError', () => {
127127
expect(result.raw).not.toContain('secret root cert')
128128
expect(JSON.parse(result.raw)).toEqual({ code: '08006' })
129129
})
130+
131+
it('handles S3 errors with correct errorCode and statusCode', () => {
132+
const s3Error = new Error('The specified upload does not exist.')
133+
s3Error.name = 'NoSuchUpload'
134+
Object.assign(s3Error, {
135+
$metadata: {
136+
httpStatusCode: 404,
137+
},
138+
})
139+
140+
const result = normalizeRawError(s3Error, 'info')
141+
142+
expect(result.errorCode).toBe(ErrorCode.S3Error)
143+
expect(result.statusCode).toBe(404)
144+
expect(result.name).toBe('NoSuchUpload')
145+
expect(result.message).toBe('The specified upload does not exist.')
146+
})
130147
})
131148

132149
describe('getErrorCode', () => {

src/internal/errors/codes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IcebergError } from '@storage/protocols/iceberg/catalog/errors'
22
import { DatabaseError } from 'pg'
33
import { configure } from 'safe-stable-stringify'
4-
import { StorageBackendError } from './storage-error'
4+
import { isS3Error, StorageBackendError } from './storage-error'
55

66
export enum ErrorCode {
77
NoSuchBucket = 'NoSuchBucket',
@@ -592,6 +592,9 @@ function hasStringErrorCode(error: Error): error is Error & { code: string } {
592592
}
593593

594594
export function getErrorCode(error: Error): string {
595+
if (isS3Error(error)) {
596+
return ErrorCode.S3Error
597+
}
595598
if (error instanceof IcebergError && error.error) {
596599
return error.error
597600
}
@@ -607,6 +610,9 @@ export function getErrorCode(error: Error): string {
607610
}
608611

609612
function getErrorStatusCode(error: Error): number {
613+
if (isS3Error(error) && error.$metadata.httpStatusCode) {
614+
return error.$metadata.httpStatusCode
615+
}
610616
if (error instanceof StorageBackendError && error.httpStatusCode) {
611617
return error.httpStatusCode
612618
}

src/storage/backend/s3/adapter.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AbortMultipartUploadCommand,
23
DeleteObjectsCommand,
34
GetObjectCommand,
45
HeadObjectCommand,
@@ -547,4 +548,39 @@ describe('S3Backend', () => {
547548
}
548549
})
549550
})
551+
552+
describe('abortMultipartUpload', () => {
553+
test('includes version in S3 key when version is provided', async () => {
554+
const backend = createBackend()
555+
const bucketName = 'test-bucket'
556+
const key = 'test-folder/test-object.txt'
557+
const uploadId = 'test-upload-id'
558+
const version = 'version-123'
559+
560+
await backend.abortMultipartUpload(bucketName, key, uploadId, version)
561+
562+
expect(mockSend).toHaveBeenCalledTimes(1)
563+
const command = mockSend.mock.calls[0][0] as AbortMultipartUploadCommand
564+
expect(command).toBeInstanceOf(AbortMultipartUploadCommand)
565+
expect(command.input.Bucket).toBe(bucketName)
566+
expect(command.input.Key).toBe(`${key}/${version}`)
567+
expect(command.input.UploadId).toBe(uploadId)
568+
})
569+
570+
test('does not include version in S3 key when version is undefined', async () => {
571+
const backend = createBackend()
572+
const bucketName = 'test-bucket'
573+
const key = 'test-folder/test-object.txt'
574+
const uploadId = 'test-upload-id'
575+
576+
await backend.abortMultipartUpload(bucketName, key, uploadId, undefined)
577+
578+
expect(mockSend).toHaveBeenCalledTimes(1)
579+
const command = mockSend.mock.calls[0][0] as AbortMultipartUploadCommand
580+
expect(command).toBeInstanceOf(AbortMultipartUploadCommand)
581+
expect(command.input.Bucket).toBe(bucketName)
582+
expect(command.input.Key).toBe(key)
583+
expect(command.input.UploadId).toBe(uploadId)
584+
})
585+
})
550586
})

src/storage/backend/s3/adapter.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ export class S3Backend implements StorageBackendAdapter {
580580
try {
581581
const paralellUploadS3 = new UploadPartCommand({
582582
Bucket: bucketName,
583-
Key: version ? `${key}/${version}` : key,
583+
Key: withOptionalVersion(key, version),
584584
UploadId: uploadId,
585585
PartNumber: partNumber,
586586
Body: body,
@@ -617,7 +617,7 @@ export class S3Backend implements StorageBackendAdapter {
617617
if (parts.length === 0) {
618618
const listPartsInput = new ListPartsCommand({
619619
Bucket: bucketName,
620-
Key: version ? key + '/' + version : key,
620+
Key: withOptionalVersion(key, version),
621621
UploadId: uploadId,
622622
})
623623

@@ -627,7 +627,7 @@ export class S3Backend implements StorageBackendAdapter {
627627

628628
const completeUpload = new CompleteMultipartUploadCommand({
629629
Bucket: bucketName,
630-
Key: version ? key + '/' + version : key,
630+
Key: withOptionalVersion(key, version),
631631
UploadId: uploadId,
632632
MultipartUpload:
633633
parts.length === 0
@@ -658,10 +658,15 @@ export class S3Backend implements StorageBackendAdapter {
658658
}
659659
}
660660

661-
async abortMultipartUpload(bucketName: string, key: string, uploadId: string): Promise<void> {
661+
async abortMultipartUpload(
662+
bucketName: string,
663+
key: string,
664+
uploadId: string,
665+
version?: string
666+
): Promise<void> {
662667
const abortUpload = new AbortMultipartUploadCommand({
663668
Bucket: bucketName,
664-
Key: key,
669+
Key: withOptionalVersion(key, version),
665670
UploadId: uploadId,
666671
})
667672
await this.client.send(abortUpload)

src/storage/protocols/s3/s3-handler.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,146 @@ describe('S3ProtocolHandler.getObject', () => {
156156
)
157157
})
158158
})
159+
160+
describe('S3ProtocolHandler.abortMultipartUpload', () => {
161+
it('aborts multipart upload and deletes from database when backend succeeds', async () => {
162+
const uploadId = 'test-upload-id'
163+
const findMultipartUpload = vi.fn().mockResolvedValue({
164+
id: uploadId,
165+
version: 'test-version',
166+
user_metadata: { key: 'value' },
167+
metadata: { mimetype: 'text/plain' },
168+
})
169+
const deleteMultipartUpload = vi.fn().mockResolvedValue(undefined)
170+
const testPermission = vi.fn().mockResolvedValue(undefined)
171+
const abortMultipartUpload = vi.fn().mockResolvedValue(undefined)
172+
const getKeyLocation = vi.fn(() => 'tenant-id/bucket/object.txt')
173+
174+
const storage = {
175+
backend: {
176+
abortMultipartUpload,
177+
},
178+
db: {
179+
asSuperUser: vi.fn(() => ({
180+
findMultipartUpload,
181+
deleteMultipartUpload,
182+
})),
183+
testPermission,
184+
},
185+
location: {
186+
getKeyLocation,
187+
},
188+
}
189+
const handler = new S3ProtocolHandler(storage as never, 'tenant-id')
190+
191+
const response = await handler.abortMultipartUpload({
192+
Bucket: 'bucket',
193+
Key: 'object.txt',
194+
UploadId: uploadId,
195+
})
196+
197+
expect(response).toEqual({})
198+
expect(findMultipartUpload).toHaveBeenCalledWith(uploadId, 'id,version,user_metadata,metadata')
199+
expect(abortMultipartUpload).toHaveBeenCalled()
200+
expect(deleteMultipartUpload).toHaveBeenCalledWith(uploadId)
201+
})
202+
203+
it('deletes from database when backend throws NoSuchUpload error', async () => {
204+
const uploadId = 'test-upload-id'
205+
const findMultipartUpload = vi.fn().mockResolvedValue({
206+
id: uploadId,
207+
version: 'test-version',
208+
user_metadata: null,
209+
metadata: null,
210+
})
211+
const deleteMultipartUpload = vi.fn().mockResolvedValue(undefined)
212+
const testPermission = vi.fn().mockResolvedValue(undefined)
213+
const noSuchUploadError = {
214+
name: 'NoSuchUpload',
215+
message: 'The specified upload does not exist.',
216+
$metadata: {
217+
httpStatusCode: 404,
218+
},
219+
}
220+
const abortMultipartUpload = vi.fn().mockRejectedValue(noSuchUploadError)
221+
const getKeyLocation = vi.fn(() => 'tenant-id/bucket/object.txt')
222+
223+
const storage = {
224+
backend: {
225+
abortMultipartUpload,
226+
},
227+
db: {
228+
asSuperUser: vi.fn(() => ({
229+
findMultipartUpload,
230+
deleteMultipartUpload,
231+
})),
232+
testPermission,
233+
},
234+
location: {
235+
getKeyLocation,
236+
},
237+
}
238+
const handler = new S3ProtocolHandler(storage as never, 'tenant-id')
239+
240+
const response = await handler.abortMultipartUpload({
241+
Bucket: 'bucket',
242+
Key: 'object.txt',
243+
UploadId: uploadId,
244+
})
245+
246+
expect(response).toEqual({})
247+
expect(findMultipartUpload).toHaveBeenCalledWith(uploadId, 'id,version,user_metadata,metadata')
248+
expect(abortMultipartUpload).toHaveBeenCalled()
249+
expect(deleteMultipartUpload).toHaveBeenCalledWith(uploadId)
250+
})
251+
252+
it('throws error when backend throws non-NoSuchUpload error', async () => {
253+
const uploadId = 'test-upload-id'
254+
const findMultipartUpload = vi.fn().mockResolvedValue({
255+
id: uploadId,
256+
version: 'test-version',
257+
user_metadata: null,
258+
metadata: null,
259+
})
260+
const deleteMultipartUpload = vi.fn().mockResolvedValue(undefined)
261+
const testPermission = vi.fn().mockResolvedValue(undefined)
262+
const otherError = {
263+
name: 'AccessDenied',
264+
message: 'Access Denied',
265+
$metadata: {
266+
httpStatusCode: 403,
267+
},
268+
}
269+
const abortMultipartUpload = vi.fn().mockRejectedValue(otherError)
270+
const getKeyLocation = vi.fn(() => 'tenant-id/bucket/object.txt')
271+
272+
const storage = {
273+
backend: {
274+
abortMultipartUpload,
275+
},
276+
db: {
277+
asSuperUser: vi.fn(() => ({
278+
findMultipartUpload,
279+
deleteMultipartUpload,
280+
})),
281+
testPermission,
282+
},
283+
location: {
284+
getKeyLocation,
285+
},
286+
}
287+
const handler = new S3ProtocolHandler(storage as never, 'tenant-id')
288+
289+
await expect(
290+
handler.abortMultipartUpload({
291+
Bucket: 'bucket',
292+
Key: 'object.txt',
293+
UploadId: uploadId,
294+
})
295+
).rejects.toEqual(otherError)
296+
297+
expect(findMultipartUpload).toHaveBeenCalledWith(uploadId, 'id,version,user_metadata,metadata')
298+
expect(abortMultipartUpload).toHaveBeenCalled()
299+
expect(deleteMultipartUpload).not.toHaveBeenCalled()
300+
})
301+
})

src/storage/protocols/s3/s3-handler.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
UploadPartCopyCommandInput,
1919
} from '@aws-sdk/client-s3'
2020
import { decrypt, encrypt } from '@internal/auth'
21-
import { ERRORS, ErrorCode, isStorageError } from '@internal/errors'
21+
import { ERRORS, ErrorCode, isS3Error, isStorageError } from '@internal/errors'
2222
import { isValidHeader } from '@internal/http/header'
2323
import { logger, logSchema } from '@internal/monitoring'
2424
import { PassThrough, Readable } from 'stream'
@@ -787,16 +787,25 @@ export class S3ProtocolHandler {
787787
metadata: multipart.metadata || undefined,
788788
})
789789

790-
await this.storage.backend.abortMultipartUpload(
791-
storageS3Bucket,
792-
this.storage.location.getKeyLocation({
793-
bucketId: Bucket,
794-
objectName: Key,
795-
tenantId: this.tenantId,
796-
}),
797-
UploadId,
798-
multipart.version
799-
)
790+
try {
791+
await this.storage.backend.abortMultipartUpload(
792+
storageS3Bucket,
793+
this.storage.location.getKeyLocation({
794+
bucketId: Bucket,
795+
objectName: Key,
796+
tenantId: this.tenantId,
797+
}),
798+
UploadId,
799+
multipart.version
800+
)
801+
} catch (e) {
802+
// gracefully continue if the upload part was already deleted/aborted on the S3 side
803+
// error.name: NoSuchUpload
804+
// error.message: The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.
805+
if (!isS3Error(e) || e.name !== 'NoSuchUpload') {
806+
throw e
807+
}
808+
}
800809

801810
await this.storage.db.asSuperUser().deleteMultipartUpload(UploadId)
802811

0 commit comments

Comments
 (0)