repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
034d3f3
parent
588159a
author
Eric Bower
date
2026-04-19 10:50:47 -0400 EDT
fix(pgs): httpcache rw.Write needs to report the num of bytes
3 files changed,  +121, -13
M pkg/httpcache/cache_test.go
+115, -0
  1@@ -799,6 +799,121 @@ func TestCache304NotModifiedMerge(t *testing.T) {
  2 	}
  3 }
  4 
  5+func TestCacheUpstreamResponseBody(t *testing.T) {
  6+	expectedBody := strings.Repeat("hello world! ", 1000)
  7+	mux := http.NewServeMux()
  8+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  9+		w.Header().Set("content-length", strconv.Itoa(len(expectedBody)))
 10+		w.WriteHeader(200)
 11+		_, _ = w.Write([]byte(expectedBody))
 12+	})
 13+
 14+	logger := slog.Default()
 15+	handler := NewHttpCache(logger, mux)
 16+	tc := NewTestContext(t, handler)
 17+	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 18+
 19+	// first request goes to upstream
 20+	resp1, _ := tc.Do(req)
 21+	if resp1.StatusCode != http.StatusOK {
 22+		t.Fatalf("expected 200, got %d", resp1.StatusCode)
 23+	}
 24+	body1, _ := readBody(resp1)
 25+	if body1 != expectedBody {
 26+		t.Errorf("upstream body mismatch: got %d bytes, want %d bytes", len(body1), len(expectedBody))
 27+	}
 28+
 29+	// second request served from cache
 30+	resp2, _ := tc.Do(req)
 31+	if resp2.StatusCode != http.StatusOK {
 32+		t.Fatalf("expected 200, got %d", resp2.StatusCode)
 33+	}
 34+	body2, _ := readBody(resp2)
 35+	if body2 != expectedBody {
 36+		t.Errorf("cached body mismatch: got %d bytes, want %d bytes", len(body2), len(expectedBody))
 37+	}
 38+}
 39+
 40+func TestCacheUpstreamStatusCode(t *testing.T) {
 41+	mux := http.NewServeMux()
 42+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 43+		w.WriteHeader(201)
 44+		_, _ = w.Write([]byte("created"))
 45+	})
 46+
 47+	logger := slog.Default()
 48+	handler := NewHttpCache(logger, mux)
 49+	tc := NewTestContext(t, handler)
 50+	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 51+
 52+	resp, _ := tc.Do(req)
 53+	if resp.StatusCode != 201 {
 54+		t.Errorf("expected 201, got %d", resp.StatusCode)
 55+	}
 56+	body, _ := readBody(resp)
 57+	if body != "created" {
 58+		t.Errorf("expected body 'created', got %q", body)
 59+	}
 60+}
 61+
 62+// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
 63+// Even if the upstream handler writes body bytes with a 304,
 64+// the cache layer must strip them before sending to the client.
 65+func TestCache304NoBody(t *testing.T) {
 66+	mux := http.NewServeMux()
 67+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 68+		if r.Header.Get("If-None-Match") == "\"abc\"" {
 69+			w.WriteHeader(http.StatusNotModified)
 70+			// Misbehaving upstream writes body alongside 304
 71+			_, _ = w.Write([]byte("should not appear"))
 72+			return
 73+		}
 74+		w.Header().Set("etag", "\"abc\"")
 75+		w.Header().Set("cache-control", "max-age=60, must-revalidate")
 76+		w.WriteHeader(200)
 77+		_, _ = w.Write([]byte("original body"))
 78+	})
 79+
 80+	logger := slog.Default()
 81+	handler := NewHttpCache(logger, mux)
 82+	tc := NewTestContext(t, handler)
 83+
 84+	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
 85+
 86+	// Populate cache with a stale must-revalidate entry so revalidation is triggered
 87+	cacheKey := handler.GetCacheKey(req)
 88+	cv := testCacheValue(250 * time.Second)
 89+	cv.Header["ETag"] = []string{"\"abc\""}
 90+	cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
 91+	cv.Body = []byte("original body")
 92+	cacheData, _ := json.Marshal(cv)
 93+	handler.Cache.Add(cacheKey, cacheData)
 94+
 95+	// Trigger revalidation — upstream returns 304 with a spurious body
 96+	resp, _ := tc.Do(req)
 97+	if resp.StatusCode != http.StatusNotModified {
 98+		t.Fatalf("expected 304, got %d", resp.StatusCode)
 99+	}
100+	body, _ := readBody(resp)
101+	if body != "" {
102+		t.Errorf("expected empty body for 304 response, got %q", body)
103+	}
104+}
105+
106+func readBody(resp *http.Response) (string, error) {
107+	defer resp.Body.Close() //nolint:errcheck
108+	buf := make([]byte, 0, 64*1024)
109+	tmp := make([]byte, 4096)
110+	for {
111+		n, err := resp.Body.Read(tmp)
112+		buf = append(buf, tmp[:n]...)
113+		if err != nil {
114+			break
115+		}
116+	}
117+	return string(buf), nil
118+}
119+
120 func TestCacheAgeTtl(t *testing.T) {
121 	mux := http.NewServeMux()
122 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
M pkg/httpcache/rw.go
+6, -3
 1@@ -15,11 +15,9 @@ func (rw *responseWriter) WriteHeader(code int) {
 2 	rw.statusCode = code
 3 }
 4 
 5-// TODO: is there a way to preserve streaming to the base response writer while being able to set headers?
 6 func (rw *responseWriter) Write(data []byte) (int, error) {
 7 	rw.body = append(rw.body, data...)
 8-	// return rw.ResponseWriter.Write(data)
 9-	return 0, nil
10+	return len(data), nil
11 }
12 
13 // Body returns the captured response body.
14@@ -37,6 +35,11 @@ func (rw *responseWriter) StatusCode() int {
15 
16 func (rw *responseWriter) Send() {
17 	rw.ResponseWriter.WriteHeader(rw.StatusCode())
18+	// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
19+	if rw.StatusCode() == http.StatusNotModified {
20+		return
21+	}
22+	_, _ = rw.ResponseWriter.Write(rw.body)
23 }
24 
25 func (rw *responseWriter) ToCacheValue() *CacheValue {
M pkg/httpcache/serve.go
+0, -10
 1@@ -147,16 +147,6 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 2 	}
 3 
 4 	wrapped.Send()
 5-	// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
 6-	// Skip writing body for 304 responses even if upstream wrote one.
 7-	var total int
 8-	if wrapped.StatusCode() != http.StatusNotModified {
 9-		total, err = wrapped.ResponseWriter.Write(wrapped.Body())
10-	}
11-	log.Info("response writer", "bytes_written", total)
12-	if err != nil {
13-		log.Error("response writer write", "err", err)
14-	}
15 }
16 
17 // isForbiddenHeader checks if a header should not be stored/served per RFC 9111 Section 3.1