Articles / Go

  • go
  • net-http
  • http-range
  • servecontent
  • caching
  • debugging

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-99 gives 206 with Content-Range: bytes 0-99/1000.
  • bytes=5000- starts past the end of the file, so you get 416 with Content-Range: bytes */1000.
  • The repeated range gives 200 and 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 send If-Range with 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 to ServeContent. 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 produce 200 however 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.