- 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
+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) {
+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 {
+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