fix(storage): surface multipart complete failures

This commit is contained in:
saltbo
2026-06-05 01:31:50 -04:00
parent 4ec207290e
commit 83d26e9dd6
4 changed files with 77 additions and 3 deletions
@@ -423,6 +423,48 @@ describe('Download tasks API integration', () => {
})
})
it('returns storage failure details when multipart upload completion fails', async () => {
vi.mocked(S3Service.prototype.completeMultipartUpload).mockRejectedValueOnce(new Error('InvalidPart: part missing'))
const { app, db } = await createTestApp()
await insertStorage(db)
const headers = {
...(await authedHeaders(app, 'multipart-complete-user@example.com')),
'Content-Type': 'application/json',
}
const createObjectRes = await app.request('/api/objects', {
method: 'POST',
headers,
body: JSON.stringify({
name: 'fixture.bin',
type: 'application/octet-stream',
size: 6 * 1024 * 1024 * 1024,
parent: '',
}),
})
expect(createObjectRes.status).toBe(201)
const object = (await createObjectRes.json()) as { id: string }
const sessionRes = await app.request(`/api/objects/${object.id}/uploads`, {
method: 'POST',
headers,
body: JSON.stringify({ partSize: 64 * 1024 * 1024 }),
})
expect(sessionRes.status).toBe(201)
const session = (await sessionRes.json()) as { id: string }
const completeRes = await app.request(`/api/objects/${object.id}/uploads/${session.id}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ action: 'complete', parts: [{ partNumber: 1, etag: '"etag-1"' }] }),
})
expect(completeRes.status).toBe(502)
await expect(completeRes.json()).resolves.toEqual({
error: 'Storage multipart upload complete failed: InvalidPart: part missing',
})
})
it('submits user task actions through downloader polling state', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
+16 -2
View File
@@ -114,13 +114,27 @@ export async function patchObjectUploadSession(
if (row.status !== 'active') throw new ObjectUploadSessionError('invalid_state')
const now = new Date()
if (params.input.action === 'complete') {
await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts)
try {
await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts)
} catch (error) {
throw new ObjectUploadSessionError(
'storage_failure',
`Storage multipart upload complete failed: ${(error as Error).message}`,
)
}
await db
.update(objectUploadSessions)
.set({ status: 'completed', updatedAt: now })
.where(eq(objectUploadSessions.id, row.id))
} else {
await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId)
try {
await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId)
} catch (error) {
throw new ObjectUploadSessionError(
'storage_failure',
`Storage multipart upload abort failed: ${(error as Error).message}`,
)
}
await db
.update(objectUploadSessions)
.set({ status: 'aborted', updatedAt: now })
+15
View File
@@ -289,6 +289,21 @@ describe('S3Service', () => {
expect(mockSend).not.toHaveBeenCalled()
})
it('rejects embedded S3 complete errors returned with HTTP 200', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response('<Error><Code>InvalidPart</Code><Message>part does not exist</Message></Error>'),
),
)
await expect(
service.completeMultipartUpload(storage, 'video.mp4', 'upload-1', [{ partNumber: 1, etag: '"etag-1"' }]),
).rejects.toThrow('InvalidPart')
})
it('aborts multipart uploads through a presigned DELETE', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
+4 -1
View File
@@ -116,8 +116,11 @@ export class S3Service {
headers: { 'Content-Type': 'application/xml' },
body: multipartCompleteXml(sortedParts),
})
const body = await response.text()
if (!response.ok) {
const body = await response.text()
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
}
if (xmlTag(body, 'Error') !== null || xmlTag(body, 'Code') !== null) {
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
}
}