An oversized upload should give someone a useful rejection. In our case, the browser received "Upload failed: 502" even though the Go handler had a file-too-large response. The affected site was RadarMakers, a free launch platform for indie makers, with Apache forwarding uploads to Go.
The interesting part was the difference between the application response and the response that reached the browser. The workaround checks the declared request length before parsing multipart data, attempts a bounded discard of an oversized body, then writes the rejection.
This account comes from the current handler and its Git history. The earlier failure is documented there; I have not reproduced it for this article. The code explains a specific workaround, with limits that matter as much as the happy path.
What the proxy changed
The failure was not simply that the application rejected a large file. The browser saw a 502 from Apache when the Go server reset the connection while the proxy was still forwarding the body. A direct request to the API had produced the handler response instead. That distinction is why the investigation followed the request across both hops.
Start a similar incident by capturing the browser response, the proxy error log, and the application log for the same request window. Compare a request sent through the public route with one sent directly to the service in a controlled environment. Keep the same method, headers, body, and authentication state. If the two paths differ, inspect how the proxy handles upstream resets and unread request bytes before changing the application status code.
This case record does not claim that every 502 during an upload has this cause. A proxy can return a gateway error for many upstream failures. Here the repository comment identifies the reset during body forwarding, and the recorded comparison showed a different response on the direct path. Those are the facts behind this particular workaround.
Why an early length check helped
The handler checks the declared Content-Length before wrapping the body or calling the multipart parser. When that value exceeds the request threshold, it takes a rejection path without parsing the form. This avoids spending image parsing work on a request that the server already intends to reject.
The header is only an early signal. A client can omit the length or send a value that does not describe the bytes that arrive. The source therefore keeps a second guard with http.MaxBytesReader before ParseMultipartForm. The Go documentation describes that reader as a request body limit that returns a MaxBytesError after a read crosses the configured size, and says it may tell the response writer to close the connection if possible.
The two checks answer different questions. The first decides whether the declared size already exceeds the limit and can be rejected before parsing. The reader limit protects the path where the header cannot make that decision. Neither check proves that the image is safe to decode. Content length limits transport size; image validation has separate responsibilities.
The drain is bounded for a reason
On the declared oversized path, the handler attempts to discard up to five times the body limit. With the current constant, that is a maximum read attempt of 25 MiB. The io.CopyN call writes to io.Discard, so these bytes are consumed for connection handling and are not stored as an upload.
A bounded drain is a tradeoff, not a universal recipe. Reading a portion of a rejected body can give a proxy a chance to finish forwarding bytes already in flight, but an unbounded read would let an oversized client keep the handler occupied for an arbitrary amount of data. The cap makes the maximum discard request explicit. The code ignores the returned byte count and error, then writes the 400 response, so it does not claim that every oversized body was fully consumed.
The comparison is important when changing the constants. Raising the file limit also changes the allowed request size and may change the maximum drain if the multiplier stays fixed. Increasing only the drain cap changes how much rejected traffic the handler may read. These values should be reviewed separately and tested with the proxy configuration that actually fronts the service.
Request bodies without a trustworthy length
HTTP request bodies can arrive without a useful declared size. The handler still wraps the body with MaxBytesReader, so the multipart parse cannot consume an unbounded body through this path. The same fallback also matters if the declared value is smaller than the bytes actually read. The header check is therefore an optimization and early rejection, not the sole security boundary.
A test suite should cover both known and unknown lengths. For a declared body above the threshold, assert that the early branch returns the intended status and message without parsing a multipart field. For a body whose declared size does not cross the threshold but whose actual bytes exceed it, assert that the reader limit controls the result. If the request uses chunked transfer, exercise the actual server and proxy path because middleware and proxy buffering can affect which component sees the limit first.
Avoid writing a test that only calls the handler directly and then declaring the production 502 fixed. The original difference occurred across Apache and Go. A direct handler test can verify the application branch, but it cannot verify how a deployed proxy handles a reset. Keep those claims separate in test names and release notes.
A transport limit is not a memory limit
The configured limit is applied to the request body and multipart parsing. After the file is read, the handler also decodes and re-encodes the image. A compressed image can represent far more pixels than its byte size suggests. That is why the code checks the image header before decoding all pixels, and why the comment explains the risk of an extremely large image dimension.
The header buffer in the handler is 64 KiB. io.ReadFull attempts to fill that buffer, while treating EOF and unexpected EOF as conditions that can still leave a usable short header. The checkImage call receives only the bytes actually read. If the header cannot be parsed, the handler returns a client error before calling sanitizeImage.
A request size limit and a dimension check protect different resources. The first bounds incoming body bytes. The second rejects an image whose decoded dimensions would require unreasonable memory. Re-encoding then creates a cleaned output from pixels rather than copying the original file bytes. These operations should remain independently visible in code review because a change to one does not remove the need for the others.
Extension policy is part of the safety design
The current allowlist accepts PNG, JPG, JPEG, and GIF after lowercasing the filename extension. SVG is deliberately absent because it is an active document format when served from the same origin. WebP is also absent because this implementation uses the standard library image path and re-encodes supported pixels. The allowlist is not a statement that every file with an accepted suffix is valid; the image decoder and sanitizer still inspect its contents.
A filename suffix is user supplied. It should not be treated as proof of the media type. The handler reads a header, checks it against the expected extension, seeks back to the start, reads the file, and sanitizes the decoded content. If a new format is added, test mismatched suffixes, malformed headers, oversized dimensions, and the final saved output. Also confirm that the chosen decoder and encoder remove metadata that should not be retained.
What to observe in production
Instrument the rejection path without logging the file contents or sensitive request data. Useful fields include the route, the outcome category, whether the declared size crossed the threshold, and whether the request had a usable length. Avoid recording raw filenames if they may contain personal data. A counter by outcome can help detect a burst of rejected uploads, but it cannot show whether the proxy delivered the intended response. For that, compare proxy status and application status in a request trace with a safe correlation identifier.
Keep the logs explicit about boundaries. The application can record that it wrote a 400 response. The proxy log can record what it returned to the client. If those values differ, the path between the two is the subject of the next investigation. Do not collapse them into one field called upload status.
Review the implementation as a set of invariants
The existing order expresses a set of useful invariants. Authentication is checked before upload processing. Declared oversized bodies are rejected before multipart parsing. Other bodies are wrapped in a byte limit. The multipart form must contain the expected file field. The extension must be allowed. Image data is checked from its header before a full decode. The source bytes are re-read from the start for sanitization, then only the cleaned result is saved.
When refactoring, preserve the ordering or explain which property has changed. A helper that moves parsing above the length check may reintroduce the expensive path. A helper that copies original bytes to disk may preserve metadata or unsafe content. A broader format list may bypass the established sanitizer. Write a test for each boundary that matters rather than asserting only that a valid PNG succeeds.
The documented workaround is deliberately narrow. It changes the oversized declared-length path so the handler can attempt a capped drain before writing its intended response. It does not claim to solve every proxy timeout, every malformed multipart form, or every malicious image. That narrow description is more useful to the next person debugging a similar symptom than a general promise that uploads are now safe.
Two charts from the implementation record
The threshold chart shows the exact request limit and drain ceiling read from internal/handler/upload.go. The format chart counts the extensions in the source allowlist: four accepted suffixes and two explicitly absent formats discussed in the code comments. These are implementation facts, not measurements of traffic or an estimate of risk. The diagrams already in the asset folder remain useful for request flow and the branch order; the charts add the actual values that a reader may want to compare with a proposed code change.
Before changing either number, rerun the relevant tests and inspect the deployment proxy limits, upload timeout, and body buffering configuration. This repository record documents the Go handler and its comments. It does not establish the settings of every reverse proxy or the behavior of every client.
Checklist for investigating a proxy upload error
- [ ] Compare the public route and direct service response with the same request.
- [ ] Correlate proxy and application logs for one request.
- [ ] Check whether the content length is present and whether it matches the bytes sent.
- [ ] Test the early rejection and the reader limit as separate paths.
- [ ] Keep the rejected body drain capped and explain the cap.
- [ ] Verify body size and decoded image dimensions independently.
- [ ] Confirm the file header and extension agree before sanitizing.
- [ ] Save only the re-encoded output when that is the security policy.
- [ ] Test through the deployed proxy before claiming the client response is fixed.
- [ ] Record which observations are from source, tests, or production logs.
The same upload had different outcomes
Commit 33cb05b, dated August 26, 2026, records an oversized upload returning 502 through Apache and 400 when sent directly to the API. That comparison is the historical observation. It places the discrepancy between the direct API response and the response delivered through the proxy.
The handler comments describe the mechanism: the body limit was crossed while Apache was still forwarding the upload, the Go connection reset, and Apache answered the browser with a bare 502. The intended rejection lost its useful explanation along the way.
That is the documented behavior of this deployment. It should not be read as a universal rule that every call to MaxBytesReader immediately resets a connection. The Go documentation for MaxBytesReader describes a reader that errors when reads exceed the limit and, where possible, tells the response writer to close the connection afterward.
The distinction matters when investigating a similar symptom. A browser's status describes what arrived at the browser. Reading the handler's error branch alone does not establish that its response survived the proxy path.
The limit applies to the request body
The size constant remains small and explicit. Excerpt from internal/handler/upload.go:
const maxFileSize = 5 << 20 // 5MB The expression is 5 MiB. The comment and user-facing message say MB, but the binary count is what the code enforces. More precisely, this is a request body limit. A multipart upload includes framing and fields as well as the file, so it is not an exact allowance of 5 MiB for image bytes alone.
The handler first checks authentication and returns 401 if claims are absent. The length decision discussed here happens after that check. It is not a description of how an anonymous request is processed.
Choosing this body threshold is an application design decision. The source supplies the value; it does not establish that this is the right threshold for every upload service.
Reject by declared length before parsing
The added branch sits before the reader wrapper and multipart parser. Excerpt from internal/handler/upload.go:
if r.ContentLength > maxFileSize { io.CopyN(io.Discard, r.Body, 5*maxFileSize) writeJSON(w, http.StatusBadRequest, map[string]string{"message": "file too large (max 5MB)"}) return } The decision uses the declared length before reading body bytes. Once that length exceeds the threshold, the handler already knows which response it intends to send. It does not parse the multipart form or inspect the image on this branch.
It still reads from the body before responding. Those bytes go to io.Discard; they are not accepted as an upload. The purpose, stated in the comments, is to let the 400 travel back through Apache while accommodating data the client has already sent.
The selected status is 400, with the existing file-too-large message. That is the application's response choice. The patch changes how the handler reaches that response rather than increasing the accepted upload size.
The drain has its own ceiling
Multiplying the body threshold by five gives a discard cap of 25 MiB. That value is a separate design decision: the comments explicitly reject unbounded draining as a way for a client to make the server keep reading indefinitely.
The operation attempts to copy up to that amount from the body. The Go documentation for CopyN says it copies the requested count or stops on an error, returning the copied count and an error. This handler ignores both return values and proceeds to write 400.
For bodies whose actual size matches their declared size, a body over 5 MiB and no larger than 25 MiB fits within the drain budget. An earlier read error can still prevent a complete drain. A body larger than 25 MiB can leave bytes unread after the cap is reached.
Those consequences follow from the code. They do not establish that every oversized upload now avoids 502. The handler attempts a bounded accommodation of the proxy path; it does not verify that all bytes were consumed or guarantee delivery of its response.
A byte cap also should not be described as a time limit. This branch specifies an amount to discard, not a duration. Any claim about complete timeout protection would need evidence beyond these lines.
Unknown lengths keep the reader guard
The declared length check is not the only protection. Requests that bypass it still encounter the original wrapper and parser. Excerpt from internal/handler/upload.go:
r.Body = http.MaxBytesReader(w, r.Body, maxFileSize) if err := r.ParseMultipartForm(maxFileSize); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"message": "file too large (max 5MB)"}) return } This includes requests without a declared length and those declaring a length at or below the threshold. The nearby comment also identifies an inaccurate declaration as a reason to retain the reader guard.
The branches therefore serve different purposes. Declared oversize selects the capped drain and rejection. Otherwise, actual reading remains bounded by MaxBytesReader before multipart parsing. Removing that wrapper would discard the protection for requests the length check cannot reject in advance.
There is another detail in the error handling: every error returned by this multipart parsing call maps to the same file-too-large message. Seeing that message is not, by itself, proof that the reader limit caused the parse error.
What is verified and what remains open
The history documents the earlier proxy failure. The current source contains the declared-length branch, the 25 MiB discard cap, and the retained 5 MiB reader limit. These are inspectable implementation facts.
The graphics README reports no regression test for the declared-length drain or Apache workaround in this checkout. Nearby image tests cover other concerns. They do not demonstrate that this response reaches a browser through Apache, and no such regression coverage is claimed here.
For a similar investigation, compare direct and proxied responses, then inspect what happens to unread request data when the handler rejects it. Keep the acceptance threshold, drain budget, and unknown-length behavior explicit. This patch makes those choices visible, while leaving a clear boundary around what its code and historical observation can support.
Sources
- Go net/http documentation for MaxBytesReader:
- Go io documentation for CopyN:
- RadarMakers source: internal/handler/upload.go and the recorded change 33cb05b dated August 26, 2026.




