Grounded in a production incident · media-svc · streaming OOM fix
← Lesson 1: io.Pipe & io.LimitReader ·
Reference cheatsheet →
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.
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.
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, ...).
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.
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
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.
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.
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.
httpkit/request.File.Content is []byte, no streaming overloadContent-Length; rejects chunked encodingContent-Length, so size is unknown until all bytes are read| 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.
Three conditions would all need to be true simultaneously:
Content-Length and doesn't have the form-field ordering constraint. This is a different GCS feature.io.Pipe and call http.NewRequestWithContext directly.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?