Skip to content

Commit 48ddfd7

Browse files
committed
fix: fall back to individual deletes when backend returns NotImplemented for DeleteObjects
Some S3-compatible backends (e.g. GCS in S3 interoperability mode) do not support the DeleteObjects batch API and return a NotImplemented error. Catch this and fall back to individual DeleteObjectCommand calls via Promise.allSettled, so a single failure does not abort remaining deletes mid-flight.
1 parent bd1edfd commit 48ddfd7

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

src/storage/backend/s3/adapter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,16 @@ export class S3Backend implements StorageBackendAdapter {
343343
})
344344
await this.client.send(command)
345345
} catch (e) {
346+
// Some S3-compatible backends (e.g. GCS) do not support DeleteObjects; fall back to individual deletes
347+
const code = (e as { Code?: string; name?: string })?.Code ?? (e as { name?: string })?.name
348+
if (code === 'NotImplemented') {
349+
await Promise.allSettled(
350+
prefixes.map((key) =>
351+
this.client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
352+
)
353+
)
354+
return
355+
}
346356
throw StorageBackendError.fromError(e)
347357
}
348358
}

src/test/s3-adapter.test.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ describe('S3Backend', () => {
2020
beforeEach(() => {
2121
jest.clearAllMocks()
2222
mockSend = jest.fn()
23-
;(S3Client as jest.Mock).mockImplementation(() => ({
24-
send: mockSend,
25-
}))
23+
; (S3Client as jest.Mock).mockImplementation(() => ({
24+
send: mockSend,
25+
}))
2626
})
2727

2828
describe('getObject', () => {
@@ -74,4 +74,56 @@ describe('S3Backend', () => {
7474
expect(result.metadata.mimetype).toBe('image/png')
7575
})
7676
})
77+
78+
describe('deleteObjects', () => {
79+
test('should use batch DeleteObjectsCommand when backend supports it', async () => {
80+
mockSend.mockResolvedValue({
81+
Deleted: [{ Key: 'file1.txt' }, { Key: 'file2.txt' }],
82+
$metadata: { httpStatusCode: 200 },
83+
})
84+
85+
const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' })
86+
await backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt'])
87+
88+
expect(mockSend).toHaveBeenCalledTimes(1)
89+
expect(mockSend.mock.calls[0][0].constructor.name).toBe('DeleteObjectsCommand')
90+
})
91+
92+
test('should fall back to individual DeleteObjectCommands when backend returns NotImplemented', async () => {
93+
const err = Object.assign(new Error('NotImplemented'), { Code: 'NotImplemented' })
94+
mockSend
95+
.mockRejectedValueOnce(err)
96+
.mockResolvedValue({ $metadata: { httpStatusCode: 204 } })
97+
98+
const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' })
99+
await backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt'])
100+
101+
expect(mockSend).toHaveBeenCalledTimes(3)
102+
expect(mockSend.mock.calls[0][0].constructor.name).toBe('DeleteObjectsCommand')
103+
expect(mockSend.mock.calls[1][0].constructor.name).toBe('DeleteObjectCommand')
104+
expect(mockSend.mock.calls[2][0].constructor.name).toBe('DeleteObjectCommand')
105+
})
106+
107+
test('should not throw if some individual fallback deletes fail', async () => {
108+
const notImplemented = Object.assign(new Error('NotImplemented'), { Code: 'NotImplemented' })
109+
mockSend
110+
.mockRejectedValueOnce(notImplemented)
111+
.mockResolvedValueOnce({ $metadata: { httpStatusCode: 204 } })
112+
.mockRejectedValueOnce(new Error('AccessDenied'))
113+
114+
const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' })
115+
await expect(
116+
backend.deleteObjects('test-bucket', ['file1.txt', 'file2.txt'])
117+
).resolves.toBeUndefined()
118+
})
119+
120+
test('should rethrow errors that are not NotImplemented', async () => {
121+
const err = Object.assign(new Error('AccessDenied'), { Code: 'AccessDenied' })
122+
mockSend.mockRejectedValue(err)
123+
124+
const backend = new S3Backend({ region: 'us-east-1', endpoint: 'http://localhost:9000' })
125+
await expect(backend.deleteObjects('test-bucket', ['file1.txt'])).rejects.toThrow()
126+
expect(mockSend).toHaveBeenCalledTimes(1)
127+
})
128+
})
77129
})

0 commit comments

Comments
 (0)