Why a Go File Server Answers 200 to a Range Request
You send Range: bytes=0-99 to your Go file server and get a 200 and the entire file back. Is that a bug? Usually not. A server is allowed to ignore a Range header, and Go's standard library does so in a few specific situations. The trick is knowing which ones, because "refusing" a range is a different status code entirely.
The three possible answers
For a GET with a Range header, a server can legitimately say one of three things:
206 Partial Content: here are the bytes you asked for.200 OK: I'm ignoring the range, here is everything.416 Range Not Satisfiable: your range makes no sense for this file.
RFC 9110 (the HTTP semantics spec, rfc-editor.org/rfc/rfc9110) treats range support as optional for the server. So a 200 is not a violation; an unexpected 200 is a clue about what the server decided.
A handler that never honours ranges
First suspect: your own handler. If you open the file and io.Copy it, there is no range logic at all.
func naive(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("big.bin")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
io.Copy(w, f) // 200, full body, no Accept-Ranges
}
Every request gets a 200. Resumable downloads and video seeking quietly stop working, and nothing logs an error. Hand the work to the standard library instead:
func better(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("big.bin")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
http.Error(w, "stat failed", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
}
http.FileServer and http.ServeFile go through the same ServeContent machinery, so they behave identically. The content needs to be an io.ReadSeeker, which *os.File is.
Probe it with curl
Make a 1000-byte file and send a few ranges. The -D - flag prints response headers; -o /dev/null discards the body.
head -c 1000 /dev/urandom > big.bin
curl -s -D - -o /dev/null -H 'Range: bytes=0-99' localhost:8080/
curl -s -D - -o /dev/null -H 'Range: bytes=5000-' localhost:8080/
curl -s -D - -o /dev/null -H 'Range: bytes=0-99,0-99,0-99' localhost:8080/
With ServeContent you should see three different outcomes, and the first is the boring one:
bytes=0-99gives206withContent-Range: bytes 0-99/1000.bytes=5000-starts past the end of the file, so you get416withContent-Range: bytes */1000.- The repeated range gives
200and the full body.
That last one is the surprise.
Why overlapping ranges get the whole file
Quick detour, because this is a neat defence. Multiple ranges in one request produce a multipart/byteranges response. A hostile client could ask for the same 1 KiB a few thousand times and make the server send gigabytes from a tiny file.
So Go adds up the sizes of the requested ranges. If the total exceeds the file size, it drops the ranges and sends the whole file once with 200. The client gets a bounded response, and the amplification is gone. Clever, and a little daft that it falls out of one comparison.
Other reasons you see 200
If your ranges are sane and you still get 200, check these:
- A failed
If-Range. Download managers sendIf-Rangewith an ETag or date so a changed file is not stitched together with the old part. If it does not match, the server must ignore the range and send the new file whole. Go compares the date against the modtime you passed toServeContent. A zero modtime or one that changes on every request means the validator never matches. - A proxy or middleware in front. Something that buffers or rewrites the response, or strips
Range, will produce200however good your handler is. Hit the Go server directly to separate the two. - A method other than GET or HEAD. Ranges only apply to those.
Note that an If-Range failure is a feature. Silently serving a stale half to a client resuming a download is the thing it exists to prevent.
What Go does refuse
Go answers 416 when the header is malformed or no range overlaps the file. A unit other than bytes is also treated as invalid, so Range: lines=1-2 gets a 416, not a shrug. A range that starts inside the file but ends past it is clamped: bytes=900-99999 returns 206 with the last 100 bytes.
If your own requirement is "reject anything that is not a single range", wrap the handler and check the header before calling ServeContent:
func singleRangeOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("Range"), ",") {
http.Error(w, "multiple ranges not supported", http.StatusRequestedRangeNotSatisfiable)
return
}
next.ServeHTTP(w, r)
})
}
This is deliberately blunt: it does not parse the header, it just refuses any comma. That is fine for a policy of "one range or none", but it is not a validator.
Checking your own server
Look for Accept-Ranges: bytes on a plain GET. If it is missing, your handler never went through ServeContent. Then run the three curl commands above. If the first one returns 200, work outward: bypass the proxy, check the modtime, and check for an If-Range header that your client added without telling you.