Skip to content

fix(virus-scanner): stop retrying 0-byte S3 objects and clarify logs#9646

Open
mvincentong wants to merge 2 commits into
opengovsg:developfrom
mvincentong:fix/virus-scanner-zero-byte-no-retry
Open

fix(virus-scanner): stop retrying 0-byte S3 objects and clarify logs#9646
mvincentong wants to merge 2 commits into
opengovsg:developfrom
mvincentong:fix/virus-scanner-zero-byte-no-retry

Conversation

@mvincentong

@mvincentong mvincentong commented Jun 22, 2026

Copy link
Copy Markdown

Problem

Closes #9481.

getS3ObjectVersionId retried 0-byte HeadObject responses and surfaced a misleading "Body is empty" error. For S3, a 0-byte ContentLength is not recoverable by retry, so retries added cost/noise without changing outcome.

Solution

  • In services/virus-scanner-guardduty/src/s3.service.ts:
    • Add a typed ZeroByteS3ObjectError ("S3 object is 0 bytes").
    • Make the 0-byte path non-retryable (single attempt).
    • Keep existing retry behavior for 404 and missing VersionId.
    • Preserve typed errors by unwrapping ts-retry-promise retry wrappers before rethrow.
  • In services/virus-scanner-guardduty/src/index.ts:
    • Split warn logging into not_found, missing_version_id, and zero_byte reasons while keeping the same 404 response body.
  • Add minimal runnable test wiring for this service (jest.config.js + test script) in a separate commit so reviewers can drop it independently if preferred.

Breaking Changes

  • Yes - this PR contains breaking changes
  • No - this PR is backwards compatible

Features:

  • N/A

Improvements:

  • Removes unrecoverable retries for 0-byte objects and improves operational log clarity.

Bug Fixes:

  • Fixes misleading 0-byte error message and ensures 0-byte path short-circuits without retry.

Before & After Screenshots

N/A - backend lambda behavior/logging only.

Tests

pnpm --filter formsg-virus-scanner-guardduty test
pnpm --filter formsg-virus-scanner-guardduty build
npx prettier --check "services/virus-scanner-guardduty/src/**/*.ts" "services/virus-scanner-guardduty/jest.config.js"
npx eslint services/virus-scanner-guardduty/src/s3.service.ts services/virus-scanner-guardduty/src/index.ts services/virus-scanner-guardduty/src/__tests/*.spec.ts

Focused coverage:

  • 0-byte path throws ZeroByteS3ObjectError after one attempt.
  • Missing VersionId still retries to exhaustion (unchanged behavior).
  • Handler logs distinct reason fields for not_found, missing_version_id, and zero_byte.

Deploy Notes

New environment variables:

  • N/A

New scripts:

  • services/virus-scanner-guardduty: test (for service-local unit tests)

New dependencies:

  • N/A

New dev dependencies:

  • N/A

The virus-scanner-guardduty service ships jest + ts-jest as devDependencies
and has spec files under src/__tests, but no jest config and no test script,
so the specs never compiled or ran (CI has no service test job either).

Add a minimal ts-jest jest.config.js and a `test` script that loads
.env.development (for the localstack bucket names the existing specs assert
against) so `pnpm --filter formsg-virus-scanner-guardduty test` runs the
suite. No production behaviour change; the next commit updates the specs to
match the 0-byte retry fix.
@mvincentong mvincentong requested a review from a team as a code owner June 22, 2026 03:55
@LoneRifle LoneRifle requested a review from kevin9foong June 22, 2026 04:00
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 opengovsg#9481
@mvincentong mvincentong force-pushed the fix/virus-scanner-zero-byte-no-retry branch from a2eca40 to 463e519 Compare June 29, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Virus-scanner lambda: stop retrying 0-byte objects and clarify logs

1 participant