internal/frontend: separate fetch and 404 logic into fetchserver

ServeFetch and ServePathNotFoundPage only work when a postgres
database is present. Separate them out into a different fetchserver
type which can be moved into a different package (in a followup cl).
This will allow their behavior, which is not used by cmd/pkgsite, to
be removed from that server. More importantly, their tests, which
depend on a real postgres database can be separated from the tests of
package internal/frontend.

For golang/go#61399

Change-Id: I73677fd06750fd48580071b8a895b322d9e3ac5d
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/518817
kokoro-CI: kokoro <noreply+kokoro@google.com>
Run-TryBot: Michael Matloob <matloob@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go
index aec6179..d2a4fd1 100644
--- a/cmd/frontend/main.go
+++ b/cmd/frontend/main.go
@@ -113,19 +113,25 @@
 		log.Fatalf(ctx, "vuln.NewClient: %v", err)
 	}
 	staticSource := template.TrustedSourceFromFlag(flag.Lookup("static").Value)
-	server, err := frontend.NewServer(frontend.ServerConfig{
-		Config:               cfg,
-		DataSourceGetter:     dsg,
+	// TODO: Can we use a separate queue for the fetchServer and for the Server?
+	// It would help differentiate ownership.
+	fetchServer := &frontend.FetchServer{
 		Queue:                fetchQueue,
 		TaskIDChangeInterval: config.TaskIDChangeIntervalFrontend,
-		TemplateFS:           template.TrustedFSFromTrustedSource(staticSource),
-		StaticFS:             os.DirFS(*staticFlag),
-		StaticPath:           *staticFlag,
-		ThirdPartyFS:         os.DirFS(*thirdPartyPath),
-		DevMode:              *devMode,
-		LocalMode:            *localMode,
-		Reporter:             reporter,
-		VulndbClient:         vc,
+	}
+	server, err := frontend.NewServer(frontend.ServerConfig{
+		Config:           cfg,
+		FetchServer:      fetchServer,
+		DataSourceGetter: dsg,
+		Queue:            fetchQueue,
+		TemplateFS:       template.TrustedFSFromTrustedSource(staticSource),
+		StaticFS:         os.DirFS(*staticFlag),
+		StaticPath:       *staticFlag,
+		ThirdPartyFS:     os.DirFS(*thirdPartyPath),
+		DevMode:          *devMode,
+		LocalMode:        *localMode,
+		Reporter:         reporter,
+		VulndbClient:     vc,
 	})
 	if err != nil {
 		log.Fatalf(ctx, "frontend.NewServer: %v", err)
diff --git a/internal/frontend/404.go b/internal/frontend/404.go
index 9824dd2..958701a 100644
--- a/internal/frontend/404.go
+++ b/internal/frontend/404.go
@@ -45,14 +45,10 @@
 
 // servePathNotFoundPage serves a 404 page for the requested path, or redirects
 // the user to an appropriate location.
-func (s *Server) servePathNotFoundPage(w http.ResponseWriter, r *http.Request,
-	ds internal.DataSource, fullPath, modulePath, requestedVersion string) (err error) {
+func (s *FetchServer) ServePathNotFoundPage(w http.ResponseWriter, r *http.Request,
+	db internal.PostgresDB, fullPath, modulePath, requestedVersion string) (err error) {
 	defer derrors.Wrap(&err, "servePathNotFoundPage(w, r, %q, %q)", fullPath, requestedVersion)
 
-	db, ok := ds.(internal.PostgresDB)
-	if !ok {
-		return datasourceNotSupportedErr()
-	}
 	ctx := r.Context()
 
 	if stdlib.Contains(fullPath) {
@@ -142,13 +138,13 @@
 
 		// If a module has a status of 404, but s.taskIDChangeInterval has
 		// passed, allow the module to be refetched.
-		if fr.status == http.StatusNotFound && time.Since(fr.updatedAt) > s.taskIDChangeInterval {
+		if fr.status == http.StatusNotFound && time.Since(fr.updatedAt) > s.TaskIDChangeInterval {
 			return pathNotFoundError(ctx, fullPath, requestedVersion)
 		}
 
 		// Redirect to the search result page for an empty directory that is above nested modules.
 		// See https://golang.org/issue/43725 for context.
-		nm, err := ds.GetNestedModules(ctx, fullPath)
+		nm, err := db.GetNestedModules(ctx, fullPath)
 		if err == nil && len(nm) > 0 {
 			http.Redirect(w, r, "/search?q="+url.QueryEscape(fullPath), http.StatusFound)
 			return nil
@@ -317,3 +313,21 @@
 		err:        err,
 	}
 }
+
+// stdlibPathForShortcut returns a path in the stdlib that shortcut should redirect to,
+// or the empty string if there is no such path.
+func stdlibPathForShortcut(ctx context.Context, db internal.PostgresDB, shortcut string) (path string, err error) {
+	defer derrors.Wrap(&err, "stdlibPathForShortcut(ctx, %q)", shortcut)
+	if !stdlib.Contains(shortcut) {
+		return "", nil
+	}
+	matches, err := db.GetStdlibPathsWithSuffix(ctx, shortcut)
+	if err != nil {
+		return "", err
+	}
+	if len(matches) == 1 {
+		return matches[0], nil
+	}
+	// No matches, or ambiguous.
+	return "", nil
+}
diff --git a/internal/frontend/404_test.go b/internal/frontend/404_test.go
index e7bd27c..145ca2a 100644
--- a/internal/frontend/404_test.go
+++ b/internal/frontend/404_test.go
@@ -8,13 +8,20 @@
 	"context"
 	"errors"
 	"net/http"
+	"net/http/httptest"
+	"net/url"
 	"testing"
 
+	"github.com/alicebob/miniredis/v2"
+	"github.com/go-redis/redis/v8"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/cookie"
 	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/middleware"
 	"golang.org/x/pkgsite/internal/postgres"
+	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/testing/sample"
 	"golang.org/x/pkgsite/internal/version"
 )
@@ -247,3 +254,258 @@
 		})
 	}
 }
+
+func TestStdlibPathForShortcut(t *testing.T) {
+	defer postgres.ResetTestDB(testDB, t)
+
+	m := sample.Module(stdlib.ModulePath, "v1.2.3",
+		"encoding/json",                  // one match for "json"
+		"text/template", "html/template", // two matches for "template"
+	)
+	ctx := context.Background()
+	postgres.MustInsertModule(ctx, t, testDB, m)
+
+	for _, test := range []struct {
+		path string
+		want string
+	}{
+		{"foo", ""},
+		{"json", "encoding/json"},
+		{"template", ""},
+	} {
+		got, err := stdlibPathForShortcut(ctx, testDB, test.path)
+		if err != nil {
+			t.Fatalf("%q: %v", test.path, err)
+		}
+		if got != test.want {
+			t.Errorf("%q: got %q, want %q", test.path, got, test.want)
+		}
+	}
+}
+
+// Verify that some paths that aren't found will redirect to valid pages.
+// Sometimes redirection sets the AlternativeModuleFlash cookie and puts
+// up a banner.
+func TestServer404Redirect(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+	defer cancel()
+
+	defer postgres.ResetTestDB(testDB, t)
+	sampleModule := sample.DefaultModule()
+	postgres.MustInsertModule(ctx, t, testDB, sampleModule)
+	alternativeModule := &internal.VersionMap{
+		ModulePath:       "module.path/alternative",
+		GoModPath:        sample.ModulePath,
+		RequestedVersion: version.Latest,
+		ResolvedVersion:  sample.VersionString,
+		Status:           derrors.ToStatus(derrors.AlternativeModule),
+	}
+	if err := testDB.UpsertVersionMap(ctx, alternativeModule); err != nil {
+		t.Fatal(err)
+	}
+
+	v1modpath := "notinv1.mod"
+	v1path := "notinv1.mod/foo"
+	postgres.MustInsertModule(ctx, t, testDB, sample.Module(v1modpath+"/v4", "v4.0.0", "foo"))
+	for _, mod := range []struct {
+		path, version string
+		status        int
+	}{
+		{v1modpath, "v1.0.0", http.StatusNotFound},
+		{v1path, "v4.0.0", http.StatusNotFound},
+		{v1modpath + "/v4", "v4.0.0", http.StatusOK},
+	} {
+		if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
+			ModulePath:       mod.path,
+			RequestedVersion: version.Latest,
+			ResolvedVersion:  mod.version,
+			Status:           mod.status,
+			GoModPath:        mod.path,
+		}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
+		ModulePath:       sample.ModulePath + "/blob/master",
+		RequestedVersion: version.Latest,
+		ResolvedVersion:  sample.VersionString,
+		Status:           http.StatusNotFound,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	rs, err := miniredis.Run()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer rs.Close()
+
+	_, _, handler, _ := newTestServerWithFetch(t, nil, middleware.NewCacher(redis.NewClient(&redis.Options{Addr: rs.Addr()})))
+
+	for _, test := range []struct {
+		name, path, flash string
+	}{
+		{"github url", "/" + sample.ModulePath + "/blob/master", ""},
+		{"alternative module", "/" + alternativeModule.ModulePath, "module.path/alternative"},
+		{"module not in v1", "/" + v1modpath, "notinv1.mod"},
+		{"import path not in v1", "/" + v1path, "notinv1.mod/foo"},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
+			// Check for http.StatusFound, which indicates a redirect.
+			if w.Code != http.StatusFound {
+				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, http.StatusFound)
+			}
+			res := w.Result()
+			c := findCookie(cookie.AlternativeModuleFlash, res.Cookies())
+			if c == nil && test.flash != "" {
+				t.Error("got no flash cookie, expected one")
+			} else if c != nil {
+				val, err := cookie.Base64Value(c)
+				if err != nil {
+					t.Fatal(err)
+				}
+				if val != test.flash {
+					t.Fatalf("got cookie value %q, want %q", val, test.flash)
+				}
+				// If we have a cookie, then following the redirect URL with the cookie
+				// should serve a "redirected from" banner.
+				loc := res.Header.Get("Location")
+				r := httptest.NewRequest("GET", loc, nil)
+				r.AddCookie(c)
+				w = httptest.NewRecorder()
+				handler.ServeHTTP(w, r)
+				err = checkBody(w.Result().Body, in(`[data-test-id="redirected-banner-text"]`, hasText(val)))
+				if err != nil {
+					t.Fatal(err)
+				}
+				// Visiting the same page again without the cookie should not
+				// display the banner.
+				r = httptest.NewRequest("GET", loc, nil)
+				w = httptest.NewRecorder()
+				handler.ServeHTTP(w, r)
+				err = checkBody(w.Result().Body, notIn(`[data-test-id="redirected-banner-text"]`))
+				if err != nil {
+					t.Fatal(err)
+				}
+			}
+		})
+	}
+}
+
+func TestServer404Redirect_NoLoop(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+	defer cancel()
+
+	altPath := "module.path/alternative"
+	goModPath := "module.path/alternative/pkg"
+	defer postgres.ResetTestDB(testDB, t)
+	sampleModule := sample.DefaultModule()
+	postgres.MustInsertModule(ctx, t, testDB, sampleModule)
+	alternativeModule := &internal.VersionMap{
+		ModulePath:       altPath,
+		GoModPath:        goModPath,
+		RequestedVersion: version.Latest,
+		ResolvedVersion:  sample.VersionString,
+		Status:           derrors.ToStatus(derrors.AlternativeModule),
+	}
+	alternativeModulePkg := &internal.VersionMap{
+		ModulePath:       goModPath,
+		GoModPath:        goModPath,
+		RequestedVersion: version.Latest,
+		ResolvedVersion:  sample.VersionString,
+		Status:           http.StatusNotFound,
+	}
+	if err := testDB.UpsertVersionMap(ctx, alternativeModule); err != nil {
+		t.Fatal(err)
+	}
+	if err := testDB.UpsertVersionMap(ctx, alternativeModulePkg); err != nil {
+		t.Fatal(err)
+	}
+
+	rs, err := miniredis.Run()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer rs.Close()
+
+	_, _, handler, _ := newTestServerWithFetch(t, nil, middleware.NewCacher(redis.NewClient(&redis.Options{Addr: rs.Addr()})))
+
+	for _, test := range []struct {
+		name, path string
+		status     int
+	}{
+		{"do not redirect if alternative module does not successfully return", "/" + altPath, http.StatusNotFound},
+		{"do not redirect go mod path endlessly", "/" + goModPath, http.StatusNotFound},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
+			// Check for http.StatusFound, which indicates a redirect.
+			if w.Code != test.status {
+				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, test.status)
+			}
+		})
+	}
+}
+
+func TestEmptyDirectoryBetweenNestedModulesRedirect(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+	defer cancel()
+	defer postgres.ResetTestDB(testDB, t)
+
+	postgres.MustInsertModule(ctx, t, testDB, sample.Module(sample.ModulePath, sample.VersionString, ""))
+	postgres.MustInsertModule(ctx, t, testDB, sample.Module(sample.ModulePath+"/missing/dir/c", sample.VersionString, ""))
+
+	missingPath := sample.ModulePath + "/missing"
+	notInsertedPath := sample.ModulePath + "/missing/dir"
+	if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
+		ModulePath:       missingPath,
+		RequestedVersion: version.Latest,
+		ResolvedVersion:  sample.VersionString,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	_, _, handler, _ := newTestServerWithFetch(t, nil, nil)
+	for _, test := range []struct {
+		name, path   string
+		wantStatus   int
+		wantLocation string
+	}{
+		{"want 404 for unknown version of module", sample.ModulePath + "@v0.5.0", http.StatusNotFound, ""},
+		{"want 404 for never fetched directory", notInsertedPath, http.StatusNotFound, ""},
+		{"want 302 for previously fetched directory", missingPath, http.StatusFound, "/search?q=" + url.PathEscape(missingPath)},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+test.path, nil))
+			if w.Code != test.wantStatus {
+				t.Errorf("%q: got status code = %d, want %d", "/"+test.path, w.Code, test.wantStatus)
+			}
+			if got := w.Header().Get("Location"); got != test.wantLocation {
+				t.Errorf("got location = %q, want %q", got, test.wantLocation)
+			}
+		})
+	}
+}
+
+func TestServerErrors(t *testing.T) {
+	_, _, handler, _ := newTestServerWithFetch(t, nil, nil)
+	for _, test := range []struct {
+		name, path string
+		wantCode   int
+	}{
+		{"not found", "/invalid-page", http.StatusNotFound},
+		{"bad request", "/gocloud.dev/@latest/blob", http.StatusBadRequest},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
+			if w.Code != test.wantCode {
+				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, test.wantCode)
+			}
+		})
+	}
+}
diff --git a/internal/frontend/fetch.go b/internal/frontend/fetch.go
index 29fdff0..dd725e6 100644
--- a/internal/frontend/fetch.go
+++ b/internal/frontend/fetch.go
@@ -86,13 +86,18 @@
 	statusNotFoundInVersionMap = 470
 )
 
+type FetchServer struct {
+	Queue                queue.Queue
+	TaskIDChangeInterval time.Duration
+}
+
 // serveFetch checks if a requested path and version exists in the database.
 // If not, it will enqueue potential module versions that could contain
 // the requested path and version to a task queue, to be fetched by the worker.
 // Meanwhile, the request will poll the database until a row is found, or a
 // timeout occurs. A status and responseText will be returned based on the
 // result of the request.
-func (s *Server) serveFetch(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) {
+func (s *FetchServer) ServeFetch(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) {
 	defer derrors.Wrap(&err, "serveFetch(%q)", r.URL.Path)
 	if _, ok := ds.(internal.PostgresDB); !ok {
 		// There's no reason for other DataSources to need this codepath.
@@ -127,7 +132,7 @@
 	resolvedVersion string
 }
 
-func (s *Server) fetchAndPoll(ctx context.Context, ds internal.DataSource, modulePath, fullPath, requestedVersion string) (status int, responseText string) {
+func (s *FetchServer) fetchAndPoll(ctx context.Context, ds internal.DataSource, modulePath, fullPath, requestedVersion string) (status int, responseText string) {
 	start := time.Now()
 	defer func() {
 		log.Infof(ctx, "fetchAndPoll(ctx, ds, q, %q, %q, %q): status=%d, responseText=%q",
@@ -172,7 +177,7 @@
 // checkPossibleModulePaths will then poll the database for each module path,
 // until a result is returned or the request times out. If shouldQueue is false,
 // it will return the fetchResult, regardless of what the status is.
-func (s *Server) checkPossibleModulePaths(ctx context.Context, db internal.PostgresDB,
+func (s *FetchServer) checkPossibleModulePaths(ctx context.Context, db internal.PostgresDB,
 	fullPath, requestedVersion string, modulePaths []string, shouldQueue bool) []*fetchResult {
 	var wg sync.WaitGroup
 	ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
@@ -189,8 +194,8 @@
 			// Before enqueuing the module version to be fetched, check if we
 			// have already attempted to fetch it in the past. If so, just
 			// return the result from that fetch process.
-			fr := checkForPath(ctx, db, fullPath, modulePath, requestedVersion, s.taskIDChangeInterval)
-			log.Debugf(ctx, "initial checkForPath(ctx, db, %q, %q, %q, %d): status=%d, err=%v", fullPath, modulePath, requestedVersion, s.taskIDChangeInterval, fr.status, fr.err)
+			fr := checkForPath(ctx, db, fullPath, modulePath, requestedVersion, s.TaskIDChangeInterval)
+			log.Debugf(ctx, "initial checkForPath(ctx, db, %q, %q, %q, %d): status=%d, err=%v", fullPath, modulePath, requestedVersion, s.TaskIDChangeInterval, fr.status, fr.err)
 			if !shouldQueue || fr.status != statusNotFoundInVersionMap {
 				results[i] = fr
 				return
@@ -199,7 +204,7 @@
 			// A row for this modulePath and requestedVersion combination does not
 			// exist in version_map. Enqueue the module version to be fetched.
 			opts := &queue.Options{Source: queue.SourceFrontendValue}
-			if _, err := s.queue.ScheduleFetch(ctx, modulePath, requestedVersion, opts); err != nil {
+			if _, err := s.Queue.ScheduleFetch(ctx, modulePath, requestedVersion, opts); err != nil {
 				fr.err = err
 				fr.status = http.StatusInternalServerError
 				log.Errorf(ctx, "enqueuing %s@%s to frontend-fetch task queue: %v", modulePath, requestedVersion, err)
@@ -210,7 +215,7 @@
 
 			// After the fetch request is enqueued, poll the database until it has been
 			// inserted or the request times out.
-			fr = pollForPath(ctx, db, pollEvery, fullPath, modulePath, requestedVersion, s.taskIDChangeInterval)
+			fr = pollForPath(ctx, db, pollEvery, fullPath, modulePath, requestedVersion, s.TaskIDChangeInterval)
 			logf := log.Infof
 			if fr.status == http.StatusInternalServerError {
 				logf = log.Errorf
diff --git a/internal/frontend/fetch_test.go b/internal/frontend/fetch_test.go
index 4463ece..7a90a38 100644
--- a/internal/frontend/fetch_test.go
+++ b/internal/frontend/fetch_test.go
@@ -80,13 +80,13 @@
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
-			s, _, teardown := newTestServer(t, testModulesForProxy, nil)
+			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
 
 			ctx, cancel := context.WithTimeout(context.Background(), testFetchTimeout)
 			defer cancel()
 
-			status, responseText := s.fetchAndPoll(ctx, s.getDataSource(ctx), testModulePath, test.fullPath, test.version)
+			status, responseText := f.fetchAndPoll(ctx, s.getDataSource(ctx), testModulePath, test.fullPath, test.version)
 			if status != http.StatusOK {
 				t.Fatalf("fetchAndPoll(%q, %q, %q) = %d, %s; want status = %d",
 					testModulePath, test.fullPath, test.version, status, responseText, http.StatusOK)
@@ -144,9 +144,9 @@
 			ctx, cancel := context.WithTimeout(context.Background(), test.fetchTimeout)
 			defer cancel()
 
-			s, _, teardown := newTestServer(t, testModulesForProxy, nil)
+			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
-			got, err := s.fetchAndPoll(ctx, s.getDataSource(ctx), test.modulePath, test.fullPath, test.version)
+			got, err := f.fetchAndPoll(ctx, s.getDataSource(ctx), test.modulePath, test.fullPath, test.version)
 
 			if got != test.want {
 				t.Fatalf("fetchAndPoll(ctx, testDB, q, %q, %q, %q): %d; want = %d",
@@ -181,9 +181,9 @@
 				t.Fatal(err)
 			}
 
-			s, _, teardown := newTestServer(t, testModulesForProxy, nil)
+			s, f, _, teardown := newTestServerWithFetch(t, testModulesForProxy, nil)
 			defer teardown()
-			got, _ := s.fetchAndPoll(ctx, s.getDataSource(ctx), sample.ModulePath, sample.PackagePath, sample.VersionString)
+			got, _ := f.fetchAndPoll(ctx, s.getDataSource(ctx), sample.ModulePath, sample.PackagePath, sample.VersionString)
 			if got != test.want {
 				t.Fatalf("fetchAndPoll for status %d: %d; want = %d)", test.status, got, test.want)
 			}
diff --git a/internal/frontend/frontend_test.go b/internal/frontend/frontend_test.go
index 7a7066a..0c2c411 100644
--- a/internal/frontend/frontend_test.go
+++ b/internal/frontend/frontend_test.go
@@ -46,20 +46,10 @@
 
 func newTestServer(t *testing.T, proxyModules []*proxytest.Module, cacher Cacher) (*Server, http.Handler, func()) {
 	t.Helper()
-	proxyClient, teardown := proxytest.SetupTestClient(t, proxyModules)
-	sourceClient := source.NewClient(sourceTimeout)
-	ctx := context.Background()
-
-	q := queue.NewInMemory(ctx, 1, nil,
-		func(ctx context.Context, mpath, version string) (int, error) {
-			return FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
-		})
 
 	s, err := NewServer(ServerConfig{
-		DataSourceGetter:     func(context.Context) internal.DataSource { return testDB },
-		Queue:                q,
-		TaskIDChangeInterval: 10 * time.Minute,
-		TemplateFS:           template.TrustedFSFromEmbed(static.FS),
+		DataSourceGetter: func(context.Context) internal.DataSource { return testDB },
+		TemplateFS:       template.TrustedFSFromEmbed(static.FS),
 		// Use the embedded FSs here to make sure they're tested.
 		// Integration tests will use the actual directories.
 		StaticFS:     static.FS,
@@ -73,6 +63,44 @@
 	s.Install(mux.Handle, cacher, nil)
 
 	return s, mux, func() {
+		postgres.ResetTestDB(testDB, t)
+	}
+}
+
+func newTestServerWithFetch(t *testing.T, proxyModules []*proxytest.Module, cacher Cacher) (*Server, *FetchServer, http.Handler, func()) {
+	t.Helper()
+	proxyClient, teardown := proxytest.SetupTestClient(t, proxyModules)
+	sourceClient := source.NewClient(sourceTimeout)
+	ctx := context.Background()
+
+	q := queue.NewInMemory(ctx, 1, nil,
+		func(ctx context.Context, mpath, version string) (int, error) {
+			return FetchAndUpdateState(ctx, mpath, version, proxyClient, sourceClient, testDB)
+		})
+
+	f := &FetchServer{
+		Queue:                q,
+		TaskIDChangeInterval: 10 * time.Minute,
+	}
+
+	s, err := NewServer(ServerConfig{
+		FetchServer:      f,
+		DataSourceGetter: func(context.Context) internal.DataSource { return testDB },
+		Queue:            q,
+		TemplateFS:       template.TrustedFSFromEmbed(static.FS),
+		// Use the embedded FSs here to make sure they're tested.
+		// Integration tests will use the actual directories.
+		StaticFS:     static.FS,
+		ThirdPartyFS: thirdparty.FS,
+		StaticPath:   "../../static",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	mux := http.NewServeMux()
+	s.Install(mux.Handle, cacher, nil)
+
+	return s, f, mux, func() {
 		teardown()
 		postgres.ResetTestDB(testDB, t)
 	}
diff --git a/internal/frontend/package_test.go b/internal/frontend/package_test.go
deleted file mode 100644
index 1b26cfd..0000000
--- a/internal/frontend/package_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright 2019 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package frontend
-
-import (
-	"context"
-	"testing"
-
-	"golang.org/x/pkgsite/internal/postgres"
-	"golang.org/x/pkgsite/internal/stdlib"
-	"golang.org/x/pkgsite/internal/testing/sample"
-)
-
-func TestStdlibPathForShortcut(t *testing.T) {
-	defer postgres.ResetTestDB(testDB, t)
-
-	m := sample.Module(stdlib.ModulePath, "v1.2.3",
-		"encoding/json",                  // one match for "json"
-		"text/template", "html/template", // two matches for "template"
-	)
-	ctx := context.Background()
-	postgres.MustInsertModule(ctx, t, testDB, m)
-
-	for _, test := range []struct {
-		path string
-		want string
-	}{
-		{"foo", ""},
-		{"json", "encoding/json"},
-		{"template", ""},
-	} {
-		got, err := stdlibPathForShortcut(ctx, testDB, test.path)
-		if err != nil {
-			t.Fatalf("%q: %v", test.path, err)
-		}
-		if got != test.want {
-			t.Errorf("%q: got %q, want %q", test.path, got, test.want)
-		}
-	}
-}
diff --git a/internal/frontend/redirect.go b/internal/frontend/redirect.go
index 8ce7235..4d33342 100644
--- a/internal/frontend/redirect.go
+++ b/internal/frontend/redirect.go
@@ -5,13 +5,8 @@
 package frontend
 
 import (
-	"context"
 	"net/http"
 	"strings"
-
-	"golang.org/x/pkgsite/internal"
-	"golang.org/x/pkgsite/internal/derrors"
-	"golang.org/x/pkgsite/internal/stdlib"
 )
 
 // handlePackageDetailsRedirect redirects all redirects to "/pkg" to "/".
@@ -25,21 +20,3 @@
 	urlPath := strings.TrimPrefix(r.URL.Path, "/mod")
 	http.Redirect(w, r, urlPath, http.StatusMovedPermanently)
 }
-
-// stdlibPathForShortcut returns a path in the stdlib that shortcut should redirect to,
-// or the empty string if there is no such path.
-func stdlibPathForShortcut(ctx context.Context, db internal.PostgresDB, shortcut string) (path string, err error) {
-	defer derrors.Wrap(&err, "stdlibPathForShortcut(ctx, %q)", shortcut)
-	if !stdlib.Contains(shortcut) {
-		return "", nil
-	}
-	matches, err := db.GetStdlibPathsWithSuffix(ctx, shortcut)
-	if err != nil {
-		return "", err
-	}
-	if len(matches) == 1 {
-		return matches[0], nil
-	}
-	// No matches, or ambiguous.
-	return "", nil
-}
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 5556cf2..264669b 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -44,48 +44,60 @@
 
 // Server can be installed to serve the go discovery frontend.
 type Server struct {
+	fetchServer FetchServerInterface
 	// getDataSource should never be called from a handler. It is called only in Server.errorHandler.
-	getDataSource        func(context.Context) internal.DataSource
-	queue                queue.Queue
-	taskIDChangeInterval time.Duration
-	templateFS           template.TrustedFS
-	staticFS             fs.FS
-	thirdPartyFS         fs.FS
-	devMode              bool
-	localMode            bool          // running locally (i.e. ./cmd/pkgsite)
-	localModules         []LocalModule // locally hosted modules; empty in production
-	staticPath           string        // used only for dynamic loading in dev mode
-	errorPage            []byte
-	appVersionLabel      string
-	googleTagManagerID   string
-	serveStats           bool
-	reporter             derrors.Reporter
-	fileMux              *http.ServeMux
-	vulnClient           *vuln.Client
-	versionID            string
-	instanceID           string
+	getDataSource      func(context.Context) internal.DataSource
+	queue              queue.Queue
+	templateFS         template.TrustedFS
+	staticFS           fs.FS
+	thirdPartyFS       fs.FS
+	devMode            bool
+	localMode          bool          // running locally (i.e. ./cmd/pkgsite)
+	localModules       []LocalModule // locally hosted modules; empty in production
+	staticPath         string        // used only for dynamic loading in dev mode
+	errorPage          []byte
+	appVersionLabel    string
+	googleTagManagerID string
+	serveStats         bool
+	reporter           derrors.Reporter
+	fileMux            *http.ServeMux
+	vulnClient         *vuln.Client
+	versionID          string
+	instanceID         string
 
 	mu        sync.Mutex // Protects all fields below
 	templates map[string]*template.Template
 }
 
+// FetchServerInterface is an interface for the parts of the server
+// that support adding packages to a queue for fetching by a worker
+// server.
+// TODO(matloob): rename to FetchServer once the FetchServer type moves
+// to its own package
+type FetchServerInterface interface {
+	ServeFetch(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error)
+	ServePathNotFoundPage(w http.ResponseWriter, r *http.Request,
+		ds internal.PostgresDB, fullPath, modulePath, requestedVersion string) (err error)
+}
+
 // ServerConfig contains everything needed by a Server.
 type ServerConfig struct {
 	Config *config.Config
+	// Note that FetchServer may be nil.
+	FetchServer FetchServerInterface
 	// DataSourceGetter should return a DataSource on each call.
 	// It should be goroutine-safe.
-	DataSourceGetter     func(context.Context) internal.DataSource
-	Queue                queue.Queue
-	TaskIDChangeInterval time.Duration
-	TemplateFS           template.TrustedFS // for loading templates safely
-	StaticFS             fs.FS              // for static/ directory
-	ThirdPartyFS         fs.FS              // for third_party/ directory
-	DevMode              bool
-	LocalMode            bool
-	LocalModules         []LocalModule
-	StaticPath           string // used only for dynamic loading in dev mode
-	Reporter             derrors.Reporter
-	VulndbClient         *vuln.Client
+	DataSourceGetter func(context.Context) internal.DataSource
+	Queue            queue.Queue
+	TemplateFS       template.TrustedFS // for loading templates safely
+	StaticFS         fs.FS              // for static/ directory
+	ThirdPartyFS     fs.FS              // for third_party/ directory
+	DevMode          bool
+	LocalMode        bool
+	LocalModules     []LocalModule
+	StaticPath       string // used only for dynamic loading in dev mode
+	Reporter         derrors.Reporter
+	VulndbClient     *vuln.Client
 }
 
 // NewServer creates a new Server for the given database and template directory.
@@ -97,20 +109,20 @@
 	}
 	dochtml.LoadTemplates(scfg.TemplateFS)
 	s := &Server{
-		getDataSource:        scfg.DataSourceGetter,
-		queue:                scfg.Queue,
-		templateFS:           scfg.TemplateFS,
-		staticFS:             scfg.StaticFS,
-		thirdPartyFS:         scfg.ThirdPartyFS,
-		devMode:              scfg.DevMode,
-		localMode:            scfg.LocalMode,
-		localModules:         scfg.LocalModules,
-		staticPath:           scfg.StaticPath,
-		templates:            ts,
-		taskIDChangeInterval: scfg.TaskIDChangeInterval,
-		reporter:             scfg.Reporter,
-		fileMux:              http.NewServeMux(),
-		vulnClient:           scfg.VulndbClient,
+		fetchServer:   scfg.FetchServer,
+		getDataSource: scfg.DataSourceGetter,
+		queue:         scfg.Queue,
+		templateFS:    scfg.TemplateFS,
+		staticFS:      scfg.StaticFS,
+		thirdPartyFS:  scfg.ThirdPartyFS,
+		devMode:       scfg.DevMode,
+		localMode:     scfg.LocalMode,
+		localModules:  scfg.LocalModules,
+		staticPath:    scfg.StaticPath,
+		templates:     ts,
+		reporter:      scfg.Reporter,
+		fileMux:       http.NewServeMux(),
+		vulnClient:    scfg.VulndbClient,
 	}
 	if scfg.Config != nil {
 		s.appVersionLabel = scfg.Config.AppVersionLabel()
@@ -145,10 +157,13 @@
 func (s *Server) Install(handle func(string, http.Handler), cacher Cacher, authValues []string) {
 	var (
 		detailHandler http.Handler = s.errorHandler(s.serveDetails)
-		fetchHandler  http.Handler = s.errorHandler(s.serveFetch)
+		fetchHandler  http.Handler
 		searchHandler http.Handler = s.errorHandler(s.serveSearch)
 		vulnHandler   http.Handler = s.errorHandler(s.serveVuln)
 	)
+	if s.fetchServer != nil {
+		fetchHandler = s.errorHandler(s.fetchServer.ServeFetch)
+	}
 	if cacher != nil {
 		// The cache middleware uses the URL string as the key for content served
 		// by the handlers it wraps. Be careful not to wrap the handler it returns
@@ -178,7 +193,9 @@
 	handle("/sitemap/", http.StripPrefix("/sitemap/", http.FileServer(http.Dir("private/sitemap"))))
 	handle("/mod/", http.HandlerFunc(s.handleModuleDetailsRedirect))
 	handle("/pkg/", http.HandlerFunc(s.handlePackageDetailsRedirect))
-	handle("/fetch/", fetchHandler)
+	if fetchHandler != nil {
+		handle("/fetch/", fetchHandler)
+	}
 	handle("/play/compile", http.HandlerFunc(s.proxyPlayground))
 	handle("/play/fmt", http.HandlerFunc(s.handleFmt))
 	handle("/play/share", http.HandlerFunc(s.proxyPlayground))
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index bf2918b..f566ab0 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -15,28 +15,21 @@
 	"io"
 	"net/http"
 	"net/http/httptest"
-	"net/url"
 	"os"
 	"regexp"
 	"strings"
 	"testing"
 	"time"
 
-	"github.com/alicebob/miniredis/v2"
-	"github.com/go-redis/redis/v8"
 	"github.com/google/safehtml/template"
 	"github.com/jba/templatecheck"
 	"golang.org/x/net/html"
 	"golang.org/x/pkgsite/internal"
-	"golang.org/x/pkgsite/internal/cookie"
-	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/frontend/page"
-	"golang.org/x/pkgsite/internal/middleware"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/testing/htmlcheck"
 	"golang.org/x/pkgsite/internal/testing/pagecheck"
 	"golang.org/x/pkgsite/internal/testing/sample"
-	"golang.org/x/pkgsite/internal/version"
 	"golang.org/x/pkgsite/static"
 )
 
@@ -1125,7 +1118,7 @@
 	if err := testDB.InsertExcludedPrefix(ctx, excludedModulePath, "testuser", "testreason"); err != nil {
 		t.Fatal(err)
 	}
-	_, handler, _ := newTestServer(t, nil, nil)
+	_, _, handler, _ := newTestServerWithFetch(t, nil, nil)
 
 	for _, test := range testCases {
 		t.Run(test.name, func(t *testing.T) { // remove initial '/' for name
@@ -1158,192 +1151,6 @@
 	}
 }
 
-func TestServerErrors(t *testing.T) {
-	_, handler, _ := newTestServer(t, nil, nil)
-	for _, test := range []struct {
-		name, path string
-		wantCode   int
-	}{
-		{"not found", "/invalid-page", http.StatusNotFound},
-		{"bad request", "/gocloud.dev/@latest/blob", http.StatusBadRequest},
-	} {
-		t.Run(test.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
-			if w.Code != test.wantCode {
-				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, test.wantCode)
-			}
-		})
-	}
-}
-
-func TestServer404Redirect_NoLoop(t *testing.T) {
-	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
-	defer cancel()
-
-	altPath := "module.path/alternative"
-	goModPath := "module.path/alternative/pkg"
-	defer postgres.ResetTestDB(testDB, t)
-	sampleModule := sample.DefaultModule()
-	postgres.MustInsertModule(ctx, t, testDB, sampleModule)
-	alternativeModule := &internal.VersionMap{
-		ModulePath:       altPath,
-		GoModPath:        goModPath,
-		RequestedVersion: version.Latest,
-		ResolvedVersion:  sample.VersionString,
-		Status:           derrors.ToStatus(derrors.AlternativeModule),
-	}
-	alternativeModulePkg := &internal.VersionMap{
-		ModulePath:       goModPath,
-		GoModPath:        goModPath,
-		RequestedVersion: version.Latest,
-		ResolvedVersion:  sample.VersionString,
-		Status:           http.StatusNotFound,
-	}
-	if err := testDB.UpsertVersionMap(ctx, alternativeModule); err != nil {
-		t.Fatal(err)
-	}
-	if err := testDB.UpsertVersionMap(ctx, alternativeModulePkg); err != nil {
-		t.Fatal(err)
-	}
-
-	rs, err := miniredis.Run()
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer rs.Close()
-
-	_, handler, _ := newTestServer(t, nil, middleware.NewCacher(redis.NewClient(&redis.Options{Addr: rs.Addr()})))
-
-	for _, test := range []struct {
-		name, path string
-		status     int
-	}{
-		{"do not redirect if alternative module does not successfully return", "/" + altPath, http.StatusNotFound},
-		{"do not redirect go mod path endlessly", "/" + goModPath, http.StatusNotFound},
-	} {
-		t.Run(test.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
-			// Check for http.StatusFound, which indicates a redirect.
-			if w.Code != test.status {
-				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, test.status)
-			}
-		})
-	}
-}
-
-// Verify that some paths that aren't found will redirect to valid pages.
-// Sometimes redirection sets the AlternativeModuleFlash cookie and puts
-// up a banner.
-func TestServer404Redirect(t *testing.T) {
-	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
-	defer cancel()
-
-	defer postgres.ResetTestDB(testDB, t)
-	sampleModule := sample.DefaultModule()
-	postgres.MustInsertModule(ctx, t, testDB, sampleModule)
-	alternativeModule := &internal.VersionMap{
-		ModulePath:       "module.path/alternative",
-		GoModPath:        sample.ModulePath,
-		RequestedVersion: version.Latest,
-		ResolvedVersion:  sample.VersionString,
-		Status:           derrors.ToStatus(derrors.AlternativeModule),
-	}
-	if err := testDB.UpsertVersionMap(ctx, alternativeModule); err != nil {
-		t.Fatal(err)
-	}
-
-	v1modpath := "notinv1.mod"
-	v1path := "notinv1.mod/foo"
-	postgres.MustInsertModule(ctx, t, testDB, sample.Module(v1modpath+"/v4", "v4.0.0", "foo"))
-	for _, mod := range []struct {
-		path, version string
-		status        int
-	}{
-		{v1modpath, "v1.0.0", http.StatusNotFound},
-		{v1path, "v4.0.0", http.StatusNotFound},
-		{v1modpath + "/v4", "v4.0.0", http.StatusOK},
-	} {
-		if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
-			ModulePath:       mod.path,
-			RequestedVersion: version.Latest,
-			ResolvedVersion:  mod.version,
-			Status:           mod.status,
-			GoModPath:        mod.path,
-		}); err != nil {
-			t.Fatal(err)
-		}
-	}
-	if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
-		ModulePath:       sample.ModulePath + "/blob/master",
-		RequestedVersion: version.Latest,
-		ResolvedVersion:  sample.VersionString,
-		Status:           http.StatusNotFound,
-	}); err != nil {
-		t.Fatal(err)
-	}
-
-	rs, err := miniredis.Run()
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer rs.Close()
-
-	_, handler, _ := newTestServer(t, nil, middleware.NewCacher(redis.NewClient(&redis.Options{Addr: rs.Addr()})))
-
-	for _, test := range []struct {
-		name, path, flash string
-	}{
-		{"github url", "/" + sample.ModulePath + "/blob/master", ""},
-		{"alternative module", "/" + alternativeModule.ModulePath, "module.path/alternative"},
-		{"module not in v1", "/" + v1modpath, "notinv1.mod"},
-		{"import path not in v1", "/" + v1path, "notinv1.mod/foo"},
-	} {
-		t.Run(test.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			handler.ServeHTTP(w, httptest.NewRequest("GET", test.path, nil))
-			// Check for http.StatusFound, which indicates a redirect.
-			if w.Code != http.StatusFound {
-				t.Errorf("%q: got status code = %d, want %d", test.path, w.Code, http.StatusFound)
-			}
-			res := w.Result()
-			c := findCookie(cookie.AlternativeModuleFlash, res.Cookies())
-			if c == nil && test.flash != "" {
-				t.Error("got no flash cookie, expected one")
-			} else if c != nil {
-				val, err := cookie.Base64Value(c)
-				if err != nil {
-					t.Fatal(err)
-				}
-				if val != test.flash {
-					t.Fatalf("got cookie value %q, want %q", val, test.flash)
-				}
-				// If we have a cookie, then following the redirect URL with the cookie
-				// should serve a "redirected from" banner.
-				loc := res.Header.Get("Location")
-				r := httptest.NewRequest("GET", loc, nil)
-				r.AddCookie(c)
-				w = httptest.NewRecorder()
-				handler.ServeHTTP(w, r)
-				err = checkBody(w.Result().Body, in(`[data-test-id="redirected-banner-text"]`, hasText(val)))
-				if err != nil {
-					t.Fatal(err)
-				}
-				// Visiting the same page again without the cookie should not
-				// display the banner.
-				r = httptest.NewRequest("GET", loc, nil)
-				w = httptest.NewRecorder()
-				handler.ServeHTTP(w, r)
-				err = checkBody(w.Result().Body, notIn(`[data-test-id="redirected-banner-text"]`))
-				if err != nil {
-					t.Fatal(err)
-				}
-			}
-		})
-	}
-}
-
 func findCookie(name string, cookies []*http.Cookie) *http.Cookie {
 	for _, c := range cookies {
 		if c.Name == name {
@@ -1489,47 +1296,6 @@
 	}
 }
 
-func TestEmptyDirectoryBetweenNestedModulesRedirect(t *testing.T) {
-	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
-	defer cancel()
-	defer postgres.ResetTestDB(testDB, t)
-
-	postgres.MustInsertModule(ctx, t, testDB, sample.Module(sample.ModulePath, sample.VersionString, ""))
-	postgres.MustInsertModule(ctx, t, testDB, sample.Module(sample.ModulePath+"/missing/dir/c", sample.VersionString, ""))
-
-	missingPath := sample.ModulePath + "/missing"
-	notInsertedPath := sample.ModulePath + "/missing/dir"
-	if err := testDB.UpsertVersionMap(ctx, &internal.VersionMap{
-		ModulePath:       missingPath,
-		RequestedVersion: version.Latest,
-		ResolvedVersion:  sample.VersionString,
-	}); err != nil {
-		t.Fatal(err)
-	}
-
-	_, handler, _ := newTestServer(t, nil, nil)
-	for _, test := range []struct {
-		name, path   string
-		wantStatus   int
-		wantLocation string
-	}{
-		{"want 404 for unknown version of module", sample.ModulePath + "@v0.5.0", http.StatusNotFound, ""},
-		{"want 404 for never fetched directory", notInsertedPath, http.StatusNotFound, ""},
-		{"want 302 for previously fetched directory", missingPath, http.StatusFound, "/search?q=" + url.PathEscape(missingPath)},
-	} {
-		t.Run(test.name, func(t *testing.T) {
-			w := httptest.NewRecorder()
-			handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+test.path, nil))
-			if w.Code != test.wantStatus {
-				t.Errorf("%q: got status code = %d, want %d", "/"+test.path, w.Code, test.wantStatus)
-			}
-			if got := w.Header().Get("Location"); got != test.wantLocation {
-				t.Errorf("got location = %q, want %q", got, test.wantLocation)
-			}
-		})
-	}
-}
-
 func TestStripScheme(t *testing.T) {
 	for _, test := range []struct {
 		url, want string
diff --git a/internal/frontend/unit.go b/internal/frontend/unit.go
index c9b3882..c7e3eb5 100644
--- a/internal/frontend/unit.go
+++ b/internal/frontend/unit.go
@@ -127,7 +127,11 @@
 		if !errors.Is(err, derrors.NotFound) {
 			return err
 		}
-		return s.servePathNotFoundPage(w, r, ds, info.FullPath, info.ModulePath, info.RequestedVersion)
+		db, ok := ds.(internal.PostgresDB)
+		if !ok || s.fetchServer == nil {
+			return datasourceNotSupportedErr()
+		}
+		return s.fetchServer.ServePathNotFoundPage(w, r, db, info.FullPath, info.ModulePath, info.RequestedVersion)
 	}
 
 	makeDepsDevURL := depsDevURLGenerator(ctx, um)
diff --git a/internal/frontend/urlinfo/urlinfo.go b/internal/frontend/urlinfo/urlinfo.go
index b089873..a1ec34b 100644
--- a/internal/frontend/urlinfo/urlinfo.go
+++ b/internal/frontend/urlinfo/urlinfo.go
@@ -35,15 +35,15 @@
 
 type UserError struct {
 	UserMessage string
-	Err         error
+	err         error
 }
 
 func (e *UserError) Error() string {
-	return e.Err.Error()
+	return e.err.Error()
 }
 
 func (e *UserError) Unwrap() error {
-	return e.Err
+	return e.err
 }
 
 // ExtractURLPathInfo extracts information from a request to pkg.go.dev.
@@ -106,7 +106,7 @@
 		// You cannot explicitly write "latest" for the version.
 		if endParts[0] == version.Latest {
 			return nil, &UserError{
-				Err:         fmt.Errorf("invalid version: %q", info.RequestedVersion),
+				err:         fmt.Errorf("invalid version: %q", info.RequestedVersion),
 				UserMessage: fmt.Sprintf("%q is not a valid version", endParts[0]),
 			}
 		}
@@ -123,7 +123,7 @@
 	}
 	if !IsValidPath(info.FullPath) {
 		return nil, &UserError{
-			Err:         fmt.Errorf("IsValidPath(%q) is false", info.FullPath),
+			err:         fmt.Errorf("IsValidPath(%q) is false", info.FullPath),
 			UserMessage: fmt.Sprintf("%q is not a valid import path", info.FullPath),
 		}
 	}
@@ -139,7 +139,7 @@
 	fullPath = strings.TrimSuffix(strings.TrimPrefix(fullPath, "/"), "/")
 	if !IsValidPath(fullPath) {
 		return nil, &UserError{
-			Err:         fmt.Errorf("IsValidPath(%q) is false", fullPath),
+			err:         fmt.Errorf("IsValidPath(%q) is false", fullPath),
 			UserMessage: fmt.Sprintf("%q is not a valid import path", fullPath),
 		}
 	}
@@ -161,7 +161,7 @@
 			return info, nil
 		}
 		return nil, &UserError{
-			Err:         fmt.Errorf("invalid Go tag for url: %q", urlPath),
+			err:         fmt.Errorf("invalid Go tag for url: %q", urlPath),
 			UserMessage: fmt.Sprintf("%q is not a valid tag for the standard library", tag),
 		}
 	}
diff --git a/internal/testing/integration/frontend_test.go b/internal/testing/integration/frontend_test.go
index ceaea33..4e75204 100644
--- a/internal/testing/integration/frontend_test.go
+++ b/internal/testing/integration/frontend_test.go
@@ -33,14 +33,18 @@
 func setupFrontend(ctx context.Context, t *testing.T, q queue.Queue, rc *redis.Client) *httptest.Server {
 	t.Helper()
 	const staticDir = "../../../static"
-	s, err := frontend.NewServer(frontend.ServerConfig{
-		DataSourceGetter:     func(context.Context) internal.DataSource { return testDB },
-		TaskIDChangeInterval: 10 * time.Minute,
-		TemplateFS:           template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant(staticDir)),
-		StaticFS:             os.DirFS(staticDir),
-		ThirdPartyFS:         os.DirFS("../../../third_party"),
+	fs := &frontend.FetchServer{
 		Queue:                q,
-		Config:               &config.Config{ServeStats: true},
+		TaskIDChangeInterval: 10 * time.Minute,
+	}
+	s, err := frontend.NewServer(frontend.ServerConfig{
+		FetchServer:      fs,
+		DataSourceGetter: func(context.Context) internal.DataSource { return testDB },
+		TemplateFS:       template.TrustedFSFromTrustedSource(template.TrustedSourceFromConstant(staticDir)),
+		StaticFS:         os.DirFS(staticDir),
+		ThirdPartyFS:     os.DirFS("../../../third_party"),
+		Queue:            q,
+		Config:           &config.Config{ServeStats: true},
 	})
 	if err != nil {
 		t.Fatal(err)