Merge branch 'stable-0.2-7' into stable-0.2

* stable-0.2-7:
  Set version to 0.2-7.1
  Fix formatting of Soy files
  Bump bazel version to 1.2.0
  Backport all build-related commits from master to stable-0.2
  Enable more error-prone checks
  Enable error-prone checks by default
  Update bazel options to align with gerrit's
  tools/eclipse/project.sh: Use bazel query to find project.py
  Harmonize build rule names to use hyphen instead of underscore
  Check BLOB content size before trying to render it
  BlameCacheImpl: Avoid NPE if path does not exist
  Navbar: Fix handling of [home] and [logo] metalinks
  Don't render navbar.md metadata in html
  Format with google-java-format 1.7
  Do not retain body in RevisionParser walk
  Remove unnecessary parseBody call from LogServlet
  Ensure RevTag is parsed before using its body
  Ensure RevCommit is parsed before using its content
  Require a RevWalk when building CommitData
  Convert /** @param */ to {@param} in Soy.
  RefServlet: Use full refname in link
  Automatically format all build files with buildifier lint mode

Change-Id: I8b140d3893d45370e7e8141e980048cd2ad292f5
diff --git a/.mailmap b/.mailmap
index fd187c2..1799c12 100644
--- a/.mailmap
+++ b/.mailmap
@@ -1,2 +1,3 @@
 Even Stensberg <evenstensberg@gmail.com> ev1stensberg <evenstensberg@gmail.com>
 Shawn Pearce <sop@google.com>            Shawn O. Pearce <sop@google.com>
+Terry Parker <tparker@google.com>        tparker <tparker@google.com>
diff --git a/WORKSPACE b/WORKSPACE
index 1aea906..883ec60e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -58,8 +58,14 @@
 
 maven_jar(
     name = "guava",
-    artifact = "com.google.guava:guava:26.0-jre",
-    sha1 = "6a806eff209f36f635f943e16d97491f00f6bfab",
+    artifact = "com.google.guava:guava:27.1-jre",
+    sha1 = "e47b59c893079b87743cdcfb6f17ca95c08c592c",
+)
+
+maven_jar(
+    name = "guava-failureaccess",
+    artifact = "com.google.guava:failureaccess:1.0.1",
+    sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
 )
 
 maven_jar(
@@ -123,8 +129,8 @@
 
 maven_jar(
     name = "truth",
-    artifact = "com.google.truth:truth:0.42",
-    sha1 = "b5768f644b114e6cf5c3962c2ebcb072f788dcbb",
+    artifact = "com.google.truth:truth:0.44",
+    sha1 = "11eff954c0c14da7d43276d7b3bcf71463105368",
 )
 
 # Indirect dependency of truth
@@ -136,14 +142,14 @@
 
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2018-03-14",
-    sha1 = "76a1322705ba5a6d6329ee26e7387417725ce4b3",
+    artifact = "com.google.template:soy:2019-03-11",
+    sha1 = "119ac4b3eb0e2c638526ca99374013965c727097",
 )
 
 maven_jar(
     name = "html-types",
-    artifact = "com.google.common.html.types:types:1.0.4",
-    sha1 = "2adf4c8bfccc0ff7346f9186ac5aa57d829ad065",
+    artifact = "com.google.common.html.types:types:1.0.8",
+    sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
 )
 
 maven_jar(
@@ -158,7 +164,7 @@
     sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
 )
 
-JGIT_VERS = "5.1.3.201810200350-r"
+JGIT_VERS = "5.3.1.201904271842-r"
 
 JGIT_REPO = MAVEN_CENTRAL
 
@@ -166,28 +172,28 @@
     name = "jgit-lib",
     artifact = "org.eclipse.jgit:org.eclipse.jgit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "f270dbd1d792d5ad06074abe018a18644c90b60e",
+    sha1 = "dba85014483315fa426259bc1b8ccda9373a624b",
 )
 
 maven_jar(
     name = "jgit-servlet",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "360405244c28b537f0eafdc0b9d9f3753503d981",
+    sha1 = "3287341fca859340a00b51cb5dd3b78b8e532b39",
 )
 
 maven_jar(
     name = "jgit-junit",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "1dc8f86bba3c461cb90c9dc3e91bf343889ca684",
+    sha1 = "3d9ba7e610d6ab5d08dcb1e4ba448b592a34de77",
 )
 
 maven_jar(
     name = "jgit-archive",
     artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
     repository = JGIT_REPO,
-    sha1 = "08e10921fcc75ead2736dd5bf099ba8e2ed8a3fb",
+    sha1 = "3585027e83fb44a5de2c10ae9ddbf976593bf080",
 )
 
 maven_jar(
diff --git a/java/com/google/gitiles/ArchiveServlet.java b/java/com/google/gitiles/ArchiveServlet.java
index 3f03455..ed9fedd 100644
--- a/java/com/google/gitiles/ArchiveServlet.java
+++ b/java/com/google/gitiles/ArchiveServlet.java
@@ -14,10 +14,10 @@
 
 package com.google.gitiles;
 
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.common.base.Strings;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import java.util.Optional;
 import javax.servlet.ServletException;
@@ -50,15 +50,13 @@
 
     ObjectId treeId = getTree(view, repo, rev);
     if (treeId.equals(ObjectId.zeroId())) {
-      res.sendError(SC_NOT_FOUND);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE);
     }
 
     Optional<ArchiveFormat> format =
         ArchiveFormat.byExtension(view.getExtension(), getAccess(req).getConfig());
     if (!format.isPresent()) {
-      res.setStatus(SC_NOT_FOUND);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
     String filename = getFilename(view, rev, view.getExtension());
     setDownloadHeaders(req, res, filename, format.get().getMimeType());
diff --git a/java/com/google/gitiles/BaseServlet.java b/java/com/google/gitiles/BaseServlet.java
index 4b8a139..02a3f1a 100644
--- a/java/com/google/gitiles/BaseServlet.java
+++ b/java/com/google/gitiles/BaseServlet.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.net.HttpHeaders;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.FieldNamingPolicy;
 import com.google.gson.GsonBuilder;
 import java.io.BufferedWriter;
@@ -119,8 +120,7 @@
         break;
       case DEFAULT:
       default:
-        res.sendError(SC_BAD_REQUEST);
-        break;
+        throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
   }
 
@@ -147,7 +147,7 @@
    * @param res in-progress response.
    */
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    res.sendError(SC_BAD_REQUEST);
+    throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
   }
 
   /**
@@ -157,7 +157,7 @@
    * @param res in-progress response.
    */
   protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    res.sendError(SC_BAD_REQUEST);
+    throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
   }
 
   /**
@@ -167,7 +167,7 @@
    * @param res in-progress response.
    */
   protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    res.sendError(SC_BAD_REQUEST);
+    throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
   }
 
   protected static Map<String, Object> getData(HttpServletRequest req) {
diff --git a/java/com/google/gitiles/DefaultErrorHandlingFilter.java b/java/com/google/gitiles/DefaultErrorHandlingFilter.java
new file mode 100644
index 0000000..958b800
--- /dev/null
+++ b/java/com/google/gitiles/DefaultErrorHandlingFilter.java
@@ -0,0 +1,63 @@
+// Copyright 2019 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gitiles;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
+
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Convert exceptions into HTTP response. */
+public class DefaultErrorHandlingFilter extends AbstractHttpFilter {
+  private static final Logger log = LoggerFactory.getLogger(DefaultErrorHandlingFilter.class);
+
+  /** HTTP header that indicates an error detail. */
+  public static final String GITILES_ERROR = "X-Gitiles-Error";
+
+  @Override
+  public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+      throws IOException, ServletException {
+    try {
+      chain.doFilter(req, res);
+    } catch (GitilesRequestFailureException e) {
+      res.setHeader(GITILES_ERROR, e.getReason().toString());
+      String publicMessage = e.getPublicErrorMessage();
+      if (publicMessage != null) {
+        res.sendError(e.getReason().getHttpStatusCode(), publicMessage);
+      } else {
+        res.sendError(e.getReason().getHttpStatusCode());
+      }
+    } catch (RepositoryNotFoundException e) {
+      res.sendError(SC_NOT_FOUND);
+    } catch (AmbiguousObjectException e) {
+      res.sendError(SC_BAD_REQUEST);
+    } catch (ServiceMayNotContinueException e) {
+      sendError(req, res, e.getStatusCode(), e.getMessage());
+    } catch (IOException | ServletException err) {
+      log.warn("Internal server error", err);
+      res.sendError(SC_INTERNAL_SERVER_ERROR);
+    }
+  }
+}
diff --git a/java/com/google/gitiles/DescribeServlet.java b/java/com/google/gitiles/DescribeServlet.java
index 785d452..ae1d4c8 100644
--- a/java/com/google/gitiles/DescribeServlet.java
+++ b/java/com/google/gitiles/DescribeServlet.java
@@ -14,11 +14,9 @@
 
 package com.google.gitiles;
 
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableMap;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
 import java.io.IOException;
 import java.io.Writer;
@@ -55,7 +53,7 @@
 
   @Override
   protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req, res);
+    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req);
     if (name == null) {
       return;
     }
@@ -66,7 +64,7 @@
 
   @Override
   protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
-    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req, res);
+    String name = describe(ServletUtils.getRepository(req), ViewFilter.getView(req), req);
     if (name == null) {
       return;
     }
@@ -77,45 +75,34 @@
         new TypeToken<Map<String, String>>() {}.getType());
   }
 
-  private ObjectId resolve(
-      Repository repo, GitilesView view, HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  private ObjectId resolve(Repository repo, GitilesView view) throws IOException {
     String rev = view.getPathPart();
     try {
       return repo.resolve(rev);
     } catch (RevisionSyntaxException e) {
-      renderTextError(
-          req,
-          res,
-          SC_BAD_REQUEST,
-          "Invalid revision syntax: " + RefServlet.sanitizeRefForText(rev));
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER)
+          .withPublicErrorMessage(
+              "Invalid revision syntax: %s", RefServlet.sanitizeRefForText(rev));
     } catch (AmbiguousObjectException e) {
-      renderTextError(
-          req,
-          res,
-          SC_BAD_REQUEST,
-          String.format(
+      throw new GitilesRequestFailureException(FailureReason.AMBIGUOUS_OBJECT)
+          .withPublicErrorMessage(
               "Ambiguous short SHA-1 %s (%s)",
-              e.getAbbreviatedObjectId(), Joiner.on(", ").join(e.getCandidates())));
-      return null;
+              e.getAbbreviatedObjectId(), Joiner.on(", ").join(e.getCandidates()));
     }
   }
 
-  private String describe(
-      Repository repo, GitilesView view, HttpServletRequest req, HttpServletResponse res)
+  private String describe(Repository repo, GitilesView view, HttpServletRequest req)
       throws IOException {
     if (!getBooleanParam(view, CONTAINS_PARAM)) {
-      res.setStatus(SC_BAD_REQUEST);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER);
     }
-    ObjectId id = resolve(repo, view, req, res);
+    ObjectId id = resolve(repo, view);
     if (id == null) {
       return null;
     }
     String name;
     try (Git git = new Git(repo)) {
-      NameRevCommand cmd = nameRevCommand(git, id, req, res);
+      NameRevCommand cmd = nameRevCommand(git, id, req);
       if (cmd == null) {
         return null;
       }
@@ -124,21 +111,20 @@
       throw new IOException(e);
     }
     if (name == null) {
-      res.setStatus(SC_NOT_FOUND);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
     }
     return name;
   }
 
-  private NameRevCommand nameRevCommand(
-      Git git, ObjectId id, HttpServletRequest req, HttpServletResponse res) throws IOException {
+  private NameRevCommand nameRevCommand(Git git, ObjectId id, HttpServletRequest req)
+      throws IOException {
     GitilesView view = ViewFilter.getView(req);
     NameRevCommand cmd = git.nameRev();
     boolean all = getBooleanParam(view, ALL_PARAM);
     boolean tags = getBooleanParam(view, TAGS_PARAM);
     if (all && tags) {
-      renderTextError(req, res, SC_BAD_REQUEST, "Cannot specify both \"all\" and \"tags\"");
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_REVISION_NAMES)
+          .withPublicErrorMessage("Cannot specify both \"all\" and \"tags\"");
     }
     if (all) {
       cmd.addPrefix(Constants.R_REFS);
diff --git a/java/com/google/gitiles/DiffServlet.java b/java/com/google/gitiles/DiffServlet.java
index d0e4409..5b1801d 100644
--- a/java/com/google/gitiles/DiffServlet.java
+++ b/java/com/google/gitiles/DiffServlet.java
@@ -15,11 +15,11 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gitiles.CommitData.Field;
 import com.google.gitiles.DateFormatter.Format;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
@@ -67,8 +67,7 @@
       AbstractTreeIterator newTree;
       try {
         if (tw == null && !view.getPathPart().isEmpty()) {
-          res.setStatus(SC_NOT_FOUND);
-          return;
+          throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
         }
         isFile = tw != null && isFile(tw);
 
@@ -77,9 +76,10 @@
         showCommit = isParentOf(walk, view.getOldRevision(), view.getRevision());
         oldTree = getTreeIterator(walk, view.getOldRevision().getId());
         newTree = getTreeIterator(walk, view.getRevision().getId());
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+      } catch (MissingObjectException e) {
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND, e);
+      } catch (IncorrectObjectTypeException e) {
+        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE, e);
       }
 
       Map<String, Object> data = getData(req);
@@ -124,9 +124,10 @@
       try {
         oldTree = getTreeIterator(walk, view.getOldRevision().getId());
         newTree = getTreeIterator(walk, view.getRevision().getId());
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+      } catch (MissingObjectException e) {
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND, e);
+      } catch (IncorrectObjectTypeException e) {
+        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE, e);
       }
 
       try (Writer writer = startRenderText(req, res);
diff --git a/java/com/google/gitiles/GitilesFilter.java b/java/com/google/gitiles/GitilesFilter.java
index 59e8acd..2c810bb 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -37,6 +37,7 @@
 import java.util.Iterator;
 import java.util.Map;
 import java.util.regex.Pattern;
+import javax.annotation.Nullable;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -174,20 +175,22 @@
   private TimeCache timeCache;
   private BlameCache blameCache;
   private GitwebRedirectFilter gitwebRedirect;
+  private Filter errorHandler;
   private boolean initialized;
 
   GitilesFilter() {}
 
   GitilesFilter(
       Config config,
-      Renderer renderer,
-      GitilesUrls urls,
-      GitilesAccess.Factory accessFactory,
-      final RepositoryResolver<HttpServletRequest> resolver,
-      VisibilityCache visibilityCache,
-      TimeCache timeCache,
-      BlameCache blameCache,
-      GitwebRedirectFilter gitwebRedirect) {
+      @Nullable Renderer renderer,
+      @Nullable GitilesUrls urls,
+      @Nullable GitilesAccess.Factory accessFactory,
+      @Nullable RepositoryResolver<HttpServletRequest> resolver,
+      @Nullable VisibilityCache visibilityCache,
+      @Nullable TimeCache timeCache,
+      @Nullable BlameCache blameCache,
+      @Nullable GitwebRedirectFilter gitwebRedirect,
+      @Nullable Filter errorHandler) {
     this.config = checkNotNull(config, "config");
     this.renderer = renderer;
     this.urls = urls;
@@ -199,6 +202,7 @@
     if (resolver != null) {
       this.resolver = resolver;
     }
+    this.errorHandler = errorHandler;
   }
 
   @Override
@@ -232,6 +236,12 @@
     initialized = true;
   }
 
+  @Override
+  protected ServletBinder register(ServletBinder b) {
+    b.through(errorHandler);
+    return b;
+  }
+
   public synchronized BaseServlet getDefaultHandler(GitilesView.Type view) {
     checkNotInitialized();
     switch (view) {
@@ -300,6 +310,7 @@
     setDefaultTimeCache();
     setDefaultBlameCache();
     setDefaultGitwebRedirect();
+    setDefaultErrorHandler();
   }
 
   private void setDefaultConfig(FilterConfig filterConfig) throws ServletException {
@@ -407,6 +418,12 @@
     }
   }
 
+  private void setDefaultErrorHandler() {
+    if (errorHandler == null) {
+      errorHandler = new DefaultErrorHandlingFilter();
+    }
+  }
+
   private static String getBaseGitUrl(Config config) throws ServletException {
     String baseGitUrl = config.getString("gitiles", null, "baseGitUrl");
     if (baseGitUrl == null) {
diff --git a/java/com/google/gitiles/GitilesRequestFailureException.java b/java/com/google/gitiles/GitilesRequestFailureException.java
new file mode 100644
index 0000000..316c023
--- /dev/null
+++ b/java/com/google/gitiles/GitilesRequestFailureException.java
@@ -0,0 +1,164 @@
+// Copyright 2019 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gitiles;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_GONE;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import javax.annotation.Nullable;
+
+/**
+ * Indicates the request should be failed.
+ *
+ * <p>When an HTTP request should be failed, throw this exception instead of directly setting an
+ * HTTP status code. The exception is caught by an error handler in {@link GitilesFilter}. By
+ * default, {@link DefaultErrorHandlingFilter} handles this exception and set an appropriate HTTP
+ * status code. If you want to customize how the error is surfaced, like changing the error page
+ * rendering, replace this error handler from {@link GitilesServlet}.
+ *
+ * <h2>Extending the error space</h2>
+ *
+ * <p>{@link GitilesServlet} lets you customize some parts of Gitiles, and sometimes you would like
+ * to create a new {@link FailureReason}. For example, a customized {@code RepositoryResolver} might
+ * check a request quota and reject a request if a client sends too many requests. In that case, you
+ * can define your own {@link RuntimeException} and an error handler.
+ *
+ * <pre><code>
+ *   public final class MyRequestFailureException extends RuntimeException {
+ *     private final FailureReason reason;
+ *
+ *     public MyRequestFailureException(FailureReason reason) {
+ *       super();
+ *       this.reason = reason;
+ *     }
+ *
+ *     public FailureReason getReason() {
+ *       return reason;
+ *     }
+ *
+ *     enum FailureReason {
+ *       QUOTA_EXCEEDED(429);
+ *     }
+ *
+ *     private final int httpStatusCode;
+ *
+ *     FailureReason(int httpStatusCode) {
+ *       this.httpStatusCode = httpStatusCode;
+ *     }
+ *
+ *     public int getHttpStatusCode() {
+ *       return httpStatusCode;
+ *     }
+ *   }
+ *
+ *   public class MyErrorHandlingFilter extends AbstractHttpFilter {
+ *     private static final DefaultErrorHandlingFilter delegate =
+ *         new DefaultErrorHandlingFilter();
+ *
+ *     {@literal @}Override
+ *     public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
+ *         throws IOException, ServletException {
+ *       try {
+ *         delegate.doFilter(req, res, chain);
+ *       } catch (MyRequestFailureException e) {
+ *         res.setHeader(DefaultErrorHandlingFilter.GITILES_ERROR, e.getReason().toString());
+ *         res.sendError(e.getReason().getHttpStatusCode());
+ *       }
+ *     }
+ *   }
+ * </code></pre>
+ *
+ * <p>{@code RepositoryResolver} can throw {@code MyRequestFailureException} and {@code
+ * MyErrorHandlingFilter} will handle that. You can control how the error should be surfaced.
+ */
+public final class GitilesRequestFailureException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+  private final FailureReason reason;
+  private String publicErrorMessage;
+
+  public GitilesRequestFailureException(FailureReason reason) {
+    super();
+    this.reason = reason;
+  }
+
+  public GitilesRequestFailureException(FailureReason reason, Throwable cause) {
+    super(cause);
+    this.reason = reason;
+  }
+
+  public GitilesRequestFailureException withPublicErrorMessage(String format, Object... params) {
+    this.publicErrorMessage = String.format(format, params);
+    return this;
+  }
+
+  public FailureReason getReason() {
+    return reason;
+  }
+
+  @Nullable
+  public String getPublicErrorMessage() {
+    return publicErrorMessage;
+  }
+
+  /** The request failure reason. */
+  public enum FailureReason {
+    /** The object specified by the URL is ambiguous and Gitiles cannot identify one object. */
+    AMBIGUOUS_OBJECT(SC_BAD_REQUEST),
+    /** There's nothing to show for blame (e.g. the file is empty). */
+    BLAME_REGION_NOT_FOUND(SC_NOT_FOUND),
+    /** Cannot parse URL as a Gitiles URL. */
+    CANNOT_PARSE_GITILES_VIEW(SC_NOT_FOUND),
+    /** URL parameters are not valid. */
+    INCORECT_PARAMETER(SC_BAD_REQUEST),
+    /**
+     * The object specified by the URL is not suitable for the view (e.g. trying to show a blob as a
+     * tree).
+     */
+    INCORRECT_OBJECT_TYPE(SC_BAD_REQUEST),
+    /** Markdown rendering is not enabled. */
+    MARKDOWN_NOT_ENABLED(SC_NOT_FOUND),
+    /** Request is not authorized. */
+    NOT_AUTHORIZED(SC_UNAUTHORIZED),
+    /** Object is not found. */
+    OBJECT_NOT_FOUND(SC_NOT_FOUND),
+    /** Object is too large to show. */
+    OBJECT_TOO_LARGE(SC_INTERNAL_SERVER_ERROR),
+    /** Repository is not found. */
+    REPOSITORY_NOT_FOUND(SC_NOT_FOUND),
+    /** Gitiles is not enabled for the repository. */
+    SERVICE_NOT_ENABLED(SC_FORBIDDEN),
+    /** GitWeb URL cannot be converted to Gitiles URL. */
+    UNSUPPORTED_GITWEB_URL(SC_GONE),
+    /** The specified object's type is not supported. */
+    UNSUPPORTED_OBJECT_TYPE(SC_NOT_FOUND),
+    /** The specified format type is not supported. */
+    UNSUPPORTED_RESPONSE_FORMAT(SC_BAD_REQUEST),
+    /** The specified revision names are not supported. */
+    UNSUPPORTED_REVISION_NAMES(SC_BAD_REQUEST);
+
+    private final int httpStatusCode;
+
+    FailureReason(int httpStatusCode) {
+      this.httpStatusCode = httpStatusCode;
+    }
+
+    public int getHttpStatusCode() {
+      return httpStatusCode;
+    }
+  }
+}
diff --git a/java/com/google/gitiles/GitilesServlet.java b/java/com/google/gitiles/GitilesServlet.java
index 56f4c61..6d10c0c 100644
--- a/java/com/google/gitiles/GitilesServlet.java
+++ b/java/com/google/gitiles/GitilesServlet.java
@@ -52,6 +52,30 @@
       @Nullable TimeCache timeCache,
       @Nullable BlameCache blameCache,
       @Nullable GitwebRedirectFilter gitwebRedirect) {
+    this(
+        config,
+        renderer,
+        urls,
+        accessFactory,
+        resolver,
+        visibilityCache,
+        timeCache,
+        blameCache,
+        gitwebRedirect,
+        null);
+  }
+
+  public GitilesServlet(
+      Config config,
+      @Nullable Renderer renderer,
+      @Nullable GitilesUrls urls,
+      @Nullable GitilesAccess.Factory accessFactory,
+      @Nullable RepositoryResolver<HttpServletRequest> resolver,
+      @Nullable VisibilityCache visibilityCache,
+      @Nullable TimeCache timeCache,
+      @Nullable BlameCache blameCache,
+      @Nullable GitwebRedirectFilter gitwebRedirect,
+      @Nullable Filter errorHandler) {
     super(
         new GitilesFilter(
             config,
@@ -62,7 +86,8 @@
             visibilityCache,
             timeCache,
             blameCache,
-            gitwebRedirect));
+            gitwebRedirect,
+            errorHandler));
   }
 
   public GitilesServlet() {
diff --git a/java/com/google/gitiles/GitwebRedirectFilter.java b/java/com/google/gitiles/GitwebRedirectFilter.java
index f1e01bc..0d0c74f 100644
--- a/java/com/google/gitiles/GitwebRedirectFilter.java
+++ b/java/com/google/gitiles/GitwebRedirectFilter.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.net.HttpHeaders.LOCATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static javax.servlet.http.HttpServletResponse.SC_GONE;
 import static javax.servlet.http.HttpServletResponse.SC_MOVED_PERMANENTLY;
 
 import com.google.common.base.Splitter;
@@ -26,6 +25,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gitiles.GitilesView.InvalidViewException;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
@@ -117,8 +117,7 @@
     } else {
       // Gitiles does not provide an RSS feed (a=rss,atom,opml)
       // Any other URL is out of date and not valid anymore.
-      res.sendError(SC_GONE);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_GITWEB_URL);
     }
 
     if (!Strings.isNullOrEmpty(project)) {
@@ -132,8 +131,7 @@
               .setServletPath(gitwebView.getServletPath())
               .toUrl();
     } catch (InvalidViewException e) {
-      res.setStatus(SC_GONE);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_GITWEB_URL);
     }
     res.setStatus(SC_MOVED_PERMANENTLY);
     res.setHeader(LOCATION, url);
diff --git a/java/com/google/gitiles/HostIndexServlet.java b/java/com/google/gitiles/HostIndexServlet.java
index d23aa3c..8b8a252 100644
--- a/java/com/google/gitiles/HostIndexServlet.java
+++ b/java/com/google/gitiles/HostIndexServlet.java
@@ -15,16 +15,11 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
 import com.google.template.soy.data.SoyListData;
 import com.google.template.soy.data.SoyMapData;
@@ -40,17 +35,12 @@
 import javax.annotation.Nullable;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
 import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Serves the top level index page for a Gitiles host. */
 public class HostIndexServlet extends BaseServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(HostIndexServlet.class);
 
   protected final GitilesUrls urls;
 
@@ -61,32 +51,17 @@
   }
 
   private Map<String, RepositoryDescription> list(
-      HttpServletRequest req, HttpServletResponse res, String prefix, Set<String> branches)
-      throws IOException {
+      HttpServletRequest req, String prefix, Set<String> branches) throws IOException {
     Map<String, RepositoryDescription> descs;
     try {
       descs = getAccess(req).listRepositories(prefix, branches);
-    } catch (RepositoryNotFoundException e) {
-      res.sendError(SC_NOT_FOUND);
-      return null;
     } catch (ServiceNotEnabledException e) {
-      res.sendError(SC_FORBIDDEN);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.SERVICE_NOT_ENABLED, e);
     } catch (ServiceNotAuthorizedException e) {
-      res.sendError(SC_UNAUTHORIZED);
-      return null;
-    } catch (ServiceMayNotContinueException e) {
-      sendError(req, res, e.getStatusCode(), e.getMessage());
-      return null;
-    } catch (IOException err) {
-      String name = urls.getHostName(req);
-      log.warn("Cannot scan repositories" + (name != null ? " for " + name : ""), err);
-      res.sendError(SC_SERVICE_UNAVAILABLE);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.NOT_AUTHORIZED, e);
     }
     if (prefix != null && descs.isEmpty()) {
-      res.sendError(SC_NOT_FOUND);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.REPOSITORY_NOT_FOUND);
     }
     return descs;
   }
@@ -103,15 +78,13 @@
   protected void doHead(HttpServletRequest req, HttpServletResponse res) throws IOException {
     Optional<FormatType> format = getFormat(req);
     if (!format.isPresent()) {
-      res.sendError(SC_BAD_REQUEST);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
 
     GitilesView view = ViewFilter.getView(req);
     String prefix = view.getRepositoryPrefix();
     if (prefix != null) {
-      Map<String, RepositoryDescription> descs =
-          list(req, res, prefix, Collections.<String>emptySet());
+      Map<String, RepositoryDescription> descs = list(req, prefix, Collections.emptySet());
       if (descs == null) {
         return;
       }
@@ -125,8 +98,7 @@
         break;
       case DEFAULT:
       default:
-        res.sendError(SC_BAD_REQUEST);
-        break;
+        throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
   }
 
@@ -134,7 +106,7 @@
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
     GitilesView view = ViewFilter.getView(req);
     String prefix = view.getRepositoryPrefix();
-    Map<String, RepositoryDescription> descs = list(req, res, prefix, parseShowBranch(req));
+    Map<String, RepositoryDescription> descs = list(req, prefix, parseShowBranch(req));
     if (descs == null) {
       return;
     }
@@ -171,7 +143,7 @@
   protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException {
     String prefix = ViewFilter.getView(req).getRepositoryPrefix();
     Set<String> branches = parseShowBranch(req);
-    Map<String, RepositoryDescription> descs = list(req, res, prefix, branches);
+    Map<String, RepositoryDescription> descs = list(req, prefix, branches);
     if (descs == null) {
       return;
     }
@@ -196,7 +168,7 @@
   @Override
   protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
     String prefix = ViewFilter.getView(req).getRepositoryPrefix();
-    Map<String, RepositoryDescription> descs = list(req, res, prefix, parseShowBranch(req));
+    Map<String, RepositoryDescription> descs = list(req, prefix, parseShowBranch(req));
     if (descs == null) {
       return;
     }
diff --git a/java/com/google/gitiles/LogServlet.java b/java/com/google/gitiles/LogServlet.java
index f1bdd95..a91aeab 100644
--- a/java/com/google/gitiles/LogServlet.java
+++ b/java/com/google/gitiles/LogServlet.java
@@ -15,8 +15,6 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -27,6 +25,7 @@
 import com.google.common.primitives.Longs;
 import com.google.gitiles.CommitData.Field;
 import com.google.gitiles.DateFormatter.Format;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -42,7 +41,6 @@
 import org.eclipse.jgit.diff.DiffConfig;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RevWalkException;
 import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Constants;
@@ -62,13 +60,10 @@
 import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
 import org.eclipse.jgit.util.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Serves an HTML page with a shortlog for commits and paths. */
 public class LogServlet extends BaseServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(LogServlet.class);
 
   static final String LIMIT_PARAM = "n";
   static final String START_PARAM = "s";
@@ -97,8 +92,7 @@
       GitilesAccess access = getAccess(req);
       paginator = newPaginator(repo, view, access);
       if (paginator == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
       DateFormatter df = new DateFormatter(access, Format.DEFAULT);
 
@@ -133,10 +127,6 @@
             .renderStreaming(paginator, null, renderer, w, df, LogSoyData.FooterBehavior.NEXT);
         w.flush();
       }
-    } catch (RevWalkException e) {
-      log.warn("Error in rev walk", e);
-      res.setStatus(SC_INTERNAL_SERVER_ERROR);
-      return;
     } finally {
       if (paginator != null) {
         paginator.getWalk().close();
@@ -160,8 +150,7 @@
       GitilesAccess access = getAccess(req);
       paginator = newPaginator(repo, view, access);
       if (paginator == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
       DateFormatter df = new DateFormatter(access, Format.DEFAULT);
       CommitJsonData.Log result = new CommitJsonData.Log();
diff --git a/java/com/google/gitiles/PathServlet.java b/java/com/google/gitiles/PathServlet.java
index 0ecb875..34e0a76 100644
--- a/java/com/google/gitiles/PathServlet.java
+++ b/java/com/google/gitiles/PathServlet.java
@@ -16,8 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gitiles.TreeSoyData.resolveTargetUrl;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
 import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
@@ -29,6 +27,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Bytes;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
@@ -61,14 +60,11 @@
 import org.eclipse.jgit.util.QuotedString;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Serves an HTML page with detailed information about a path within a tree. */
 // TODO(dborowitz): Handle non-UTF-8 names.
 public class PathServlet extends BaseServlet {
   private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(PathServlet.class);
 
   static final String MODE_HEADER = "X-Gitiles-Path-Mode";
   static final String TYPE_HEADER = "X-Gitiles-Object-Type";
@@ -131,8 +127,7 @@
     try (RevWalk rw = new RevWalk(repo);
         WalkResult wr = WalkResult.forPath(rw, view, false)) {
       if (wr == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
       switch (wr.type) {
         case TREE:
@@ -149,12 +144,10 @@
           showGitlink(req, res, wr);
           break;
         default:
-          log.error("Bad file type: {}", wr.type);
-          res.setStatus(SC_NOT_FOUND);
-          break;
+          throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
       }
     } catch (LargeObjectException e) {
-      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_TOO_LARGE, e);
     }
   }
 
@@ -166,8 +159,7 @@
     try (RevWalk rw = new RevWalk(repo);
         WalkResult wr = WalkResult.forPath(rw, view, false)) {
       if (wr == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
 
       // Write base64 as plain text without modifying any other headers, under
@@ -185,11 +177,10 @@
           break;
         case GITLINK:
         default:
-          renderTextError(req, res, SC_NOT_FOUND, "Not a file");
-          break;
+          throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
       }
     } catch (LargeObjectException e) {
-      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_TOO_LARGE, e);
     }
   }
 
@@ -252,8 +243,7 @@
     try (RevWalk rw = new RevWalk(repo);
         WalkResult wr = WalkResult.forPath(rw, view, recursive)) {
       if (wr == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
       switch (wr.type) {
         case REGULAR_FILE:
@@ -275,11 +265,10 @@
         case GITLINK:
         case SYMLINK:
         default:
-          res.setStatus(SC_NOT_FOUND);
-          break;
+          throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
       }
     } catch (LargeObjectException e) {
-      res.setStatus(SC_INTERNAL_SERVER_ERROR);
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_TOO_LARGE, e);
     }
   }
 
diff --git a/java/com/google/gitiles/ReadmeHelper.java b/java/com/google/gitiles/ReadmeHelper.java
index 911c4ef..a01c7f0 100644
--- a/java/com/google/gitiles/ReadmeHelper.java
+++ b/java/com/google/gitiles/ReadmeHelper.java
@@ -14,10 +14,10 @@
 
 package com.google.gitiles;
 
+import com.google.common.html.types.SafeHtml;
 import com.google.gitiles.doc.GitilesMarkdown;
 import com.google.gitiles.doc.MarkdownConfig;
 import com.google.gitiles.doc.MarkdownToHtml;
-import com.google.template.soy.data.SanitizedContent;
 import java.io.IOException;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -87,7 +87,7 @@
     return readmePath;
   }
 
-  SanitizedContent render() {
+  SafeHtml render() {
     try {
       byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(config.inputLimit);
       return MarkdownToHtml.builder()
diff --git a/java/com/google/gitiles/RefServlet.java b/java/com/google/gitiles/RefServlet.java
index 2b16ef5..fd93126 100644
--- a/java/com/google/gitiles/RefServlet.java
+++ b/java/com/google/gitiles/RefServlet.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -25,6 +24,7 @@
 import com.google.common.collect.Ordering;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
 import java.io.IOException;
 import java.io.Writer;
@@ -59,8 +59,7 @@
   @Override
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
     if (!ViewFilter.getView(req).getPathPart().isEmpty()) {
-      res.setStatus(SC_NOT_FOUND);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER);
     }
     List<Map<String, Object>> tags;
     try (RevWalk walk = new RevWalk(ServletUtils.getRepository(req))) {
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java
index 655b8d2..9ead8bf 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -26,6 +26,7 @@
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.common.html.types.LegacyConversions;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
 import com.google.template.soy.tofu.SoyTofu;
@@ -210,7 +211,15 @@
   }
 
   SoyTofu.Renderer newRenderer(String templateName) {
-    return getTofu().newRenderer(templateName);
+    ImmutableMap.Builder<String, Object> staticUrls = ImmutableMap.builder();
+    for (String key : STATIC_URL_GLOBALS.keySet()) {
+      staticUrls.put(
+          key.replaceFirst("^gitiles\\.", ""),
+          LegacyConversions.riskilyAssumeTrustedResourceUrl(globals.get(key)));
+    }
+    return getTofu()
+        .newRenderer(templateName)
+        .setIjData(ImmutableMap.of("staticUrls", staticUrls.build()));
   }
 
   protected abstract SoyTofu getTofu();
diff --git a/java/com/google/gitiles/RepositoryFilter.java b/java/com/google/gitiles/RepositoryFilter.java
index fa9d601..a25a8de 100644
--- a/java/com/google/gitiles/RepositoryFilter.java
+++ b/java/com/google/gitiles/RepositoryFilter.java
@@ -16,11 +16,9 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gitiles.ViewFilter.getRegexGroup;
-import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
-import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
-import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
 import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
 
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -28,12 +26,12 @@
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
 import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
 
 class RepositoryFilter extends AbstractHttpFilter {
+
   private final RepositoryResolver<HttpServletRequest> resolver;
 
   RepositoryFilter(RepositoryResolver<HttpServletRequest> resolver) {
@@ -53,15 +51,13 @@
         // to HostIndexServlet which will attempt to list repositories
         // or send SC_NOT_FOUND there.
         chain.doFilter(req, res);
-      } catch (ServiceMayNotContinueException e) {
-        sendError(req, res, e.getStatusCode(), e.getMessage());
       } finally {
         req.removeAttribute(ATTRIBUTE_REPOSITORY);
       }
     } catch (ServiceNotEnabledException e) {
-      sendError(req, res, SC_FORBIDDEN);
+      throw new GitilesRequestFailureException(FailureReason.SERVICE_NOT_ENABLED, e);
     } catch (ServiceNotAuthorizedException e) {
-      res.sendError(SC_UNAUTHORIZED);
+      throw new GitilesRequestFailureException(FailureReason.NOT_AUTHORIZED, e);
     }
   }
 }
diff --git a/java/com/google/gitiles/RepositoryIndexServlet.java b/java/com/google/gitiles/RepositoryIndexServlet.java
index 7d7ac36..d057512 100644
--- a/java/com/google/gitiles/RepositoryIndexServlet.java
+++ b/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -15,16 +15,16 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.common.html.types.SafeHtml;
 import com.google.gitiles.DateFormatter.Format;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gitiles.doc.MarkdownConfig;
 import com.google.gson.reflect.TypeToken;
-import com.google.template.soy.data.SanitizedContent;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
@@ -65,8 +65,7 @@
     // If the repository didn't exist a prior filter would have 404 replied.
     Optional<FormatType> format = getFormat(req);
     if (!format.isPresent()) {
-      res.sendError(SC_BAD_REQUEST);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
     switch (format.get()) {
       case HTML:
@@ -77,8 +76,7 @@
       case TEXT:
       case DEFAULT:
       default:
-        res.sendError(SC_BAD_REQUEST);
-        break;
+        throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT);
     }
   }
 
@@ -174,7 +172,7 @@
             req.getRequestURI());
     readme.scanTree(rootTree);
     if (readme.isPresent()) {
-      SanitizedContent html = readme.render();
+      SafeHtml html = readme.render();
       if (html != null) {
         return ImmutableMap.<String, Object>of("readmeHtml", html);
       }
diff --git a/java/com/google/gitiles/RevisionServlet.java b/java/com/google/gitiles/RevisionServlet.java
index 597c053..38aecb6 100644
--- a/java/com/google/gitiles/RevisionServlet.java
+++ b/java/com/google/gitiles/RevisionServlet.java
@@ -15,7 +15,6 @@
 package com.google.gitiles;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT;
 import static org.eclipse.jgit.lib.Constants.OBJ_TAG;
@@ -28,6 +27,7 @@
 import com.google.gitiles.CommitData.Field;
 import com.google.gitiles.CommitJsonData.Commit;
 import com.google.gitiles.DateFormatter.Format;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
@@ -49,8 +49,6 @@
 import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Serves an HTML page with detailed information about a ref. */
 public class RevisionServlet extends BaseServlet {
@@ -60,7 +58,6 @@
       Field.setOf(CommitJsonData.DEFAULT_FIELDS, Field.DIFF_TREE);
 
   private static final long serialVersionUID = 1L;
-  private static final Logger log = LoggerFactory.getLogger(RevisionServlet.class);
 
   private final Linkifier linkifier;
 
@@ -125,18 +122,12 @@
                       new TagSoyData(linkifier, req).toSoyData(walk, (RevTag) obj, df)));
               break;
             default:
-              log.warn("Bad object type for {}: {}", ObjectId.toString(obj.getId()), obj.getType());
-              res.setStatus(SC_NOT_FOUND);
-              return;
+              throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
           }
         } catch (MissingObjectException e) {
-          log.warn("Missing object " + ObjectId.toString(obj.getId()), e);
-          res.setStatus(SC_NOT_FOUND);
-          return;
+          throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND, e);
         } catch (IncorrectObjectTypeException e) {
-          log.warn("Incorrect object type for " + ObjectId.toString(obj.getId()), e);
-          res.setStatus(SC_NOT_FOUND);
-          return;
+          throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE, e);
         }
       }
 
@@ -159,13 +150,12 @@
     try (ObjectReader reader = repo.newObjectReader()) {
       ObjectLoader loader = reader.open(view.getRevision().getId());
       if (loader.getType() != OBJ_COMMIT) {
-        res.setStatus(SC_NOT_FOUND);
-      } else {
-        PathServlet.setTypeHeader(res, loader.getType());
-        try (Writer writer = startRenderText(req, res);
-            OutputStream out = BaseEncoding.base64().encodingStream(writer)) {
-          loader.copyTo(out);
-        }
+        throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
+      }
+      PathServlet.setTypeHeader(res, loader.getType());
+      try (Writer writer = startRenderText(req, res);
+          OutputStream out = BaseEncoding.base64().encodingStream(writer)) {
+        loader.copyTo(out);
       }
     }
   }
@@ -188,8 +178,7 @@
           break;
         default:
           // TODO(dborowitz): Support showing other types.
-          res.setStatus(SC_NOT_FOUND);
-          break;
+          throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_OBJECT_TYPE);
       }
     }
   }
diff --git a/java/com/google/gitiles/RootedDocServlet.java b/java/com/google/gitiles/RootedDocServlet.java
index 96329b1..8f021e6 100644
--- a/java/com/google/gitiles/RootedDocServlet.java
+++ b/java/com/google/gitiles/RootedDocServlet.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
 
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gitiles.doc.DocServlet;
 import com.google.gitiles.doc.HtmlSanitizer;
 import java.io.IOException;
@@ -24,7 +25,6 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -33,12 +33,9 @@
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
 import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Serves Markdown at the root of a host. */
 public class RootedDocServlet extends HttpServlet {
-  private static final Logger log = LoggerFactory.getLogger(ViewFilter.class);
   private static final long serialVersionUID = 1L;
   public static final String BRANCH = "refs/heads/md-pages";
 
@@ -74,14 +71,12 @@
         RevWalk rw = new RevWalk(repo)) {
       ObjectId id = repo.resolve(BRANCH);
       if (id == null) {
-        res.sendError(HttpServletResponse.SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
 
       RevObject obj = rw.peel(rw.parseAny(id));
       if (!(obj instanceof RevCommit)) {
-        res.sendError(HttpServletResponse.SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE);
       }
 
       req.setAttribute(ATTRIBUTE_REPOSITORY, repo);
@@ -95,11 +90,10 @@
               .build());
 
       docServlet.service(req, res);
-    } catch (RepositoryNotFoundException
-        | ServiceNotAuthorizedException
-        | ServiceNotEnabledException e) {
-      log.error(String.format("cannot open repository for %s", req.getServerName()), e);
-      res.sendError(HttpServletResponse.SC_NOT_FOUND);
+    } catch (ServiceNotAuthorizedException e) {
+      throw new GitilesRequestFailureException(FailureReason.NOT_AUTHORIZED, e);
+    } catch (ServiceNotEnabledException e) {
+      throw new GitilesRequestFailureException(FailureReason.SERVICE_NOT_ENABLED, e);
     } finally {
       ViewFilter.removeView(req);
       req.removeAttribute(ATTRIBUTE_REPOSITORY);
diff --git a/java/com/google/gitiles/ViewFilter.java b/java/com/google/gitiles/ViewFilter.java
index 03e8d9a..f400ff0 100644
--- a/java/com/google/gitiles/ViewFilter.java
+++ b/java/com/google/gitiles/ViewFilter.java
@@ -16,12 +16,10 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
-import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
-import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
 import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
 
 import com.google.common.base.Strings;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import java.io.IOException;
 import java.util.Map;
 import javax.servlet.FilterChain;
@@ -30,13 +28,9 @@
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.http.server.glue.WrappedRequest;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Filter to parse URLs and convert them to {@link GitilesView}s. */
 public class ViewFilter extends AbstractHttpFilter {
-  private static final Logger log = LoggerFactory.getLogger(ViewFilter.class);
   // TODO(dborowitz): Make this public in JGit (or implement getRegexGroup
   // upstream).
   private static final String REGEX_GROUPS_ATTRIBUTE =
@@ -102,21 +96,9 @@
   @Override
   public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
       throws IOException, ServletException {
-    GitilesView.Builder view;
-    try {
-      view = parse(req);
-    } catch (ServiceMayNotContinueException e) {
-      sendError(req, res, e.getStatusCode(), e.getMessage());
-      return;
-    } catch (IOException err) {
-      String name = urls.getHostName(req);
-      log.warn("Cannot parse view" + (name != null ? " for " + name : ""), err);
-      res.setStatus(SC_SERVICE_UNAVAILABLE);
-      return;
-    }
+    GitilesView.Builder view = parse(req);
     if (view == null) {
-      res.setStatus(SC_NOT_FOUND);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.CANNOT_PARSE_GITILES_VIEW);
     }
 
     @SuppressWarnings("unchecked")
diff --git a/java/com/google/gitiles/VisibilityCache.java b/java/com/google/gitiles/VisibilityCache.java
index 75b5e9f..fbb3a45 100644
--- a/java/com/google/gitiles/VisibilityCache.java
+++ b/java/com/google/gitiles/VisibilityCache.java
@@ -37,6 +37,7 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -135,10 +136,11 @@
       return false;
     }
 
+    RefDatabase refDb = repo.getRefDatabase();
+
     // If any reference directly points at the requested object, permit display. Common for displays
     // of pending patch sets in Gerrit Code Review, or bookmarks to the commit a tag points at.
-    Collection<Ref> all = repo.getRefDatabase().getRefs();
-    for (Ref ref : all) {
+    for (Ref ref : repo.getRefDatabase().getRefs()) {
       ref = repo.getRefDatabase().peel(ref);
       if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) {
         return true;
@@ -149,9 +151,9 @@
     // tend to be much further back in history and just clutter up the priority queue in the common
     // case.
     return isReachableFrom(walk, commit, knownReachable)
-        || isReachableFromRefs(walk, commit, all.stream().filter(r -> refStartsWith(r, R_HEADS)))
-        || isReachableFromRefs(walk, commit, all.stream().filter(r -> refStartsWith(r, R_TAGS)))
-        || isReachableFromRefs(walk, commit, all.stream().filter(r -> otherRefs(r)));
+        || isReachableFromRefs(walk, commit, refDb.getRefsByPrefix(R_HEADS).stream())
+        || isReachableFromRefs(walk, commit, refDb.getRefsByPrefix(R_TAGS).stream())
+        || isReachableFromRefs(walk, commit, refDb.getRefs().stream().filter(r -> otherRefs(r)));
   }
 
   private static boolean refStartsWith(Ref ref, String prefix) {
diff --git a/java/com/google/gitiles/blame/BlameServlet.java b/java/com/google/gitiles/blame/BlameServlet.java
index 605ac6f..0bc061b 100644
--- a/java/com/google/gitiles/blame/BlameServlet.java
+++ b/java/com/google/gitiles/blame/BlameServlet.java
@@ -15,7 +15,6 @@
 package com.google.gitiles.blame;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
@@ -27,6 +26,8 @@
 import com.google.gitiles.DateFormatter;
 import com.google.gitiles.DateFormatter.Format;
 import com.google.gitiles.GitilesAccess;
+import com.google.gitiles.GitilesRequestFailureException;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gitiles.GitilesView;
 import com.google.gitiles.Renderer;
 import com.google.gitiles.ViewFilter;
@@ -74,7 +75,7 @@
 
     try (RevWalk rw = new RevWalk(repo)) {
       GitilesAccess access = getAccess(req);
-      RegionResult result = getRegions(view, access, repo, rw, res);
+      RegionResult result = getRegions(view, access, repo, rw);
       if (result == null) {
         return;
       }
@@ -116,7 +117,7 @@
     Repository repo = ServletUtils.getRepository(req);
 
     try (RevWalk rw = new RevWalk(repo)) {
-      RegionResult result = getRegions(view, getAccess(req), repo, rw, res);
+      RegionResult result = getRegions(view, getAccess(req), repo, rw);
       if (result == null) {
         return;
       }
@@ -154,13 +155,11 @@
   }
 
   private RegionResult getRegions(
-      GitilesView view, GitilesAccess access, Repository repo, RevWalk rw, HttpServletResponse res)
-      throws IOException {
+      GitilesView view, GitilesAccess access, Repository repo, RevWalk rw) throws IOException {
     RevCommit currCommit = rw.parseCommit(view.getRevision().getId());
     ObjectId currCommitBlobId = resolveBlob(view, rw, currCommit);
     if (currCommitBlobId == null) {
-      res.setStatus(SC_NOT_FOUND);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
     }
 
     ObjectId lastCommit = cache.findLastCommit(repo, currCommit, view.getPathPart());
@@ -182,8 +181,7 @@
 
     List<Region> regions = cache.get(repo, lastCommit, view.getPathPart());
     if (regions.isEmpty()) {
-      res.setStatus(SC_NOT_FOUND);
-      return null;
+      throw new GitilesRequestFailureException(FailureReason.BLAME_REGION_NOT_FOUND);
     }
     return new RegionResult(regions, lastCommitBlobId);
   }
diff --git a/java/com/google/gitiles/dev/BUILD b/java/com/google/gitiles/dev/BUILD
index 91355e8..47db87d 100644
--- a/java/com/google/gitiles/dev/BUILD
+++ b/java/com/google/gitiles/dev/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gitiles:servlet",
         "//lib:guava",
+        "//lib:guava-failureaccess",
         "//lib:html-types",
         "//lib:servlet-api_3_0",
         "//lib/jetty:server",
diff --git a/java/com/google/gitiles/doc/DocServlet.java b/java/com/google/gitiles/doc/DocServlet.java
index 24f8d4b..ca83ae0 100644
--- a/java/com/google/gitiles/doc/DocServlet.java
+++ b/java/com/google/gitiles/doc/DocServlet.java
@@ -14,8 +14,6 @@
 
 package com.google.gitiles.doc;
 
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.eclipse.jgit.lib.FileMode.TYPE_FILE;
@@ -30,6 +28,8 @@
 import com.google.common.net.HttpHeaders;
 import com.google.gitiles.BaseServlet;
 import com.google.gitiles.GitilesAccess;
+import com.google.gitiles.GitilesRequestFailureException;
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gitiles.GitilesView;
 import com.google.gitiles.Renderer;
 import com.google.gitiles.ViewFilter;
@@ -53,11 +53,8 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class DocServlet extends BaseServlet {
-  private static final Logger log = LoggerFactory.getLogger(DocServlet.class);
   private static final long serialVersionUID = 1L;
 
   private static final String INDEX_MD = "index.md";
@@ -86,8 +83,7 @@
   protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
     MarkdownConfig cfg = MarkdownConfig.get(getAccess(req).getConfig());
     if (!cfg.render) {
-      res.setStatus(SC_NOT_FOUND);
-      return;
+      throw new GitilesRequestFailureException(FailureReason.MARKDOWN_NOT_ENABLED);
     }
 
     GitilesView view = ViewFilter.getView(req);
@@ -99,14 +95,12 @@
       try {
         root = rw.parseTree(view.getRevision().getId());
       } catch (IncorrectObjectTypeException e) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.INCORRECT_OBJECT_TYPE);
       }
 
       MarkdownFile srcmd = findFile(rw, root, path);
       if (srcmd == null) {
-        res.setStatus(SC_NOT_FOUND);
-        return;
+        throw new GitilesRequestFailureException(FailureReason.OBJECT_NOT_FOUND);
       }
 
       MarkdownFile navmd = findNavbar(rw, root, path);
@@ -122,11 +116,8 @@
         if (navmd != null) {
           navmd.read(reader, cfg);
         }
-      } catch (LargeObjectException.ExceedsLimit errBig) {
-        fileTooBig(res, view, errBig);
-        return;
-      } catch (IOException err) {
-        readError(res, view, err);
+      } catch (LargeObjectException.ExceedsLimit e) {
+        fileTooBig(res, view);
         return;
       }
 
@@ -276,31 +267,11 @@
     return false;
   }
 
-  private static void fileTooBig(
-      HttpServletResponse res, GitilesView view, LargeObjectException.ExceedsLimit errBig)
-      throws IOException {
+  private static void fileTooBig(HttpServletResponse res, GitilesView view) throws IOException {
     if (view.getType() == GitilesView.Type.ROOTED_DOC) {
-      log.error(
-          String.format(
-              "markdown too large: %s/%s %s %s: %s",
-              view.getHostName(),
-              view.getRepositoryName(),
-              view.getRevision(),
-              view.getPathPart(),
-              errBig.getMessage()));
-      res.setStatus(SC_INTERNAL_SERVER_ERROR);
-    } else {
-      res.sendRedirect(GitilesView.show().copyFrom(view).toUrl());
+      throw new GitilesRequestFailureException(FailureReason.OBJECT_TOO_LARGE);
     }
-  }
-
-  private static void readError(HttpServletResponse res, GitilesView view, IOException err) {
-    log.error(
-        String.format(
-            "cannot load markdown %s/%s %s %s",
-            view.getHostName(), view.getRepositoryName(), view.getRevision(), view.getPathPart()),
-        err);
-    res.setStatus(SC_INTERNAL_SERVER_ERROR);
+    res.sendRedirect(GitilesView.show().copyFrom(view).toUrl());
   }
 
   private static class MarkdownFile {
diff --git a/java/com/google/gitiles/doc/MarkdownToHtml.java b/java/com/google/gitiles/doc/MarkdownToHtml.java
index 64758b2..e45503d 100644
--- a/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -19,11 +19,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.html.types.SafeHtml;
 import com.google.gitiles.GitilesView;
 import com.google.gitiles.ThreadSafePrettifyParser;
 import com.google.gitiles.doc.html.HtmlBuilder;
 import com.google.gitiles.doc.html.SoyHtmlBuilder;
-import com.google.template.soy.data.SanitizedContent;
 import java.util.List;
 import javax.annotation.Nullable;
 import org.commonmark.ext.gfm.strikethrough.Strikethrough;
@@ -168,7 +168,7 @@
   }
 
   /** Render the document AST to sanitized HTML. */
-  public SanitizedContent toSoyHtml(Node node) {
+  public SafeHtml toSoyHtml(Node node) {
     if (node != null) {
       SoyHtmlBuilder out = new SoyHtmlBuilder();
       renderToHtml(out, node);
diff --git a/java/com/google/gitiles/doc/html/SoyHtmlBuilder.java b/java/com/google/gitiles/doc/html/SoyHtmlBuilder.java
index 23e6ee6..a130ce4 100644
--- a/java/com/google/gitiles/doc/html/SoyHtmlBuilder.java
+++ b/java/com/google/gitiles/doc/html/SoyHtmlBuilder.java
@@ -14,9 +14,8 @@
 
 package com.google.gitiles.doc.html;
 
-import com.google.template.soy.data.SanitizedContent;
-import com.google.template.soy.data.SanitizedContent.ContentKind;
-import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import com.google.common.html.types.LegacyConversions;
+import com.google.common.html.types.SafeHtml;
 
 /** Builds a document fragment using a restricted subset of HTML. */
 public final class SoyHtmlBuilder extends HtmlBuilder {
@@ -32,8 +31,8 @@
   }
 
   /** Bless the current content as HTML. */
-  public SanitizedContent toSoy() {
+  public SafeHtml toSoy() {
     finish();
-    return UnsafeSanitizedContentOrdainer.ordainAsSafe(buf.toString(), ContentKind.HTML);
+    return LegacyConversions.riskilyAssumeSafeHtml(buf.toString());
   }
 }
diff --git a/javatests/com/google/gitiles/BUILD b/javatests/com/google/gitiles/BUILD
index 2cd737f..c67e565 100644
--- a/javatests/com/google/gitiles/BUILD
+++ b/javatests/com/google/gitiles/BUILD
@@ -4,6 +4,7 @@
 DEPS = [
     "//lib:gson",
     "//lib:guava",
+    "//lib:guava-failureaccess",
     "//lib/jgit:jgit",
     "//lib/jgit:jgit-servlet",
     "//lib/soy:soy",
diff --git a/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java b/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java
new file mode 100644
index 0000000..b96e43d
--- /dev/null
+++ b/javatests/com/google/gitiles/DefaultErrorHandlingFilterTest.java
@@ -0,0 +1,45 @@
+package com.google.gitiles;
+
+import static com.google.common.truth.Truth.assertThat;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+
+import com.google.gitiles.GitilesRequestFailureException.FailureReason;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.glue.MetaFilter;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class DefaultErrorHandlingFilterTest {
+  private final MetaFilter mf = new MetaFilter();
+
+  @Before
+  public void setUp() {
+    mf.serve("*").through(new DefaultErrorHandlingFilter()).with(new TestServlet());
+  }
+
+  @Test
+  public void renderError() throws Exception {
+    FakeHttpServletRequest req = FakeHttpServletRequest.newRequest();
+    req.setPathInfo("/");
+    FakeHttpServletResponse resp = new FakeHttpServletResponse();
+    mf.doFilter(req, resp, (unusedReq, unusedResp) -> {});
+
+    assertThat(resp.getStatus()).isEqualTo(SC_BAD_REQUEST);
+    assertThat(resp.getHeader(DefaultErrorHandlingFilter.GITILES_ERROR))
+        .isEqualTo("INCORECT_PARAMETER");
+  }
+
+  private static class TestServlet extends HttpServlet {
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse res) {
+      throw new GitilesRequestFailureException(FailureReason.INCORECT_PARAMETER);
+    }
+  }
+}
diff --git a/javatests/com/google/gitiles/MoreAssert.java b/javatests/com/google/gitiles/MoreAssert.java
new file mode 100644
index 0000000..3f79874
--- /dev/null
+++ b/javatests/com/google/gitiles/MoreAssert.java
@@ -0,0 +1,40 @@
+// Copyright 2019 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gitiles;
+
+/** Assertion methods for Gitiles. */
+public class MoreAssert {
+  private MoreAssert() {}
+
+  /** Simple version of assertThrows that will be introduced in JUnit 4.13. */
+  public static <T extends Throwable> T assertThrows(Class<T> expected, ThrowingRunnable r) {
+    try {
+      r.run();
+      throw new AssertionError("Expected " + expected.getSimpleName() + " to be thrown");
+    } catch (Throwable actual) {
+      if (expected.isAssignableFrom(actual.getClass())) {
+        @SuppressWarnings("unchecked")
+        T toReturn = (T) actual;
+        return toReturn;
+      }
+      throw new AssertionError(
+          "Expected " + expected.getSimpleName() + ", but got " + actual.getClass().getSimpleName(),
+          actual);
+    }
+  }
+
+  public interface ThrowingRunnable {
+    void run() throws Throwable;
+  }
+}
diff --git a/javatests/com/google/gitiles/ViewFilterTest.java b/javatests/com/google/gitiles/ViewFilterTest.java
index 2a8cb65..66cd5d8 100644
--- a/javatests/com/google/gitiles/ViewFilterTest.java
+++ b/javatests/com/google/gitiles/ViewFilterTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gitiles.MoreAssert.assertThrows;
 
 import com.google.common.net.HttpHeaders;
 import com.google.gitiles.GitilesView.Type;
@@ -45,8 +46,8 @@
   public void noCommand() throws Exception {
     assertThat(getView("/").getType()).isEqualTo(Type.HOST_INDEX);
     assertThat(getView("/repo").getType()).isEqualTo(Type.REPOSITORY_INDEX);
-    assertThat(getView("/repo/+")).isNull();
-    assertThat(getView("/repo/+/")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+/"));
   }
 
   @Test
@@ -136,8 +137,8 @@
   public void describe() throws Exception {
     GitilesView view;
 
-    assertThat(getView("/repo/+describe")).isNull();
-    assertThat(getView("/repo/+describe/")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+describe"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+describe/"));
 
     view = getView("/repo/+describe/deadbeef");
     assertThat(view.getType()).isEqualTo(Type.DESCRIBE);
@@ -184,7 +185,7 @@
     assertThat(view.getRevision().getId()).isEqualTo(stable);
     assertThat(view.getPathPart()).isNull();
 
-    assertThat(getView("/repo/+show/stable..master")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+show/stable..master"));
   }
 
   @Test
@@ -250,7 +251,8 @@
     assertThat(view.getRevision().getId()).isEqualTo(master);
     assertThat(view.getPathPart()).isEqualTo("foo/bar");
 
-    assertThat(getView("/repo/+show/stable..master/foo")).isNull();
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+show/stable..master/foo"));
   }
 
   @Test
@@ -279,7 +281,8 @@
     assertThat(view.getRevision().getId()).isEqualTo(master);
     assertThat(view.getPathPart()).isEqualTo("foo/bar.md");
 
-    assertThat(getView("/repo/+doc/stable..master/foo")).isNull();
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+doc/stable..master/foo"));
   }
 
   @Test
@@ -288,10 +291,11 @@
     assertThat(getView("//").getType()).isEqualTo(Type.HOST_INDEX);
     assertThat(getView("//repo").getType()).isEqualTo(Type.REPOSITORY_INDEX);
     assertThat(getView("//repo//").getType()).isEqualTo(Type.REPOSITORY_INDEX);
-    assertThat(getView("/repo/+//master")).isNull();
-    assertThat(getView("/repo/+/refs//heads//master")).isNull();
-    assertThat(getView("/repo/+//master//")).isNull();
-    assertThat(getView("/repo/+//master/foo//bar")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+//master"));
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+/refs//heads//master"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+//master//"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+//master/foo//bar"));
   }
 
   @Test
@@ -420,13 +424,16 @@
     repo.branch("refs/heads/branch").commit().create();
     GitilesView view;
 
-    assertThat(getView("/repo/+archive")).isNull();
-    assertThat(getView("/repo/+archive/")).isNull();
-    assertThat(getView("/repo/+archive/master..branch")).isNull();
-    assertThat(getView("/repo/+archive/master.foo")).isNull();
-    assertThat(getView("/repo/+archive/master.zip")).isNull();
-    assertThat(getView("/repo/+archive/master/.tar.gz")).isNull();
-    assertThat(getView("/repo/+archive/master/foo/.tar.gz")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+archive"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+archive/"));
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+archive/master..branch"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+archive/master.foo"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+archive/master.zip"));
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+archive/master/.tar.gz"));
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+archive/master/foo/.tar.gz"));
 
     view = getView("/repo/+archive/master.tar.gz");
     assertThat(view.getType()).isEqualTo(Type.ARCHIVE);
@@ -462,10 +469,11 @@
     repo.branch("refs/heads/branch").commit().create();
     GitilesView view;
 
-    assertThat(getView("/repo/+blame")).isNull();
-    assertThat(getView("/repo/+blame/")).isNull();
-    assertThat(getView("/repo/+blame/master")).isNull();
-    assertThat(getView("/repo/+blame/master..branch")).isNull();
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+blame"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+blame/"));
+    assertThrows(GitilesRequestFailureException.class, () -> getView("/repo/+blame/master"));
+    assertThrows(
+        GitilesRequestFailureException.class, () -> getView("/repo/+blame/master..branch"));
 
     view = getView("/repo/+blame/master/foo/bar");
     assertThat(view.getType()).isEqualTo(Type.BLAME);
diff --git a/javatests/com/google/gitiles/VisibilityCacheTest.java b/javatests/com/google/gitiles/VisibilityCacheTest.java
new file mode 100644
index 0000000..f22cf02
--- /dev/null
+++ b/javatests/com/google/gitiles/VisibilityCacheTest.java
@@ -0,0 +1,149 @@
+// Copyright 2019 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gitiles;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class VisibilityCacheTest {
+
+  private InMemoryRepository repo;
+  private GitilesAccess access = new FakeGitilesAccess();
+
+  private RevCommit baseCommit;
+  private RevCommit commit1;
+  private RevCommit commit2;
+  private RevCommit commitA;
+  private RevCommit commitB;
+  private RevCommit commitC;
+
+  private VisibilityCache visibilityCache;
+  private RevWalk walk;
+
+  @Before
+  public void setUp() throws Exception {
+    /**
+     *
+     *
+     * <pre>
+     *               commitC
+     *                 |
+     *   commit2     commitB
+     *      |          |
+     *   commit1     commitA <--- refs/tags/v0.1
+     *       \         /
+     *        \       /
+     *        baseCommit
+     * </pre>
+     */
+    repo = new InMemoryRepository(new DfsRepositoryDescription());
+    try (TestRepository<InMemoryRepository> git = new TestRepository<>(repo)) {
+      baseCommit = git.commit().message("baseCommit").create();
+      commit1 = git.commit().parent(baseCommit).message("commit1").create();
+      commit2 = git.commit().parent(commit1).message("commit2").create();
+
+      commitA = git.commit().parent(baseCommit).message("commitA").create();
+      commitB = git.commit().parent(commitA).message("commitB").create();
+      commitC = git.commit().parent(commitB).message("commitC").create();
+
+      git.update("master", commit2);
+      git.update("refs/tags/v0.1", commitA);
+    }
+
+    visibilityCache = new VisibilityCache(true);
+    walk = new RevWalk(repo);
+    walk.setRetainBody(false);
+  }
+
+  @After
+  public void tearDown() {
+    repo.close();
+  }
+
+  @Test
+  public void isTip() throws IOException {
+    ObjectId[] known = new ObjectId[0];
+    assertThat(visibilityCache.isVisible(repo, walk, access, commit2.getId(), known)).isTrue();
+  }
+
+  @Test
+  public void reachableFromHeads() throws Exception {
+    ObjectId[] known = new ObjectId[0];
+    assertThat(visibilityCache.isVisible(repo, walk, access, commit1.getId(), known)).isTrue();
+  }
+
+  @Test
+  public void reachableFromTags() throws Exception {
+    ObjectId[] known = new ObjectId[0];
+    assertThat(visibilityCache.isVisible(repo, walk, access, commitA.getId(), known)).isTrue();
+  }
+
+  @Test
+  public void unreachableFromAnyTip() throws Exception {
+    ObjectId[] known = new ObjectId[0];
+    assertThat(visibilityCache.isVisible(repo, walk, access, commitB.getId(), known)).isFalse();
+  }
+
+  @Test
+  public void reachableFromAnotherId() throws Exception {
+    ObjectId[] known = new ObjectId[] {commitC.getId()};
+    assertThat(visibilityCache.isVisible(repo, walk, access, commitB.getId(), known)).isTrue();
+  }
+
+  private static class FakeGitilesAccess implements GitilesAccess {
+    @Override
+    public Map<String, RepositoryDescription> listRepositories(String prefix, Set<String> branches)
+        throws ServiceNotEnabledException, ServiceNotAuthorizedException, IOException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Object getUserKey() {
+      return "Test";
+    }
+
+    @Override
+    public String getRepositoryName() {
+      return "VisibilityCache-Test";
+    }
+
+    @Override
+    public RepositoryDescription getRepositoryDescription() throws IOException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Config getConfig() throws IOException {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git a/lib/BUILD b/lib/BUILD
index 9059da0..e1f4654 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -21,5 +21,6 @@
     "servlet-api_3_0",
     "gson",
     "guava",
+    "guava-failureaccess",
     "prettify",
 ]]
diff --git a/resources/com/google/gitiles/templates/BlameDetail.soy b/resources/com/google/gitiles/templates/BlameDetail.soy
index e25b4fd..fa68ac7 100644
--- a/resources/com/google/gitiles/templates/BlameDetail.soy
+++ b/resources/com/google/gitiles/templates/BlameDetail.soy
@@ -34,9 +34,10 @@
         diffUrl: URL for a diff of this file at this commit.
         class: class name for tr. All keys but "class" are optional.
       */
+  {@inject staticUrls: ?}
 {if $regions}
   {call .header data="all"}
-    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
     {param containerClass: 'Container--fullWidth' /}
   {/call}
 
diff --git a/resources/com/google/gitiles/templates/Common.soy b/resources/com/google/gitiles/templates/Common.soy
index 67d5b0b..82c77ba 100644
--- a/resources/com/google/gitiles/templates/Common.soy
+++ b/resources/com/google/gitiles/templates/Common.soy
@@ -24,8 +24,9 @@
       */
   {@param? customVariant: ?}  /** variant name for custom styling. */
   {@param breadcrumbs: ?}  /** navigation breadcrumbs for this page. */
-  {@param? css: ?}  /** optional list of CSS URLs to include. */
+  {@param? css: list<?>}  /** optional list of CSS URLs to include. */
   {@param? containerClass: ?}  /** optional class to append to the main container. */
+  {@inject staticUrls: ?}
 <!DOCTYPE html>
 <html lang="en">
 <head>
@@ -38,10 +39,10 @@
     {sp}- {msg desc="name of the application"}{gitiles.SITE_TITLE}{/msg}
   </title>
 
-  <link rel="stylesheet" type="text/css" href="{gitiles.BASE_CSS_URL |blessStringAsTrustedResourceUrlForLegacy}">
+  <link rel="stylesheet" type="text/css" href="{$staticUrls.BASE_CSS_URL}">
   {if $css and length($css)}
     {for $url in $css}
-      <link rel="stylesheet" type="text/css" href="{$url |blessStringAsTrustedResourceUrlForLegacy}">
+      <link rel="stylesheet" type="text/css" href="{$url}">
     {/for}
   {/if}
   {delcall gitiles.customHeadTagPart variant="$customVariant ?: ''" /}
diff --git a/resources/com/google/gitiles/templates/Doc.soy b/resources/com/google/gitiles/templates/Doc.soy
index e7eb0d9..db1bfc6 100644
--- a/resources/com/google/gitiles/templates/Doc.soy
+++ b/resources/com/google/gitiles/templates/Doc.soy
@@ -44,6 +44,7 @@
   {@param? analyticsId: ?}  /** Google Analytics Property ID. */
   {@param? navbarHtml: ?}  /** navar.md converted to SafeHtml. */
   {@param? customVariant: ?}  /** variant name for custom styling. */
+  {@inject staticUrls: ?}
 <!DOCTYPE html>
 <html lang="en">
 <head>
@@ -52,9 +53,9 @@
     {if $siteTitle}{$siteTitle} -{sp}{/if}
     {$pageTitle}
   </title>
-  <link rel="stylesheet" type="text/css" href="{gitiles.BASE_CSS_URL |blessStringAsTrustedResourceUrlForLegacy}" />
-  <link rel="stylesheet" type="text/css" href="{gitiles.DOC_CSS_URL |blessStringAsTrustedResourceUrlForLegacy}" />
-  <link rel="stylesheet" type="text/css" href="{gitiles.PRETTIFY_CSS_URL |blessStringAsTrustedResourceUrlForLegacy}" />
+  <link rel="stylesheet" type="text/css" href="{$staticUrls.BASE_CSS_URL}" />
+  <link rel="stylesheet" type="text/css" href="{$staticUrls.DOC_CSS_URL}" />
+  <link rel="stylesheet" type="text/css" href="{$staticUrls.PRETTIFY_CSS_URL}" />
   {delcall gitiles.customHeadTagPart variant="$customVariant ?: ''" /}
 </head>
 <body class="Site">
diff --git a/resources/com/google/gitiles/templates/PathDetail.soy b/resources/com/google/gitiles/templates/PathDetail.soy
index 67bad2e..7dd283b 100644
--- a/resources/com/google/gitiles/templates/PathDetail.soy
+++ b/resources/com/google/gitiles/templates/PathDetail.soy
@@ -26,13 +26,14 @@
       org.eclipse.jgit.lib.FileMode. */
   {@param data: ?}  /** path data, matching the params for one of .treeDetail, .blobDetail,
       .symlinkDetail, or .gitlinkDetail as appropriate. */
+  {@inject staticUrls: ?}
 {if $type == 'REGULAR_FILE' or $type == 'EXECUTABLE_FILE'}
   {call .header data="all"}
-    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {elseif $data.readmeHtml}
   {call .header data="all"}
-    {param css: [gitiles.DOC_CSS_URL, gitiles.PRETTIFY_CSS_URL] /}
+    {param css: [$staticUrls.DOC_CSS_URL, $staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {else}
   {call .header data="all" /}
diff --git a/resources/com/google/gitiles/templates/RepositoryIndex.soy b/resources/com/google/gitiles/templates/RepositoryIndex.soy
index 3d617d7..0a27d47 100644
--- a/resources/com/google/gitiles/templates/RepositoryIndex.soy
+++ b/resources/com/google/gitiles/templates/RepositoryIndex.soy
@@ -30,6 +30,7 @@
   {@param? moreTagsUrl: ?}  /** URL to show more branches, if necessary. */
   {@param hasLog: ?}  /** whether a log should be shown for HEAD. */
   {@param? readmeHtml: ?}  /** optional rendered README.md contents. */
+  {@inject staticUrls: ?}
 {if $readmeHtml}
   {call .header data="all"}
     {param title: $repositoryName /}
@@ -37,7 +38,7 @@
     {param menuEntries: $menuEntries /}
     {param customVariant: $customVariant /}
     {param breadcrumbs: $breadcrumbs /}
-    {param css: [gitiles.DOC_CSS_URL] /}
+    {param css: [$staticUrls.DOC_CSS_URL] /}
   {/call}
 {else}
   {call .header}
diff --git a/resources/com/google/gitiles/templates/RevisionDetail.soy b/resources/com/google/gitiles/templates/RevisionDetail.soy
index 353432c..06fa996 100644
--- a/resources/com/google/gitiles/templates/RevisionDetail.soy
+++ b/resources/com/google/gitiles/templates/RevisionDetail.soy
@@ -28,13 +28,14 @@
       "type" key with one of the org.eclipse.jgit.lib.Contants.TYPE_* constant strings, and a "data"
       key with an object whose keys correspond to the appropriate object detail template from
       ObjectDetail.soy. */
+  {@inject staticUrls: ?}
 {if $hasBlob}
   {call .header data="all"}
-    {param css: [gitiles.PRETTIFY_CSS_URL] /}
+    {param css: [$staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {elseif $hasReadme}
   {call .header data="all"}
-    {param css: [gitiles.DOC_CSS_URL, gitiles.PRETTIFY_CSS_URL] /}
+    {param css: [$staticUrls.DOC_CSS_URL, $staticUrls.PRETTIFY_CSS_URL] /}
   {/call}
 {else}
   {call .header data="all" /}
diff --git a/version.bzl b/version.bzl
index 8f19d12..d361e3e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -4,4 +4,4 @@
 # we currently have no stable releases, we use the "build number" scheme
 # described at:
 # https://www.mojohaus.org/versions-maven-plugin/version-rules.html
-GITILES_VERSION = "0.2-7.1"
+GITILES_VERSION = "0.2-11"