aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonathan Amsterdam <jba@google.com>2026-04-07 18:52:05 -0400
committerJonathan Amsterdam <jba@google.com>2026-04-08 13:35:34 -0700
commitb0096dc799437fdf92fc65135a7141fa8ad358be (patch)
tree59514073b525d15fd7917f424d0228ded51e67b9
parent0ef8af41d6814a34f239b02ab621c4cfcb8c0019 (diff)
downloadgo-x-pkgsite-b0096dc799437fdf92fc65135a7141fa8ad358be.tar.xz
internal/api: set cache-control headers
Set an HTTP Cache-Control header for all API responses. Since requests that reference a specific, numbered version apparently always produce the same response, it is tempting to use the "immutable" Cache-Control directive so these pages can be cached indefinitely. But occasionally we must exclude a module. It would be unfortunate if the module's data lived in caches forever. Instead, we cache such pages for one day. Pages that are subject to more rapid change, like those with versions "latest", "master" and so on, or those that depend on data other than a module (imported-by, search, etc.) are cached for an hour. That is an arbitrary value that seems like a good compromise, since the likelihood of a particular page's value changing in an hour is low. Change-Id: I21414c22c724220c993c1dd7e7a0b49074efd8b9 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/763782 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Ethan Lee <ethanalee@google.com> Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com> TryBot-Bypass: Jonathan Amsterdam <jba@google.com>
-rw-r--r--internal/api/api.go67
-rw-r--r--internal/api/api_test.go55
2 files changed, 110 insertions, 12 deletions
diff --git a/internal/api/api.go b/internal/api/api.go
index 2d4d80b5..daf3ffdb 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -12,6 +12,7 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
@@ -69,7 +70,7 @@ func ServePackage(w http.ResponseWriter, r *http.Request, ds internal.DataSource
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version))
}
// ServeModule handles requests for the v1 module metadata endpoint.
@@ -90,6 +91,8 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource)
if requestedVersion == "" {
requestedVersion = version.Latest
}
+ // The served response is cacheDur if and only if the version is.
+ cacheDur := versionCacheDur(requestedVersion)
// For modules, we can use GetUnitMeta on the module path.
um, err := ds.GetUnitMeta(r.Context(), modulePath, modulePath, requestedVersion)
@@ -111,7 +114,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource)
}
if !params.Readme && !params.Licenses {
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, cacheDur)
}
fs := internal.MinimalFields
@@ -123,7 +126,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource)
}
unit, err := ds.GetUnit(r.Context(), um, fs, internal.BuildContext{})
if err != nil {
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, cacheDur)
}
if params.Readme && unit.Readme != nil {
@@ -142,7 +145,7 @@ func ServeModule(w http.ResponseWriter, r *http.Request, ds internal.DataSource)
}
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, cacheDur)
}
// ServeModuleVersions handles requests for the v1 module versions endpoint.
@@ -179,7 +182,8 @@ func ServeModuleVersions(w http.ResponseWriter, r *http.Request, ds internal.Dat
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ // The response is never immutable, because a new version can arrive at any time.
+ return serveJSON(w, http.StatusOK, resp, shortCacheDur)
}
// ServeModulePackages handles requests for the v1 module packages endpoint.
@@ -225,7 +229,7 @@ func ServeModulePackages(w http.ResponseWriter, r *http.Request, ds internal.Dat
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion))
}
// ServeSearch handles requests for the v1 search endpoint.
@@ -270,7 +274,11 @@ func ServeSearch(w http.ResponseWriter, r *http.Request, ds internal.DataSource)
return fmt.Errorf("%w: %s", derrors.InvalidArgument, err.Error())
}
- return serveJSON(w, http.StatusOK, resp)
+ // Search results are never immutable, because new modules are always being added.
+ // NOTE: the default cache freshness is set to 1 hour (see serveJSON). This seems
+ // like a reasonable time to cache a search, but be aware of complaints
+ // about stale search results.
+ return serveJSON(w, http.StatusOK, resp, shortCacheDur)
}
// ServePackageSymbols handles requests for the v1 package symbols endpoint.
@@ -318,7 +326,7 @@ func ServePackageSymbols(w http.ResponseWriter, r *http.Request, ds internal.Dat
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, versionCacheDur(params.Version))
}
// ServePackageImportedBy handles requests for the v1 package imported-by endpoint.
@@ -381,7 +389,8 @@ func ServePackageImportedBy(w http.ResponseWriter, r *http.Request, ds internal.
},
}
- return serveJSON(w, http.StatusOK, resp)
+ // The imported-by list is not immutable, because new modules are always being added.
+ return serveJSON(w, http.StatusOK, resp, shortCacheDur)
}
// ServeVulnerabilities handles requests for the v1 module vulnerabilities endpoint.
@@ -428,7 +437,7 @@ func ServeVulnerabilities(vc *vuln.Client) func(w http.ResponseWriter, r *http.R
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, versionCacheDur(requestedVersion))
}
}
@@ -483,12 +492,36 @@ func resolveModulePath(r *http.Request, ds internal.DataSource, pkgPath, moduleP
return um, nil
}
-func serveJSON(w http.ResponseWriter, status int, data any) error {
+// Values for the Cache-Control header.
+// Compare with the TTLs for pkgsite's own cache, in internal/frontend/server.go
+// (look for symbols ending in "TTL").
+// Those values are shorter to manage our cache's memory, but the job of
+// Cache-Control is to reduce network traffic; downstream caches can manage
+// their own memory.
+const (
+ // Immutable pages can theoretically, be cached indefinitely,
+ // but have them time out so that excluded modules don't
+ // live in caches forever.
+ longCacheDur = 3 * time.Hour
+ // The information on some pages can change relatively quickly.
+ shortCacheDur = 1 * time.Hour
+ // Errors should not be cached.
+ noCache = time.Duration(0)
+)
+
+func serveJSON(w http.ResponseWriter, status int, data any, cacheDur time.Duration) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(data); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
+ var ccHeader string
+ if cacheDur == 0 {
+ ccHeader = "no-store"
+ } else {
+ ccHeader = fmt.Sprintf("public, max-age=%d", int(cacheDur.Seconds()))
+ }
+ w.Header().Set("Cache-Control", ccHeader)
w.WriteHeader(status)
_, err := w.Write(buf.Bytes())
return err
@@ -503,7 +536,7 @@ func ServeError(w http.ResponseWriter, err error) error {
Message: err.Error(),
}
}
- return serveJSON(w, aerr.Code, aerr)
+ return serveJSON(w, aerr.Code, aerr, noCache)
}
// paginate returns a paginated response for the given list of items and pagination parameters.
@@ -632,3 +665,13 @@ func renderDocumentation(unit *internal.Unit, d *internal.Documentation, format
}
return sb.String(), nil
}
+
+// versionCacheDur returns the duration used in the Cache-Control header
+// appropriate for the given module version.
+func versionCacheDur(v string) time.Duration {
+ immutable := !(v == "" || v == version.Latest || internal.DefaultBranches[v] || stdlib.SupportedBranches[v])
+ if immutable {
+ return longCacheDur
+ }
+ return shortCacheDur
+}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 5d5c9c03..5de0ab6e 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -1053,3 +1053,58 @@ func unmarshalResponse[T any](data []byte) (any, error) {
}
return nil, errors.Join(err1, err2)
}
+
+func TestCacheControl(t *testing.T) {
+ ctx := context.Background()
+ ds := fakedatasource.New()
+ const modulePath = "example.com"
+ for _, v := range []string{"v1.0.0", "master"} {
+ ds.MustInsertModule(ctx, &internal.Module{
+ ModuleInfo: internal.ModuleInfo{
+ ModulePath: modulePath,
+ Version: v,
+ },
+ Units: []*internal.Unit{{
+ UnitMeta: internal.UnitMeta{
+ Path: modulePath,
+ ModuleInfo: internal.ModuleInfo{
+ ModulePath: modulePath,
+ Version: v,
+ },
+ },
+ }},
+ })
+ }
+
+ for _, test := range []struct {
+ version string
+ want string
+ }{
+ {"v1.0.0", "public, max-age=10800"},
+ {"latest", "public, max-age=3600"},
+ {"master", "public, max-age=3600"},
+ {"", "public, max-age=3600"},
+ } {
+ t.Run(test.version, func(t *testing.T) {
+ url := "/v1/module/" + modulePath
+ if test.version != "" {
+ url += "?version=" + test.version
+ }
+ r := httptest.NewRequest("GET", url, nil)
+ w := httptest.NewRecorder()
+
+ if err := ServeModule(w, r, ds); err != nil {
+ t.Fatal(err)
+ }
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ got := w.Header().Get("Cache-Control")
+ if got != test.want {
+ t.Errorf("Cache-Control = %q, want %q", got, test.want)
+ }
+ })
+ }
+}