Skip to content

Bundle stats upload fails against AWS S3 storage (chunked Transfer-Encoding → 501 NotImplemented) #351

Description

@ebeigarts

Summary

uploadStats sends the stats payload as a streamed request body (ReadableStream + duplex: "half"), which undici serializes with Transfer-Encoding: chunked (no Content-Length). Amazon S3 does not support chunked PUTs and rejects the pre-signed upload with 501 NotImplemented, so bundle analysis is completely non-functional on any self-hosted Codecov instance whose storage backend is real AWS S3.

MinIO (the common self-hosted default) is lenient and accepts chunked, which is likely why this hasn't surfaced widely — but a Codecov Enterprise instance configured with services.minio.host: s3.amazonaws.com hits it every time.

Environment

  • @codecov/vite-plugin 2.0.1 (@codecov/bundler-plugin-core 2.0.1)
  • Node.js v24.14.1
  • Self-hosted Codecov (Enterprise 25.5.1) with storage backend = AWS S3 (services.minio.host: s3.amazonaws.com)

Root cause

In packages/bundler-plugin-core/src/utils/uploadStats.ts the body is a stream:

const stream = new ReadableStream({
  pull(controller) { /* ... enqueue chars ... */ },
}).pipeThrough(new TextEncoderStream());

await fetchWithRetry({
  url: preSignedUrl,
  requestData: {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    duplex: "half",
    body: stream,   // <-- undici sends Transfer-Encoding: chunked, no Content-Length
  },
});

A streamed body with no known length is sent as Transfer-Encoding: chunked. S3 returns:

<Error>
  <Code>NotImplemented</Code>
  <Message>A header you provided implies functionality that is not implemented</Message>
  <Header>Transfer-Encoding</Header>
</Error>

With a large real payload, S3 responds 501 and closes the socket mid-upload, so undici surfaces it as a low-level TypeError: fetch failed (rethrown from fetchWithRetry) rather than the clean 501 — hence the opaque Failed to upload stats, fetch failed users see. With a tiny payload the full body flushes first and you get the clean 501.

Reproduction

  1. Configure a self-hosted Codecov whose storage is genuine AWS S3.
  2. Run any build with the plugin enabled + CODECOV_TOKEN set, pointing apiUrl at that instance.
  3. Pre-signed URL fetch succeeds; upload-stats fails 3× → Failed to upload stats, fetch failed.

Minimal repro of the underlying HTTP behavior (Node fetch → S3 pre-signed PUT):

// buffered string body -> Content-Length set -> 200 OK
await fetch(preSignedUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, body: statsJson })

// streamed body + duplex:"half" -> Transfer-Encoding: chunked -> 501 NotImplemented
const stream = new ReadableStream({ /* enqueue chars */ }).pipeThrough(new TextEncoderStream())
await fetch(preSignedUrl, { method: "PUT", headers: { "Content-Type": "application/json" }, duplex: "half", body: stream })

Suggested fix

Send the payload as a buffered string/Uint8Array so fetch sets Content-Length, instead of a ReadableStream:

await fetchWithRetry({
  url: preSignedUrl,
  requestData: {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: message,   // string -> Content-Length, S3-compatible
  },
});

The stats JSON is already fully in memory (it's iterated char-by-char to build the stream), so buffering it adds no meaningful memory overhead and makes uploads compatible with S3 as well as MinIO/GCS.

Alternatively, keep streaming but compute and set an explicit Content-Length header, or use S3's aws-chunked content-encoding — but a plain buffered body is by far the simplest and works across all backends.

I've verified the buffered-body change fixes uploads against AWS S3 end-to-end (Successfully uploaded stats). Happy to open a PR if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions