From 12f3221aed1941f4541efdb76fab858933b6b582 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 19 Jun 2026 17:31:16 +0200 Subject: [PATCH] http plugin: honor cache when upstream sends no-store/max-age=0 (#31028) --- plugin/http.go | 44 ++++++++++++++++++++++++++++++-------------- plugin/http_test.go | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/plugin/http.go b/plugin/http.go index 88344c97c..30c844be5 100644 --- a/plugin/http.go +++ b/plugin/http.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -109,11 +110,11 @@ func NewHTTP(log *util.Logger, method, uri string, insecure bool, cache time.Dur } if cache > 0 { - // remove no-cache response headers + // remove cache-busting response headers base = &transport.Modifier{ Modifier: func(resp *http.Response) error { - dropNoCache(resp, "Cache-Control") - dropNoCache(resp, "Pragma") + dropCacheBusting(resp, "Cache-Control") + dropCacheBusting(resp, "Pragma") return nil }, Base: base, @@ -148,21 +149,36 @@ func NewHTTP(log *util.Logger, method, uri string, insecure bool, cache time.Dur return p } -func dropNoCache(resp *http.Response, header string) { - if h := resp.Header.Get(header); h != "" { - var hh []string +// dropCacheBusting removes response directives that defeat the cache layer +// (no-cache, no-store and max-age=0) so a configured cache duration takes effect. +func dropCacheBusting(resp *http.Response, header string) { + h := resp.Header.Get(header) + if h == "" { + return + } - for h := range strings.SplitSeq(h, ",") { - if s := strings.TrimSpace(h); strings.ToLower(s) != "no-cache" { - hh = append(hh, s) + var hh []string + + for token := range strings.SplitSeq(h, ",") { + s := strings.TrimSpace(token) + + name, value, _ := strings.Cut(s, "=") + switch strings.ToLower(strings.TrimSpace(name)) { + case "no-cache", "no-store": + continue + case "max-age": + if v, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && v <= 0 { + continue } } - if len(hh) == 0 { - resp.Header.Del(header) - } else { - resp.Header.Set(header, strings.Join(hh, ", ")) - } + hh = append(hh, s) + } + + if len(hh) == 0 { + resp.Header.Del(header) + } else { + resp.Header.Set(header, strings.Join(hh, ", ")) } } diff --git a/plugin/http_test.go b/plugin/http_test.go index a230980d3..ddb7f6c5d 100644 --- a/plugin/http_test.go +++ b/plugin/http_test.go @@ -12,14 +12,19 @@ import ( ) type httpHandler struct { - val string - req *http.Request - cnt int + val string + req *http.Request + cnt int + cacheBusting bool } func (h *httpHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { h.req = req h.val = lo.RandomString(16, lo.LettersCharset) + if h.cacheBusting { + w.Header().Set("Cache-Control", "no-store, no-cache, max-age=0, must-revalidate") + w.Header().Set("Pragma", "no-cache") + } _, _ = w.Write([]byte(h.val)) h.cnt++ } @@ -72,6 +77,30 @@ func (suite *httpTestSuite) TestCacheGet() { } } +func (suite *httpTestSuite) TestCacheGetNoStore() { + // upstream sends cache-busting headers, cache must still take effect (#31025) + suite.h.cacheBusting = true + defer func() { suite.h.cacheBusting = false }() + + uri := suite.srv.URL + "/foo/bar?baz=2" + p := NewHTTP(util.NewLogger("foo"), http.MethodGet, uri, false, time.Minute) + + g, err := p.StringGetter() + suite.Require().NoError(err) + + suite.h.cnt = 0 + res, err := g() + suite.Require().NoError(err) + first := suite.h.cnt + + for range 3 { + val, err := g() + suite.Require().NoError(err) + suite.Require().Equal(res, val) + suite.Require().Equal(first, suite.h.cnt) + } +} + func (suite *httpTestSuite) TestSetQuery() { uri := suite.srv.URL + "/foo/bar?baz={{.baz}}" p := NewHTTP(util.NewLogger("foo"), http.MethodGet, uri, false, 0)