internal/api: set client error cache duration

Establish a short cache duration for 4xx client errors to prevent
redundant calls on errors such as package not found or bad request.

Change-Id: I32dc5880d14955f081caaa6e6bbc9d951fad1adb
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/805020
Auto-Submit: Ethan Lee <ethanalee@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
diff --git a/internal/api/api.go b/internal/api/api.go
index 0673825..b9e9908 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -802,8 +802,10 @@
 	longCacheDur = 3 * time.Hour
 	// The information on some pages can change relatively quickly.
 	shortCacheDur = 1 * time.Hour
-	// Errors should not be cached.
+	// Server errors (5xx) and transient errors should not be cached.
 	noCache = time.Duration(0)
+	// 4xx Client errors (such as 404 Not Found or 400 Bad Request) are cached for a short period.
+	clientErrorCacheDur = 10 * time.Minute
 )
 
 func serveJSON(w http.ResponseWriter, status int, data any, cacheDur time.Duration) error {
@@ -835,7 +837,14 @@
 		}
 	}
 	log.Errorf(r.Context(), "API error %d: %v", aerr.Code, aerr)
-	return serveJSON(w, aerr.Code, aerr, noCache)
+	cacheDur := noCache
+	// Cache deterministic 4xx client errors (e.g. 404 Not Found, 400 Bad Request) for a short duration.
+	// Exclude transient 4xx errors like 429 Too Many Requests (which would lock out rate-limited users after quota reset)
+	// and 5xx server errors (which should recover instantly as soon as backend issues resolve).
+	if aerr.Code >= 400 && aerr.Code < 500 && aerr.Code != http.StatusTooManyRequests && aerr.Code != http.StatusRequestTimeout {
+		cacheDur = clientErrorCacheDur
+	}
+	return serveJSON(w, aerr.Code, aerr, cacheDur)
 }
 
 // paginate returns a paginated response for the given list of items and pagination parameters.
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index d99de33..5f8e171 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -6,11 +6,14 @@
 
 import (
 	"encoding/json"
+	"errors"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/osv"
 	"golang.org/x/pkgsite/internal/testing/fakedatasource"
 	"golang.org/x/pkgsite/internal/vuln"
@@ -221,3 +224,45 @@
 		})
 	}
 }
+
+func TestServeErrorCacheControl(t *testing.T) {
+	for _, test := range []struct {
+		name       string
+		err        error
+		wantStatus int
+		wantCC     string
+	}{
+		{
+			name:       "404 not found is cached for 10 min",
+			err:        fmt.Errorf("module not found: %w", derrors.NotFound),
+			wantStatus: http.StatusNotFound,
+			wantCC:     "public, max-age=600",
+		},
+		{
+			name:       "400 bad request is cached for 10 min",
+			err:        BadRequest("invalid query", "details"),
+			wantStatus: http.StatusBadRequest,
+			wantCC:     "public, max-age=600",
+		},
+		{
+			name:       "500 internal error is not cached",
+			err:        errors.New("db failure"),
+			wantStatus: http.StatusInternalServerError,
+			wantCC:     "no-store",
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			r := httptest.NewRequest("GET", "/v1beta/module/nonexistent", nil)
+			w := httptest.NewRecorder()
+			if err := ServeError(w, r, test.err); err != nil {
+				t.Fatal(err)
+			}
+			if w.Code != test.wantStatus {
+				t.Errorf("status = %d, want %d", w.Code, test.wantStatus)
+			}
+			if got := w.Header().Get("Cache-Control"); got != test.wantCC {
+				t.Errorf("Cache-Control = %q, want %q", got, test.wantCC)
+			}
+		})
+	}
+}