Jonathan Amsterdam would like Hyang-Ah Hana Kim and Ethan Lee to review this change.
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.
diff --git a/internal/api/api.go b/internal/api/api.go
index 831b1af..d5d812b 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -12,6 +12,7 @@
"net/http"
"strconv"
"strings"
+ "time"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
@@ -69,7 +70,8 @@
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ //
+ return serveJSON(w, http.StatusOK, resp, immutableVersion(params.Version))
}
// ServeModule handles requests for the v1 module metadata endpoint.
@@ -90,6 +92,8 @@
if requestedVersion == "" {
requestedVersion = version.Latest
}
+ // The served response is immutable if and only if the version is.
+ immutable := immutableVersion(requestedVersion)
// For modules, we can use GetUnitMeta on the module path.
um, err := ds.GetUnitMeta(r.Context(), modulePath, modulePath, requestedVersion)
@@ -111,7 +115,7 @@
}
if !params.Readme && !params.Licenses {
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, immutable)
}
fs := internal.MinimalFields
@@ -123,7 +127,7 @@
}
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, immutable)
}
if params.Readme && unit.Readme != nil {
@@ -142,7 +146,7 @@
}
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, immutable)
}
// ServeModuleVersions handles requests for the v1 module versions endpoint.
@@ -179,7 +183,8 @@
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, false)
}
// ServeModulePackages handles requests for the v1 module packages endpoint.
@@ -225,7 +230,7 @@
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, immutableVersion(requestedVersion))
}
// ServeSearch handles requests for the v1 search endpoint.
@@ -270,7 +275,11 @@
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 serverJSON). 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, false)
}
// ServePackageSymbols handles requests for the v1 package symbols endpoint.
@@ -318,7 +327,7 @@
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, immutableVersion(params.Version))
}
// ServePackageImportedBy handles requests for the v1 package imported-by endpoint.
@@ -381,7 +390,8 @@
},
}
- 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, false)
}
// ServeVulnerabilities handles requests for the v1 module vulnerabilities endpoint.
@@ -428,7 +438,7 @@
return err
}
- return serveJSON(w, http.StatusOK, resp)
+ return serveJSON(w, http.StatusOK, resp, immutableVersion(requestedVersion))
}
}
@@ -483,12 +493,32 @@
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.
+ longFreshness = 24 * time.Hour
+ // The information on some pages can change relatively quickly.
+ shortFreshness = 1 * time.Hour
+)
+
+func serveJSON(w http.ResponseWriter, status int, data any, immutable bool) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(data); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
+ freshness := longFreshness
+ if !immutable {
+ freshness = shortFreshness
+ }
+ w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", freshness.Seconds()))
w.WriteHeader(status)
_, err := w.Write(buf.Bytes())
return err
@@ -503,7 +533,7 @@
Message: err.Error(),
}
}
- return serveJSON(w, aerr.Code, aerr)
+ return serveJSON(w, aerr.Code, aerr, false)
}
// paginate returns a paginated response for the given list of items and pagination parameters.
@@ -629,3 +659,8 @@
}
return sb.String(), nil
}
+
+// immutableVersion reports whether a module version is immutable.
+func immutableVersion(v string) bool {
+ return !(v == "" || v == version.Latest || internal.DefaultBranches[v] || stdlib.SupportedBranches[v])
+}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 36066f4..05441b4 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -965,3 +965,58 @@
}
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=86400"},
+ {"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)
+ }
+ })
+ }
+}
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Kokoro presubmit build finished with status: SUCCESS
Logs at: https://source.cloud.google.com/results/invocations/de7d8eb2-92f3-4e93-8355-588a07b25a87
| kokoro-CI | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
// NOTE: the default cache freshness is set to 1 hour (see serverJSON). This seemsnit: serveJson
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", freshness.Seconds()))nit: Can we cast seconds to an integer and use `%d`.
func ServeError(w http.ResponseWriter, err error) error {Currently, `ServeError` defaults to a 1 hour "short" freshness. There is a risk that transient server errors will be cached by downstream proxies even when the serve recovers. We should use `no-store` or a very short TTL (1 minute) for error responses.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
// NOTE: the default cache freshness is set to 1 hour (see serverJSON). This seemsJonathan Amsterdamnit: serveJson
Done
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", freshness.Seconds()))nit: Can we cast seconds to an integer and use `%d`.
Done
Currently, `ServeError` defaults to a 1 hour "short" freshness. There is a risk that transient server errors will be cached by downstream proxies even when the serve recovers. We should use `no-store` or a very short TTL (1 minute) for error responses.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Kokoro presubmit build finished with status: SUCCESS
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Auto-Submit | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Code-Review | +2 |
// Do not cache errors.nit: I think the variable name is enough here, we can remove this comment.
// immutableVersion reports whether a module version is immutable.
func immutableVersion(v string) bool {
return !(v == "" || v == version.Latest || internal.DefaultBranches[v] || stdlib.SupportedBranches[v])
}nit: can we please inline this since it's only used here.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
nit: I think the variable name is enough here, we can remove this comment.
Done
// immutableVersion reports whether a module version is immutable.
func immutableVersion(v string) bool {
return !(v == "" || v == version.Latest || internal.DefaultBranches[v] || stdlib.SupportedBranches[v])
}nit: can we please inline this since it's only used here.
Done
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Kokoro presubmit build finished with status: SUCCESS
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Code-Review | +2 |
longCacheDur = 24 * time.HourIn module proxy, max cache ttl is 3hr. (in case we need to push bug fix or remove bad modules, etc)
short cache ttl is 1~30min depending on cases (e.g. error is 1min). I don't know about shortCacheDur, but I think we can consider a shorter period for longCacheDur. (3hr?)
Still we see significant cache hit rate. Not sure what the pkgsite api traffic pattern will be though.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
In module proxy, max cache ttl is 3hr. (in case we need to push bug fix or remove bad modules, etc)
short cache ttl is 1~30min depending on cases (e.g. error is 1min). I don't know about shortCacheDur, but I think we can consider a shorter period for longCacheDur. (3hr?)
Still we see significant cache hit rate. Not sure what the pkgsite api traffic pattern will be though.
Done
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Kokoro presubmit build finished with status: FAILURE
Logs at: https://source.cloud.google.com/results/invocations/f4ccdff1-c7bb-49df-90bd-9c685036d973
| kokoro-CI | -1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| TryBot-Bypass | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
3 is the latest approved patch-set.
The change was submitted with unreviewed changes in the following files:
```
The name of the file: internal/api/api.go
Insertions: 1, Deletions: 1.
@@ -502,7 +502,7 @@
// Immutable pages can theoretically, be cached indefinitely,
// but have them time out so that excluded modules don't
// live in caches forever.
- longCacheDur = 24 * time.Hour
+ longCacheDur = 3 * time.Hour
// The information on some pages can change relatively quickly.
shortCacheDur = 1 * time.Hour
// Errors should not be cached.
```
```
The name of the file: internal/api/api_test.go
Insertions: 1, Deletions: 1.
@@ -992,7 +992,7 @@
version string
want string
}{
- {"v1.0.0", "public, max-age=86400"},
+ {"v1.0.0", "public, max-age=10800"},
{"latest", "public, max-age=3600"},
{"master", "public, max-age=3600"},
{"", "public, max-age=3600"},
```
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.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |