carlos / platform Public

edge: a 103 must not be the status the recorder remembers

The recordingResponseWriter latches the first WriteHeader status. When
httputil.ReverseProxy forwards an upstream's 103 Early Hints, the recorder
would latch 103 instead of the final 2xx status that follows. maybeStore's
2xx check then refused to cache, breaking caching for apps that emit
Early Hints.

Pass through 1xx responses without recording them; wait for the final status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cb654c4d9f2f306edd94e52816be26ade93c6c93 parent 777d529
2 files changed, +60 −0
  • internal/edge/edge.go +8 −0
  • internal/edge/edge_test.go +52 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 916d8ce09..8c5372eb7 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -627,6 +627,14 @@ type recordingResponseWriter struct {
}
func (r *recordingResponseWriter) WriteHeader(status int) {
+ // 1xx responses (103 Early Hints, forwarded by httputil.ReverseProxy)
+ // precede the final status; latching one would record it as THE status
+ // and maybeStore's 2xx check would then refuse the real response that
+ // follows. Pass them through and keep waiting for the final one.
+ if status < 200 {
+ r.ResponseWriter.WriteHeader(status)
+ return
+ }
if !r.wroteHdr {
r.status = status
r.wroteHdr = true
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 651fd1df7..59008f1ef 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -13,6 +13,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "slices"
"strings"
"sync/atomic"
"syscall"
@@ -1412,3 +1413,54 @@ func TestCacheBeforeWakeFullCycle(t *testing.T) {
t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes)
}
}
+
+type mockResponseWriterFor1xx struct {
+ header http.Header
+ codes []int
+}
+
+func (m *mockResponseWriterFor1xx) Header() http.Header {
+ return m.header
+}
+
+func (m *mockResponseWriterFor1xx) WriteHeader(status int) {
+ // Stand-in for a real ResponseWriter. httptest.ResponseRecorder rejects
+ // writes after a 1xx status (RFC 9110 forbids bodies on 1xx), but a real
+ // ResponseWriter accepts the sequence 1xx then 2xx (103 Early Hints
+ // followed by the final response status) and forwards both. This mock
+ // accepts and records both.
+ m.codes = append(m.codes, status)
+}
+
+func (m *mockResponseWriterFor1xx) Write(p []byte) (int, error) {
+ return len(p), nil
+}
+
+func TestRecordingWriterIgnoresInformational(t *testing.T) {
+ mock := &mockResponseWriterFor1xx{header: make(http.Header)}
+
+ rec := &recordingResponseWriter{
+ ResponseWriter: mock,
+ request: httptest.NewRequest("GET", "http://app.example/x", nil),
+ cacheable: true,
+ maxBody: 1 << 20,
+ }
+ rec.WriteHeader(http.StatusEarlyHints)
+ rec.Header().Set("Cache-Control", "max-age=60")
+ rec.WriteHeader(http.StatusOK)
+ if _, err := rec.Write([]byte("final body")); err != nil {
+ t.Fatal(err)
+ }
+ if rec.status != http.StatusOK {
+ t.Fatalf("recorded status %d, want %d — informational must not latch", rec.status, http.StatusOK)
+ }
+ if !slices.Equal(mock.codes, []int{http.StatusEarlyHints, http.StatusOK}) {
+ t.Fatalf("underlying writer saw %v, want [103 200] — the 103 must be forwarded, not swallowed", mock.codes)
+ }
+ e := &Edge{cache: NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)}
+ key := CacheKey{Host: "app.example", Method: "GET", Path: "/x"}
+ e.maybeStore(key, rec)
+ if _, ok := e.cache.get(key); !ok {
+ t.Fatal("response written after a 103 was not cached")
+ }
+}