Skip to content

Commit 463e519

Browse files
committed
fix(virus-scanner): stop retrying 0-byte S3 objects and clarify logs
S3 has had strong read-after-write consistency since 2020, so a HeadObject that reports ContentLength === 0 can never recover on retry. The previous code retried 0-byte objects three times (wasted invocations) and logged the failure as "Body is empty", which was misleading — the check is on HeadObject.ContentLength, not a downloaded body. s3.service.ts: - Throw a typed ZeroByteS3ObjectError ("S3 object is 0 bytes") for the 0-byte case and make it non-retryable via retryIf, so it short-circuits after one attempt. 404 and missing-VersionId still retry as before (MissingS3VersionIdError is now typed but remains retryable). - Unwrap ts-retry-promise's RetryError to its original cause before rethrowing (duck-typed, since the package's src/dist entry points make instanceof unsafe under ts-jest) so callers can branch on the typed error. - Drop the misleading "after retries" suffix from the failure log, since the 0-byte path no longer retries. index.ts: - Split the collapsed 404 warn into three distinguishable log lines with a structured `reason` field: not_found, missing_version_id, zero_byte. The HTTP response stays 404 with the same body, preserving caller behaviour. Tests cover the 0-byte short-circuit (one attempt, no retry), the missing-VersionId retry-then-fail path (four attempts, unchanged), and the three distinct handler warn logs. Closes #9481
1 parent a2a63d8 commit 463e519

4 files changed

Lines changed: 174 additions & 29 deletions

File tree

services/virus-scanner-guardduty/src/__tests/index.spec.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { validate } from 'uuid'
77

88
import { handler } from '../index'
99
import * as LoggerService from '../logger'
10-
import { S3Service } from '../s3.service'
10+
import {
11+
MissingS3VersionIdError,
12+
S3Service,
13+
ZeroByteS3ObjectError,
14+
} from '../s3.service'
1115

1216
// mocked S3Service
1317
const MockS3Service = jest.mocked(S3Service)
@@ -72,14 +76,14 @@ describe('handler', () => {
7276
)
7377
})
7478

75-
it('should return 404 if versionId not found', async () => {
79+
it('should return 404 and log missing_version_id if the object has no version id', async () => {
7680
// Arrange
7781
const mockUUID = crypto.randomUUID()
7882
const mockEvent = { key: mockUUID }
7983

8084
MockS3Service.prototype.getS3ObjectVersionId = jest
8185
.fn()
82-
.mockRejectedValueOnce(new Error('VersionId is empty'))
86+
.mockRejectedValueOnce(new MissingS3VersionIdError())
8387

8488
// Act
8589
const result = await handler(mockEvent as unknown as APIGatewayProxyEvent)
@@ -95,21 +99,26 @@ describe('handler', () => {
9599
expect(mockLoggerWarn).toHaveBeenCalledTimes(1)
96100
expect(mockLoggerWarn).toHaveBeenCalledWith(
97101
expect.objectContaining({
98-
message: 'File not found or its content is empty',
99-
err: new Error('VersionId is empty'),
102+
message: 'S3 object is missing a version id',
103+
reason: 'missing_version_id',
104+
err: expect.any(MissingS3VersionIdError),
100105
quarantineFileKey: mockUUID,
101106
}),
102107
)
103108
})
104109

105-
it('should return 404 if body content length is empty', async () => {
110+
it('should return 404 and log not_found if the object is missing', async () => {
106111
// Arrange
107112
const mockUUID = crypto.randomUUID()
108113
const mockEvent = { key: mockUUID }
109114

115+
const notFoundError = Object.assign(new Error('Not Found'), {
116+
name: 'NotFound',
117+
$metadata: { httpStatusCode: 404 },
118+
})
110119
MockS3Service.prototype.getS3ObjectVersionId = jest
111120
.fn()
112-
.mockRejectedValueOnce(new Error('Body is empty'))
121+
.mockRejectedValueOnce(notFoundError)
113122

114123
// Act
115124
const result = await handler(mockEvent as unknown as APIGatewayProxyEvent)
@@ -125,8 +134,40 @@ describe('handler', () => {
125134
expect(mockLoggerWarn).toHaveBeenCalledTimes(1)
126135
expect(mockLoggerWarn).toHaveBeenCalledWith(
127136
expect.objectContaining({
137+
message: 'File not found in s3',
138+
reason: 'not_found',
139+
err: notFoundError,
140+
quarantineFileKey: mockUUID,
141+
}),
142+
)
143+
})
144+
145+
it('should return 404 and log zero_byte if the object is 0 bytes', async () => {
146+
// Arrange
147+
const mockUUID = crypto.randomUUID()
148+
const mockEvent = { key: mockUUID }
149+
150+
MockS3Service.prototype.getS3ObjectVersionId = jest
151+
.fn()
152+
.mockRejectedValueOnce(new ZeroByteS3ObjectError())
153+
154+
// Act
155+
const result = await handler(mockEvent as unknown as APIGatewayProxyEvent)
156+
157+
// Assert
158+
expect(result.statusCode).toBe(StatusCodes.NOT_FOUND)
159+
expect(result.body).toBe(
160+
JSON.stringify({
128161
message: 'File not found or its content is empty',
129-
err: new Error('Body is empty'),
162+
fileKey: mockUUID,
163+
}),
164+
)
165+
expect(mockLoggerWarn).toHaveBeenCalledTimes(1)
166+
expect(mockLoggerWarn).toHaveBeenCalledWith(
167+
expect.objectContaining({
168+
message: 'S3 object is 0 bytes',
169+
reason: 'zero_byte',
170+
err: expect.any(ZeroByteS3ObjectError),
130171
quarantineFileKey: mockUUID,
131172
}),
132173
)

services/virus-scanner-guardduty/src/__tests/s3.service.spec.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import { CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
44

55
import * as LoggerService from '../logger'
6-
import { S3Service } from '../s3.service'
6+
import {
7+
MissingS3VersionIdError,
8+
S3Service,
9+
ZeroByteS3ObjectError,
10+
} from '../s3.service'
711

812
const VersionId = 'mockObjectVersionId'
913
// Mock S3Client
@@ -107,6 +111,16 @@ describe('S3Service', () => {
107111
})
108112
})
109113
describe('getS3ObjectVersionId', () => {
114+
beforeEach(() => {
115+
// The real retry backoff (2s/4s/8s/16s) would make these tests slow; fake
116+
// timers let the retry waits resolve instantly. We advance up to 40s,
117+
// which stays under the retry wrapper's 60s timeout.
118+
jest.useFakeTimers()
119+
})
120+
afterEach(() => {
121+
jest.useRealTimers()
122+
})
123+
110124
it('should return version id', async () => {
111125
// Arrange
112126
const mockS3Service = new S3Service(true, mockLogger)
@@ -121,55 +135,80 @@ describe('S3Service', () => {
121135
expect(versionIdResult).toEqual('mockObjectVersionId')
122136
})
123137

124-
it('should throw error and log if body is empty', async () => {
138+
it('should short-circuit without retrying when the object is 0 bytes', async () => {
125139
// Arrange
126140
const mockS3Service = new S3Service(true, mockLogger)
127141
getResult = {
128142
ContentLength: 0,
129143
VersionId: 'mockObjectVersionId',
130144
}
131145

132-
// Act + assert
146+
// Act + assert. A 0-byte HeadObject is non-retryable, so no retry waits
147+
// are scheduled and the promise settles immediately.
133148
await expect(
134149
mockS3Service.getS3ObjectVersionId({
135150
bucketName: 'bucketName',
136151
objectKey: 'objectKey',
137152
}),
138-
).rejects.toThrow('Body is empty')
153+
).rejects.toThrow(ZeroByteS3ObjectError)
139154

155+
// Only one attempt ran — proving the retry loop was short-circuited.
156+
expect(mockLoggerWarn).toHaveBeenCalledTimes(1)
157+
expect(mockLoggerWarn).toHaveBeenCalledWith(
158+
expect.objectContaining({
159+
meta: expect.objectContaining({ attempt: 1, status: 'missed' }),
160+
err: expect.any(ZeroByteS3ObjectError),
161+
}),
162+
'getS3ObjectVersionId attempt failed',
163+
)
140164
expect(mockLoggerError).toHaveBeenCalledTimes(1)
141165
expect(mockLoggerError).toHaveBeenCalledWith(
142166
expect.objectContaining({
143-
bucketName: 'bucketName',
144-
objectKey: 'objectKey',
145-
err: new Error('Body is empty'),
167+
meta: expect.objectContaining({
168+
bucketName: 'bucketName',
169+
objectKey: 'objectKey',
170+
status: 'failed',
171+
attempts: 1,
172+
}),
173+
err: expect.any(ZeroByteS3ObjectError),
146174
}),
147175
'Failed to get object version ID from s3',
148176
)
149177
})
150178

151-
it('should throw error and log if version id is empty', async () => {
179+
it('should retry then fail when the version id is empty', async () => {
152180
// Arrange
153181
const mockS3Service = new S3Service(true, mockLogger)
154182
getResult = {
155183
ContentLength: 100,
156184
VersionId: '',
157185
}
158186

159-
// Act + assert
160-
await expect(
187+
// Act + assert. missing-VersionId is still retried (unchanged), so we must
188+
// attach the rejection assertion before advancing fake timers, otherwise
189+
// the rejection fires unhandled while the retry waits are flushing.
190+
// eslint-disable-next-line jest/valid-expect -- assertion is awaited below
191+
const expectation = expect(
161192
mockS3Service.getS3ObjectVersionId({
162193
bucketName: 'bucketName',
163194
objectKey: 'objectKey',
164195
}),
165-
).rejects.toThrow('VersionId is empty')
196+
).rejects.toThrow(MissingS3VersionIdError)
197+
await jest.advanceTimersByTimeAsync(40000)
198+
await expectation
166199

200+
// 1 initial attempt + 3 retries = 4 attempts, all logged as missed.
201+
expect(mockLoggerWarn).toHaveBeenCalledTimes(4)
167202
expect(mockLoggerError).toHaveBeenCalledTimes(1)
168203
expect(mockLoggerError).toHaveBeenCalledWith(
169204
expect.objectContaining({
170-
bucketName: 'bucketName',
171-
objectKey: 'objectKey',
172-
err: new Error('VersionId is empty'),
205+
meta: expect.objectContaining({
206+
bucketName: 'bucketName',
207+
objectKey: 'objectKey',
208+
status: 'failed',
209+
attempts: 4,
210+
}),
211+
err: expect.any(MissingS3VersionIdError),
173212
}),
174213
'Failed to get object version ID from s3',
175214
)

services/virus-scanner-guardduty/src/index.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { validate } from 'uuid'
55

66
import { config } from './config'
77
import { getLambdaLogger } from './logger'
8-
import { S3Service } from './s3.service'
8+
import {
9+
MissingS3VersionIdError,
10+
S3Service,
11+
ZeroByteS3ObjectError,
12+
} from './s3.service'
913
import { isBodyWithKey, MalwareScanTagValue } from './types'
1014

1115
/**
@@ -66,8 +70,25 @@ export const handler = async (
6670
objectKey: quarantineFileKey,
6771
})
6872
} catch (err) {
73+
// Distinguish the three failure modes so the warn log is filterable per
74+
// case (404, missing VersionId, 0-byte). The HTTP response stays 404 with
75+
// the same body for all three, preserving existing caller behaviour.
76+
let reason: 'not_found' | 'missing_version_id' | 'zero_byte'
77+
let logMessage: string
78+
if (err instanceof ZeroByteS3ObjectError) {
79+
reason = 'zero_byte'
80+
logMessage = 'S3 object is 0 bytes'
81+
} else if (err instanceof MissingS3VersionIdError) {
82+
reason = 'missing_version_id'
83+
logMessage = 'S3 object is missing a version id'
84+
} else {
85+
reason = 'not_found'
86+
logMessage = 'File not found in s3'
87+
}
88+
6989
logger.warn({
70-
message: 'File not found or its content is empty',
90+
message: logMessage,
91+
reason,
7192
err,
7293
quarantineFileKey,
7394
})

services/virus-scanner-guardduty/src/s3.service.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,41 @@ import {
1717
MoveS3FileParams,
1818
} from './types'
1919

20+
/**
21+
* ts-retry-promise wraps retried errors in its own RetryError, which exposes the
22+
* original failure as `lastError`. We duck-type it (instead of `instanceof`)
23+
* because the package's source/dist dual entry points make `instanceof` unsafe
24+
* under ts-jest.
25+
*/
26+
const isRetryError = (err: unknown): err is { lastError: unknown } =>
27+
!!err && typeof err === 'object' && 'lastError' in err
28+
29+
/**
30+
* Thrown when a HeadObject succeeds but the object has no VersionId.
31+
* Retried like any other transient failure, then surfaced to the caller.
32+
*/
33+
export class MissingS3VersionIdError extends Error {
34+
constructor() {
35+
super('VersionId is empty')
36+
this.name = 'MissingS3VersionIdError'
37+
Object.setPrototypeOf(this, MissingS3VersionIdError.prototype)
38+
}
39+
}
40+
41+
/**
42+
* Thrown when a HeadObject reports ContentLength === 0. S3 has had strong
43+
* read-after-write consistency since 2020, so a 0-byte HeadObject can never
44+
* recover on retry — this error is therefore non-retryable and short-circuits
45+
* immediately so callers can log and bail without wasted invocations.
46+
*/
47+
export class ZeroByteS3ObjectError extends Error {
48+
constructor() {
49+
super('S3 object is 0 bytes')
50+
this.name = 'ZeroByteS3ObjectError'
51+
Object.setPrototypeOf(this, ZeroByteS3ObjectError.prototype)
52+
}
53+
}
54+
2055
export class S3Service {
2156
private readonly s3Client: S3Client
2257
private readonly isDevelopmentEnv: boolean
@@ -305,13 +340,13 @@ export class S3Service {
305340
const { VersionId: versionId, ContentLength } = response
306341

307342
if (!versionId) {
308-
const err = new Error('VersionId is empty')
343+
const err = new MissingS3VersionIdError()
309344
logMissedAttempt(err)
310345
throw err
311346
}
312347

313348
if (!ContentLength || ContentLength === 0) {
314-
const err = new Error('Body is empty')
349+
const err = new ZeroByteS3ObjectError()
315350
logMissedAttempt(err)
316351
throw err
317352
}
@@ -336,22 +371,31 @@ export class S3Service {
336371
delay: 2000,
337372
backoff: (attempt) =>
338373
1000 * Math.pow(2, attempt) + Math.floor(Math.random() * 1000),
374+
// A 0-byte HeadObject can never recover under S3 strong
375+
// read-after-write consistency, so do not retry it. 404 and
376+
// missing-VersionId still retry as before.
377+
retryIf: (err) => !(err instanceof ZeroByteS3ObjectError),
339378
},
340379
)
341380
} catch (err) {
381+
// ts-retry-promise wraps retried errors in its RetryError (exposed via
382+
// `lastError`); unwrap to the original cause so callers can branch on the
383+
// typed error. The 0-byte case is never retried (see retryIf above) and
384+
// surfaces unwrapped.
385+
const cause = isRetryError(err) ? err.lastError : err
342386
this.logger.error(
343387
{
344388
meta: {
345389
...logMeta,
346390
status: 'failed',
347391
attempts: attempt,
348392
},
349-
err,
393+
err: cause,
350394
},
351-
'Failed to get object version ID from s3 after retries',
395+
'Failed to get object version ID from s3',
352396
)
353397

354-
throw err
398+
throw cause
355399
}
356400
}
357401
}

0 commit comments

Comments
 (0)