Lesson 2 — Why the reserved-URL path can't stream

Grounded in a production incident · media-svc · streaming OOM fix
← Lesson 1: io.Pipe & io.LimitReader · Reference cheatsheet →

1. The setup: two upload paths in a production incident

a production incident introduced streaming download→upload for videos so the pod never buffers an entire 80 MB file in memory. But the fix only worked on one of the two upload paths. Let's look at both.

NORMAL PATH (upload-svc) provider URL ──download──▶ io.Pipe ──stream──▶ upload-svc ← streaming ✓ RESERVED PATH (GCS pre-signed POST) provider URL ──download──▶ []byte ──buffer──▶ GCS pre-signed URL ← buffered ✗

The reserved path is used when the caller pre-allocates a GCS object with a known destination URL and a signed upload policy (e.g. for CDN pre-warming or exact-key uploads). It calls DownloadFile (returns []byte) then UploadToReservedURL.

The question this lesson answers: Three independent constraints prevent streaming here. Understanding them tells you exactly when you can stream and when you cannot.

2. Constraint 1 — the httpkit API itself

UploadToReservedURL calls request.PostFormWithContext. That function's File struct looks like this (from the actual httpkit source):

// httpkit/request/request.go — File struct
type File struct {
    Key      string
    Content  []byte       // ← must be fully in memory. No io.Reader.
    Filename string
    MimeType string
}

There is no overload that accepts io.Reader. The library was designed for regular JSON API uploads where the caller already has the bytes. To pass a stream, you'd have to bypass the library entirely and write your own http.NewRequestWithContext call — which is what DownloadAndUploadFile does for the normal path, using request/v2.Post(ctx, url, pr, ...).

Key insight: PostForm internally uses io.Pipe to send the multipart body to the HTTP transport (you can see this in the source). But it still needs the file content as []byte to write into that pipe. The library streams the body; it does not stream the file source.

3. Constraint 2 — GCS pre-signed POST requires Content-Length

This is the deeper protocol constraint. A GCS pre-signed POST URL is not a regular REST endpoint. It's an HTML-form-style upload backed by a signed policy document. Look at what FormFields contains at runtime:

// Fields sent to GCS pre-signed POST URL (req.FormFields)
{
  "key":              "videos/2026/abc123.mp4",   // exact object path
  "content-type":    "video/mp4",              // must match policy
  "policy":          "eyJleHBpcm...",           // base64 policy doc
  "x-goog-signature":"4a8bc...",              // HMAC of the policy
  "x-goog-algorithm":"GOOG4-RSA-SHA256",
  "x-goog-credential":"sa@project.iam...",
  "x-goog-date":     "20260617T000000Z",
}

When GCS receives this POST, it decodes and validates the policy. That policy is a JSON document signed at URL-generation time. It contains a content-length-range condition:

// Inside the base64-decoded policy
{
  "conditions": [
    {"bucket": "my-bucket"},
    {"key": "videos/2026/abc123.mp4"},
    ["content-length-range", 1, 209715200]  // 1B – 200MB
  ]
}

GCS validates that the actual byte count of the uploaded file falls within content-length-range. The only way for GCS to enforce this during the upload is to know the Content-Length of the body being streamed.

Now here is the problem. When Go's http.Client sends a request whose body is an *io.PipeReader (which has no Len() method), it sends:

POST /upload/storage/v1/b/my-bucket/o?uploadType=multipart
Transfer-Encoding: chunked
Content-Type: multipart/form-data; boundary=abc
GCS pre-signed POST endpoints do not support Transfer-Encoding: chunked. The spec for browser-based (HTML form) uploads assumes the browser knows the file size at the point of submission. GCS validates the full body size against the policy. A chunked upload defeats that validation — GCS rejects it with HTTP 400.

The normal path (upload-svc) does accept chunked encoding because it's our own server. It reads the stream and buffers internally at its discretion. GCS is not our server; we cannot change what it accepts.

4. Constraint 3 — we don't know the file size upfront

Even if we could bypass the library (constraint 1) and GCS accepted chunked encoding (constraint 2), there's a third problem: the provider's download URL often doesn't include a Content-Length response header.

// Downloading from Hedra / BytePlus provider URLs GET https://provider.cdn/generated/abc123.mp4 ← HTTP/1.1 200 OK Content-Type: video/mp4 (no Content-Length header — common with CDNs using chunked delivery) Transfer-Encoding: chunked

To set a correct Content-Length on the GCS upload, we'd need to know the video's byte count before starting the upload. Without it from the provider response headers, the only way to know is to buffer the entire file — which is exactly what we're trying to avoid.

Summary — the three constraints work together:
  1. Library — httpkit/request.File.Content is []byte, no streaming overload
  2. Protocol — GCS pre-signed POST requires known Content-Length; rejects chunked encoding
  3. Source — provider CDN URLs rarely send Content-Length, so size is unknown until all bytes are read
Any one of these alone would block streaming. All three apply simultaneously.

5. Why the normal path doesn't have these constraints

Factor Normal path (DownloadAndUploadFile) Reserved path (UploadToReservedURL)
Upload target Our own upload-svc GCS pre-signed POST URL (Google)
Accepts chunked? Yes — it's our server, we control it No — GCS HTML-form upload spec
Signed policy? None Yes — content-length-range enforced
httpkit library Bypassed — uses request/v2.Post(ctx, url, pr, ...) with raw io.Reader Uses PostFormWithContext which needs []byte
Body sent as Transfer-Encoding: chunked (pipe reader) Needs explicit Content-Length

For the normal path, we explicitly bypassed httpkit's multipart helper and built the pipe manually — that was the key architectural choice in a production incident. For the reserved path, there's no equivalent bypass that would work against GCS.

6. Could we ever fix this?

Three conditions would all need to be true simultaneously:

①
Provider reliably sends Content-Length
We'd need the download response header to give us the file size before reading a single byte. Some CDNs do, many don't.
②
GCS signed policy allows it (or we switch to signed URL + PUT)
Alternatively, instead of pre-signed POST, use a GCS signed PUT URL. A signed PUT accepts a raw body with Content-Length and doesn't have the form-field ordering constraint. This is a different GCS feature.
③
httpkit updated to accept io.Reader
Or we bypass it the same way we did for the normal path — write our own multipart construction with io.Pipe and call http.NewRequestWithContext directly.
Practical takeaway: The reserved path deals with <5% of video uploads (only when a caller pre-allocates a GCS slot). Its memory cost is bounded by one video at a time per worker iteration. The OOM risk was in the high-frequency normal path, which is now fixed. The reserved path is "good enough" until GCS signed-PUT is adopted.

7. Quick check — test your understanding

Q1. What does Go's http.Client send for Content-Length when the request body is an *io.PipeReader?

Q2. GCS pre-signed POST validates content-length-range from the policy. When would chunked encoding satisfy this validation?

Q3. Which single change would most directly enable streaming for the reserved path?