diff --git a/README.md b/README.md index feaefe3..33cb34c 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,14 @@ curl -s -i http://localhost/lancache-heartbeat | Field | Meaning | | --- | --- | | `cache_hits` / `cache_misses` / `hit_rate` | Whether later requests were served from cache | +| `range_cache` / `range_upstream` | Range GETs served as 206 from a cached object vs after a full upstream fetch | | `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits | | `total_requests` / `errors` | Volume and failures | A first pass through new content is mostly misses (`hit_rate` near 0). Repeat the same content and `cache_hits` / `hit_rate` should rise. +Steam clients lean on Range requests. When an object is already cached, a Range GET is served locally as 206 from that full object (`range_cache`). On a Range miss the cache still fetches and stores the full upstream body, then returns the requested byte range as 206 (`range_upstream`). + To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`): ```bash @@ -179,7 +182,7 @@ The recommended validation config is at [docs/examples/validate-config.yaml](doc Running a realistic SteamPrefill benchmark workload through a built steamcache2 exercises the complete public surface that matters for production use: - Steam User-Agent detection and depot/manifest/chunk URL patterns - Full MISS → cache write → HIT (and HIT-COALESCED) paths -- Range request handling from cached full responses +- Range request handling from cached full responses (local 206 on HIT via `range_cache`; MISS fetches the full object then serves the requested slice as 206 via `range_upstream`) - Request coalescing under concurrent load - Memory tier + disk tier interaction (including async disk attach) - Garbage collection and eviction under pressure diff --git a/steamcache/format.go b/steamcache/format.go index 55862e4..d788a0c 100644 --- a/steamcache/format.go +++ b/steamcache/format.go @@ -262,6 +262,13 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques // Send the range data _, _ = w.Write(rangeData) // client write error ignored (disconnect during range body send is not actionable) + // Range served from cache: count the range-specific metric and the range + // bytes actually written (handleCacheHit skips full-blob byte counting for + // Range requests so these are not double-counted). + sc.metrics.IncrementRangeCache() + sc.metrics.AddBytesServed(int64(len(rangeData))) + sc.metrics.AddBytesSaved(int64(len(rangeData))) + logger.Logger.Info(). Str("cache_key", cacheKey). Str("url", r.URL.String()). diff --git a/steamcache/handler.go b/steamcache/handler.go index 4f3dbf7..89f7cfb 100644 --- a/steamcache/handler.go +++ b/steamcache/handler.go @@ -109,8 +109,14 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac // Track cache hit metrics sc.metrics.IncrementCacheHits() sc.metrics.AddResponseTime(time.Since(tstart)) - sc.metrics.AddBytesServed(int64(len(cachedData))) - sc.metrics.AddBytesSaved(int64(len(cachedData))) + if r.Header.Get("Range") == "" { + // Full-object HIT: count the cached blob served. Range HITs skip + // this here — streamCachedResponse counts the range bytes actually + // written to the client instead, so BytesServed/Saved reflect the + // partial body and are not double-counted. + sc.metrics.AddBytesServed(int64(len(cachedData))) + sc.metrics.AddBytesSaved(int64(len(cachedData))) + } sc.metrics.IncrementServiceRequests(service.Name) logger.Logger.Debug(). @@ -549,14 +555,40 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-LanCache-Status", "MISS") w.Header().Set("X-LanCache-Processed-By", "SteamCache2") - // Stream the response body to client - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable) + // Stream the response body to client. + // Range miss: the Range header was stripped for the upstream fetch (so the + // FULL object is cached below); serve the client's requested slice from the + // full body as 206, matching the HIT Range path. + if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { + start, end, totalSize, rangeValid := parseRangeHeader(rangeHeader, int64(len(bodyData))) + if !rangeValid { + // Invalid range — 416 (consistent with the HIT Range path). Drop the + // upstream Content-Length: it describes the full body, which 416 does + // not send (a stale CL would hang clients waiting for a body). + w.Header().Del("Content-Length") + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", len(bodyData))) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + } else { + rangeData := bodyData[start : end+1] + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(rangeData))) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(rangeData) // client write error ignored (disconnect during MISS range body send is not actionable) + + // Range required an upstream fetch (full object) then served as 206 + sc.metrics.IncrementRangeUpstream() + sc.metrics.AddBytesServed(int64(len(rangeData))) // range bytes only, not the full cached object + } + } else { + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable) + sc.metrics.AddBytesServed(int64(len(bodyData))) + } // Track cache miss metrics sc.metrics.IncrementCacheMisses() sc.metrics.AddResponseTime(time.Since(tstart)) - sc.metrics.AddBytesServed(int64(len(bodyData))) sc.metrics.IncrementServiceRequests(service.Name) // Verify we received the complete file by checking Content-Length diff --git a/steamcache/metrics/metrics.go b/steamcache/metrics/metrics.go index b4dfea4..ff44ba5 100644 --- a/steamcache/metrics/metrics.go +++ b/steamcache/metrics/metrics.go @@ -16,6 +16,8 @@ type Metrics struct { CacheHits int64 CacheMisses int64 CacheCoalesced int64 + RangeCache int64 // Range requests served as 206 from an already-cached object (HIT) + RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206 Errors int64 RateLimited int64 @@ -78,6 +80,17 @@ func (m *Metrics) IncrementCacheCoalesced() { atomic.AddInt64(&m.CacheCoalesced, 1) } +// IncrementRangeCache increments the Range-from-cache counter (HIT served as 206) +func (m *Metrics) IncrementRangeCache() { + atomic.AddInt64(&m.RangeCache, 1) +} + +// IncrementRangeUpstream increments the Range-from-upstream counter (full object +// fetched upstream, requested slice served as 206) +func (m *Metrics) IncrementRangeUpstream() { + atomic.AddInt64(&m.RangeUpstream, 1) +} + // IncrementErrors increments the error counter func (m *Metrics) IncrementErrors() { atomic.AddInt64(&m.Errors, 1) @@ -188,6 +201,8 @@ func (m *Metrics) GetStats() *Stats { CacheHits: cacheHits, CacheMisses: cacheMisses, CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced), + RangeCache: atomic.LoadInt64(&m.RangeCache), + RangeUpstream: atomic.LoadInt64(&m.RangeUpstream), Errors: atomic.LoadInt64(&m.Errors), RateLimited: atomic.LoadInt64(&m.RateLimited), HitRate: hitRate, @@ -215,6 +230,8 @@ func (m *Metrics) Reset() { atomic.StoreInt64(&m.CacheHits, 0) atomic.StoreInt64(&m.CacheMisses, 0) atomic.StoreInt64(&m.CacheCoalesced, 0) + atomic.StoreInt64(&m.RangeCache, 0) + atomic.StoreInt64(&m.RangeUpstream, 0) atomic.StoreInt64(&m.Errors, 0) atomic.StoreInt64(&m.RateLimited, 0) atomic.StoreInt64(&m.TotalResponseTime, 0) @@ -244,6 +261,8 @@ type Stats struct { CacheHits int64 CacheMisses int64 CacheCoalesced int64 + RangeCache int64 + RangeUpstream int64 Errors int64 RateLimited int64 HitRate float64 @@ -276,6 +295,8 @@ func WriteText(w http.ResponseWriter, stats *Stats) { _, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits) _, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses) _, _ = fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced) + _, _ = fmt.Fprintf(w, "range_cache %d\n", stats.RangeCache) + _, _ = fmt.Fprintf(w, "range_upstream %d\n", stats.RangeUpstream) _, _ = fmt.Fprintf(w, "errors %d\n", stats.Errors) _, _ = fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited) _, _ = fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors) diff --git a/steamcache/range_test.go b/steamcache/range_test.go new file mode 100644 index 0000000..66b17e6 --- /dev/null +++ b/steamcache/range_test.go @@ -0,0 +1,386 @@ +// steamcache/range_test.go +package steamcache + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestRangeHitServedLocally verifies that a Range GET against an already-cached +// object is served locally as 206 (no upstream re-fetch) and increments range_cache. +func TestRangeHitServedLocally(t *testing.T) { + body := []byte("0123456789abcdef") // 16 bytes + var upstreamCalls atomic.Int64 + f := func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + } + sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") + srv := newCacheServer(t, sc) + c := &http.Client{Timeout: 5 * time.Second} + + // 1) Populate the cache with a full (non-Range) MISS. + req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + resp, err := c.Do(req) + if err != nil { + t.Fatalf("miss GET: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("miss GET: expected 200, got %d", resp.StatusCode) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if got := sc.GetMetrics().CacheMisses; got < 1 { + t.Fatalf("expected CacheMisses >= 1 after first GET, got %d", got) + } + + // 2) Range GET against the same URL — must be a local 206 HIT. + req2, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + req2.Header.Set("Range", "bytes=4-7") + resp2, err := c.Do(req2) + if err != nil { + t.Fatalf("range GET: %v", err) + } + data, err := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + if err != nil { + t.Fatalf("read range body: %v", err) + } + if resp2.StatusCode != http.StatusPartialContent { + t.Fatalf("range GET: expected 206, got %d", resp2.StatusCode) + } + if string(data) != "4567" { + t.Errorf("range GET: expected body %q, got %q", "4567", data) + } + if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" { + t.Errorf("range GET: expected X-LanCache-Status HIT, got %q", got) + } + if got := resp2.Header.Get("Content-Range"); got != "bytes 4-7/16" { + t.Errorf("range GET: expected Content-Range bytes 4-7/16, got %q", got) + } + if got := resp2.Header.Get("Accept-Ranges"); got != "bytes" { + t.Errorf("range GET: expected Accept-Ranges bytes, got %q", got) + } + + // Upstream must NOT have been hit again. + if got := upstreamCalls.Load(); got != 1 { + t.Errorf("upstream hit %d times, want exactly 1 (range HIT must be local)", got) + } + + // range_cache incremented, range_upstream untouched. + stats := sc.GetMetrics() + if stats.RangeCache != 1 { + t.Errorf("expected RangeCache == 1 after range HIT, got %d", stats.RangeCache) + } + if stats.RangeUpstream != 0 { + t.Errorf("expected RangeUpstream == 0 (no range miss yet), got %d", stats.RangeUpstream) + } + if stats.CacheHits < 1 { + t.Errorf("expected CacheHits >= 1 after range HIT, got %d", stats.CacheHits) + } + // BytesServed: 16 (full miss body) + 4 (range bytes) = 20. + if stats.TotalBytesServed != 20 { + t.Errorf("expected TotalBytesServed == 20 (16 + range 4), got %d", stats.TotalBytesServed) + } +} + +// TestRangeMissServes206FromFullFetch verifies that a Range GET on a cold key fetches +// the FULL object from upstream (Range stripped), caches it, and serves the requested +// slice as 206 with range_upstream incremented. +func TestRangeMissServes206FromFullFetch(t *testing.T) { + body := []byte("0123456789abcdef") // 16 bytes + var upstreamCalls atomic.Int64 + var upstreamSawRange atomic.Bool + f := func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + if r.Header.Get("Range") != "" { + upstreamSawRange.Store(true) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + } + sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") + srv := newCacheServer(t, sc) + c := &http.Client{Timeout: 5 * time.Second} + + // Range GET on a cold key. + req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + req.Header.Set("Range", "bytes=0-3") + resp, err := c.Do(req) + if err != nil { + t.Fatalf("range miss GET: %v", err) + } + data, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("read body: %v", err) + } + if resp.StatusCode != http.StatusPartialContent { + t.Fatalf("range miss GET: expected 206, got %d", resp.StatusCode) + } + if string(data) != "0123" { + t.Errorf("range miss GET: expected body %q, got %q", "0123", data) + } + if got := resp.Header.Get("X-LanCache-Status"); got != "MISS" { + t.Errorf("range miss GET: expected X-LanCache-Status MISS, got %q", got) + } + if got := resp.Header.Get("Content-Range"); got != "bytes 0-3/16" { + t.Errorf("range miss GET: expected Content-Range bytes 0-3/16, got %q", got) + } + if got := resp.Header.Get("Accept-Ranges"); got != "bytes" { + t.Errorf("range miss GET: expected Accept-Ranges bytes, got %q", got) + } + + // Range must have been stripped for the upstream fetch (full file cached). + if upstreamSawRange.Load() { + t.Error("upstream received a Range header; Range must be stripped so the full object is cached") + } + if got := upstreamCalls.Load(); got != 1 { + t.Errorf("upstream hit %d times, want exactly 1", got) + } + + // range_upstream incremented, range_cache untouched. + stats := sc.GetMetrics() + if stats.RangeUpstream != 1 { + t.Errorf("expected RangeUpstream == 1 after range MISS, got %d", stats.RangeUpstream) + } + if stats.RangeCache != 0 { + t.Errorf("expected RangeCache == 0 (no range hit yet), got %d", stats.RangeCache) + } + if stats.CacheMisses < 1 { + t.Errorf("expected CacheMisses >= 1, got %d", stats.CacheMisses) + } + // BytesServed for the range miss: only the 4 range bytes, not the 16-byte fetch. + if stats.TotalBytesServed != 4 { + t.Errorf("expected TotalBytesServed == 4 (range bytes only), got %d", stats.TotalBytesServed) + } + + // The FULL object must have been cached: a subsequent full GET is a HIT with the + // complete 16-byte body. + req3, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req3.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + resp3, err := c.Do(req3) + if err != nil { + t.Fatalf("full GET after range miss: %v", err) + } + fullData, err := io.ReadAll(resp3.Body) + _ = resp3.Body.Close() + if err != nil { + t.Fatalf("read full body: %v", err) + } + if resp3.StatusCode != http.StatusOK { + t.Fatalf("full GET after range miss: expected 200, got %d", resp3.StatusCode) + } + if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" { + t.Errorf("full GET after range miss: expected X-LanCache-Status HIT, got %q", got) + } + if len(fullData) != len(body) || string(fullData) != string(body) { + t.Errorf("full GET after range miss: expected full %d-byte body, got %d bytes", len(body), len(fullData)) + } + if got := upstreamCalls.Load(); got != 1 { + t.Errorf("upstream hit %d times after HIT, want still 1", got) + } +} + +// TestRangeMissInvalidRange416 verifies that an unsatisfiable Range on a cold key +// fetches upstream, returns 416 (as on the HIT path), and does not count range_upstream. +func TestRangeMissInvalidRange416(t *testing.T) { + body := []byte("0123456789abcdef") // 16 bytes + var upstreamCalls atomic.Int64 + f := func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + _, _ = w.Write(body) + } + sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") + srv := newCacheServer(t, sc) + c := &http.Client{Timeout: 5 * time.Second} + + req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk3", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + req.Header.Set("Range", "bytes=100-200") + resp, err := c.Do(req) + if err != nil { + t.Fatalf("invalid range GET: %v", err) + } + data, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("read body: %v", err) + } + if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("invalid range GET: expected 416, got %d", resp.StatusCode) + } + if len(data) != 0 { + t.Errorf("invalid range GET: expected empty body, got %d bytes", len(data)) + } + if got := resp.Header.Get("Content-Range"); got != "bytes */16" { + t.Errorf("invalid range GET: expected Content-Range bytes */16, got %q", got) + } + if got := upstreamCalls.Load(); got != 1 { + t.Errorf("upstream hit %d times, want exactly 1 (fetch happens, then 416 to client)", got) + } + stats := sc.GetMetrics() + if stats.RangeUpstream != 0 { + t.Errorf("expected RangeUpstream == 0 for unsatisfiable range, got %d", stats.RangeUpstream) + } + if stats.RangeCache != 0 { + t.Errorf("expected RangeCache == 0, got %d", stats.RangeCache) + } +} + +// TestRangeMetricsWriteText verifies /metrics emits the range_cache and range_upstream +// lines with the expected values after a range HIT and a range MISS. +func TestRangeMetricsWriteText(t *testing.T) { + body := []byte("0123456789abcdef") + f := func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + } + sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0") + srv := newCacheServer(t, sc) + c := &http.Client{Timeout: 5 * time.Second} + + get := func(path, rangeHeader string) int { + t.Helper() + req, err := http.NewRequest("GET", srv.URL+path, nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + if rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + resp, err := c.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return resp.StatusCode + } + + // Range MISS on cold key -> range_upstream; warm it; range HIT -> range_cache. + if code := get("/depot/rangetest/wt/1", "bytes=0-3"); code != http.StatusPartialContent { + t.Fatalf("range miss: expected 206, got %d", code) + } + if code := get("/depot/rangetest/wt/2", ""); code != http.StatusOK { + t.Fatalf("warm miss: expected 200, got %d", code) + } + if code := get("/depot/rangetest/wt/2", "bytes=8-11"); code != http.StatusPartialContent { + t.Fatalf("range hit: expected 206, got %d", code) + } + + req, err := http.NewRequest("GET", srv.URL+"/metrics", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := c.Do(req) + if err != nil { + t.Fatalf("GET /metrics: %v", err) + } + out, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("read /metrics: %v", err) + } + text := string(out) + if !strings.Contains(text, "range_cache 1\n") { + t.Errorf("/metrics missing 'range_cache 1' line:\n%s", text) + } + if !strings.Contains(text, "range_upstream 1\n") { + t.Errorf("/metrics missing 'range_upstream 1' line:\n%s", text) + } +} + +// TestStreamCachedResponseRange206 is a focused unit test for streamCachedResponse: +// valid Range yields 206 with the exact slice + metrics; invalid Range yields 416 +// with no range metrics. +func TestStreamCachedResponseRange206(t *testing.T) { + body := []byte("0123456789abcdef") // 16 bytes + raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n"), body...) + serialized, err := serializeRawResponse(raw) + if err != nil { + t.Fatalf("serialize cache file: %v", err) + } + cf, err := deserializeCacheFile(serialized) + if err != nil { + t.Fatalf("build cache file: %v", err) + } + + sc, _ := newTestCacheWithFakeUpstream(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("x")) + }, "1MB", "0") + sc.ResetMetrics() + + // Valid range -> 206 + slice + metrics. + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil) + req.Header.Set("Range", "bytes=4-7") + sc.streamCachedResponse(rec, req, cf, "steam/testkey", "127.0.0.1", time.Now()) + + if rec.Code != http.StatusPartialContent { + t.Fatalf("expected 206, got %d", rec.Code) + } + if rec.Body.String() != "4567" { + t.Errorf("expected body %q, got %q", "4567", rec.Body.String()) + } + if got := rec.Header().Get("Content-Range"); got != "bytes 4-7/16" { + t.Errorf("expected Content-Range bytes 4-7/16, got %q", got) + } + if got := rec.Header().Get("X-LanCache-Status"); got != "HIT" { + t.Errorf("expected X-LanCache-Status HIT, got %q", got) + } + stats := sc.GetMetrics() + if stats.RangeCache != 1 { + t.Errorf("expected RangeCache == 1, got %d", stats.RangeCache) + } + if stats.TotalBytesServed != 4 { + t.Errorf("expected TotalBytesServed == 4 (range bytes), got %d", stats.TotalBytesServed) + } + if stats.TotalBytesSaved != 4 { + t.Errorf("expected TotalBytesSaved == 4 (range bytes), got %d", stats.TotalBytesSaved) + } + + // Invalid range -> 416, no range metrics, no bytes served. + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil) + req2.Header.Set("Range", "bytes=100-200") + sc.streamCachedResponse(rec2, req2, cf, "steam/testkey", "127.0.0.1", time.Now()) + + if rec2.Code != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("expected 416, got %d", rec2.Code) + } + if got := rec2.Header().Get("Content-Range"); got != "bytes */16" { + t.Errorf("expected Content-Range bytes */16, got %q", got) + } + stats = sc.GetMetrics() + if stats.RangeCache != 1 { + t.Errorf("RangeCache must stay 1 after unsatisfiable range, got %d", stats.RangeCache) + } + if stats.TotalBytesServed != 4 { + t.Errorf("TotalBytesServed must stay 4 after 416, got %d", stats.TotalBytesServed) + } +}