edge: static responses revalidate, on a version ETag
A static response carried Last-Modified and nothing else, so browsers guessed a freshness lifetime from the file's age and reused their copy without asking: a site last deployed a month ago kept showing the old page for days after the next deploy (#195). Every answer out of a site tree now sends Cache-Control: no-cache, and each file an ETag naming the version that served it. Revalidation is a 304 until a deploy changes the version. The mtime was also the wrong validator: it differs between edges and runs backwards on a rollback.
Human
Prompt
OK, go ahead and message it. And let's fix all the bugs.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Static sites send Cache-Control: no-cache and a version ETag, so a deploy shows on the next load instead of after the browser's guessed freshness lifetime.
Agent notes
Discussion #195, item 1 only. Item 2 (letting an app mark paths immutable) is not built. No user-facing strings. A browser entry cached before this change holds no ETag and revalidates on If-Modified-Since alone, so a rollback can still read as unmodified for that one entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+133
−3
internal/edge/static.go+26 −3internal/edge/static_cache_test.go+107 −0
CI passed — run details
diff --git a/internal/edge/static.go b/internal/edge/static.go| index ea43c68ba..da2eba89c 100644 |
| --- a/internal/edge/static.go |
| +++ b/internal/edge/static.go |
| @@ -163,7 +163,16 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // Set before any body is written, so it lands on the tree's own 404s too |
| // (the handler serving that tree IS a version). |
| w.Header().Set("X-Carlos-Version", version) |
| - s.serveTree(dir, w, r) |
| + // Every answer out of the tree is revalidated, its 404s included. With |
| + // Last-Modified alone a browser GUESSES a freshness lifetime — typically a |
| + // tenth of the file's age — and reuses its copy without asking, so a site |
| + // last deployed a month ago kept showing the old page for days after the |
| + // next deploy (discussion #195). no-cache is "store it, but ask first"; |
| + // the ETag serveTree sets is what makes asking cost a 304. It also keeps |
| + // these responses out of the edge's own shared cache, which admits only a |
| + // positive lifetime — as they always were, having named none. |
| + w.Header().Set("Cache-Control", "no-cache") |
| + s.serveTree(dir, version, w, r) |
| } |
| // dir is where version's tree lives once extracted. |
| @@ -363,7 +372,19 @@ func extractSiteTar(data []byte, dst string) error { |
| // serveTree answers one request from an extracted version dir: |
| // http.FileServer semantics with index.html for directories and a clean 404 |
| // (never a directory listing) when neither the file nor an index exists. |
| -func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Request) { |
| +// |
| +// The validator is the VERSION, never the file's mtime. An extracted tree's |
| +// mtimes say when THIS box unpacked it: they differ between edges serving the |
| +// same build, and they run backwards on a rollback — the tree rolled back to |
| +// was unpacked before the one the browser holds, so If-Modified-Since alone |
| +// answers "not modified" and the browser keeps the build that was withdrawn. |
| +// A version names its bytes exactly (trees are immutable once extracted), is |
| +// the same on every edge, and http.ServeFile checks If-None-Match ahead of |
| +// If-Modified-Since. One value for every path is sound: an entity tag is |
| +// scoped to its URL. version is path-safe by construction (ValidVersion: |
| +// [a-zA-Z0-9._-]), so it needs no quoting beyond the quotes. The edge's |
| +// compression weakens it when it gzips (compress.go, weakenETag). |
| +func (s *staticSite) serveTree(dir, version string, w http.ResponseWriter, r *http.Request) { |
| // Clean rooted at "/" so no request path can climb out of the tree. |
| p := path.Clean("/" + r.URL.Path) |
| fp := filepath.Join(dir, filepath.FromSlash(p)) |
| @@ -376,6 +397,8 @@ func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Reques |
| http.NotFound(w, r) |
| return |
| } |
| - // ServeFile brings Content-Type, ranges, If-Modified-Since for free. |
| + // ServeFile brings Content-Type, ranges and the conditional-request |
| + // handling for free; it honours an ETag set before the call. |
| + w.Header().Set("ETag", `"`+version+`"`) |
| http.ServeFile(w, r, fp) |
| } |
diff --git a/internal/edge/static_cache_test.go b/internal/edge/static_cache_test.go| new file mode 100644 |
| index 000000000..d1bb8b53e |
| --- /dev/null |
| +++ b/internal/edge/static_cache_test.go |
| @@ -0,0 +1,107 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// condGet is a GET with the validators a browser holding a cached copy sends. |
| +func condGet(t *testing.T, base, host, path string, hdr map[string]string) (*http.Response, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", base+path, nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + for k, v := range hdr { |
| + req.Header.Set(k, v) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s%s (host %s): %v", base, path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + return resp, string(b) |
| +} |
| + |
| +// A static response used to carry Last-Modified and nothing else, and a |
| +// browser handed only that guesses a freshness lifetime — typically a tenth of |
| +// the file's age — and reuses its copy without asking. A site last deployed a |
| +// month ago kept showing the old page for days after the next deploy |
| +// (discussion #195). no-cache makes every load ask; the version ETag makes the |
| +// answer a cheap 304 until a deploy actually changes it. |
| +func TestStaticResponsesMakeTheBrowserRevalidate(t *testing.T) { |
| + _, _, _, srv := staticFixture(t) |
| + |
| + for _, path := range []string{"/", "/docs/page.html", "/style.css"} { |
| + resp := getPathResp(t, srv.URL, "site.example.com", path) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", path, resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Cache-Control"); got != "no-cache" { |
| + t.Errorf("GET %s Cache-Control = %q, want %q", path, got, "no-cache") |
| + } |
| + if got := resp.Header.Get("ETag"); got != `"v1"` { |
| + t.Errorf("GET %s ETag = %q, want %q (the version whose tree answered)", path, got, `"v1"`) |
| + } |
| + } |
| + |
| + // The tree's own 404 must not be cached on a guess either: the page it |
| + // stands in for may exist in the next deploy. It names no representation, |
| + // so it carries no validator. |
| + resp := getPathResp(t, srv.URL, "site.example.com", "/missing.html") |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Cache-Control"); got != "no-cache" { |
| + t.Errorf("404 Cache-Control = %q, want %q", got, "no-cache") |
| + } |
| + if got := resp.Header.Get("ETag"); got != "" { |
| + t.Errorf("404 ETag = %q, want none", got) |
| + } |
| +} |
| + |
| +// The validator is the VERSION, not the file's mtime. An unchanged version |
| +// revalidates to a 304; a deploy answers 200 with the new bytes; and so does a |
| +// rollback — where the mtime gets it wrong, because the tree rolled back to was |
| +// extracted BEFORE the one the browser holds, so If-Modified-Since alone reads |
| +// "not modified" and the browser keeps the build that was just withdrawn. |
| +func TestStaticValidatorFollowsTheVersion(t *testing.T) { |
| + store, _, priv, srv := staticFixture(t) |
| + promote := func(ver string) { |
| + t.Helper() |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", ver); err != nil { |
| + t.Fatalf("Promote %s: %v", ver, err) |
| + } |
| + } |
| + |
| + resp, _ := condGet(t, srv.URL, "site.example.com", "/", map[string]string{"If-None-Match": `"v1"`}) |
| + if resp.StatusCode != http.StatusNotModified { |
| + t.Fatalf("revalidating v1 against v1 = %d, want 304", resp.StatusCode) |
| + } |
| + |
| + shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"}) |
| + promote("v2") |
| + resp, body := condGet(t, srv.URL, "site.example.com", "/", map[string]string{"If-None-Match": `"v1"`}) |
| + if resp.StatusCode != http.StatusOK || body != "home v2" { |
| + t.Fatalf("revalidating v1 after a deploy = %d %q, want 200 %q", resp.StatusCode, body, "home v2") |
| + } |
| + if got := resp.Header.Get("ETag"); got != `"v2"` { |
| + t.Fatalf("ETag after the deploy = %q, want %q", got, `"v2"`) |
| + } |
| + v2Modified := resp.Header.Get("Last-Modified") |
| + |
| + promote("v1") // roll back to a tree extracted before v2's |
| + resp, body = condGet(t, srv.URL, "site.example.com", "/", map[string]string{ |
| + "If-None-Match": `"v2"`, |
| + "If-Modified-Since": v2Modified, |
| + }) |
| + if resp.StatusCode != http.StatusOK || body != "home v1" { |
| + t.Fatalf("revalidating v2 after a rollback = %d %q, want 200 %q", resp.StatusCode, body, "home v1") |
| + } |
| +} |