Throw HealthCheckFailedException upon failures or timeouts

Throw a proper exception whenever a health check fails or does not
complete within the expected time limit.

Previously, the check was returning a 500 status code with a JSON
payload, which Gerrit Response API tolerated; however, since the
introduction of Ib1a259f9c05, the response cannot be a 500.

All APIs are expected to throw a proper exception to notify that the
execution has failed. To keep the response JSON payload, the healthcheck
plugin needs to register an exception hook that recognises its
HealthCheckFailedException and properly format the JSON
payload.

Bug: Issue 469458273
Change-Id: Ic6aca61070457b79c52579284a7931bb6d2963b5
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckExceptionHook.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckExceptionHook.java
new file mode 100644
index 0000000..81a3dfc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckExceptionHook.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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
+//
+// http://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.googlesource.gerrit.plugins.healthcheck;
+
+import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
+import static com.googlesource.gerrit.plugins.healthcheck.filter.HealthCheckStatusFilter.GSON;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.ExceptionHook;
+import com.google.inject.AbstractModule;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+public class HealthCheckExceptionHook implements ExceptionHook {
+
+  @Override
+  public Optional<Status> getStatus(Throwable throwable) {
+    if (throwable instanceof HealthCheckFailedException exc) {
+      return Optional.of(
+          Status.create(
+              SC_INTERNAL_SERVER_ERROR,
+              new String(JSON_MAGIC, StandardCharsets.UTF_8) + GSON.toJson(exc.getResult())));
+    }
+    return Optional.empty();
+  }
+
+  static AbstractModule module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), ExceptionHook.class).to(HealthCheckExceptionHook.class);
+      }
+    };
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckFailedException.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckFailedException.java
new file mode 100644
index 0000000..ee917ea
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckFailedException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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
+//
+// http://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.googlesource.gerrit.plugins.healthcheck;
+
+
+import java.util.Map;
+
+public class HealthCheckFailedException extends Exception {
+  private final Map<String, Object> result;
+
+  public HealthCheckFailedException(Map<String, Object> result) {
+    this.result = result;
+  }
+
+  Map<String, Object> getResult() {
+    return result;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/Module.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/Module.java
index 54cd5a0..d13108a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/Module.java
@@ -20,6 +20,7 @@
 
   @Override
   protected void configure() {
+    install(HealthCheckExceptionHook.module());
     install(new HealthCheckSubsystemsModule());
     install(new HealthCheckApiModule());
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/api/HealthCheckStatusEndpoint.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/api/HealthCheckStatusEndpoint.java
index 58ab01b..fe73cc5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/api/HealthCheckStatusEndpoint.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/api/HealthCheckStatusEndpoint.java
@@ -19,6 +19,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.healthcheck.HealthCheckConfig;
+import com.googlesource.gerrit.plugins.healthcheck.HealthCheckFailedException;
 import com.googlesource.gerrit.plugins.healthcheck.check.GlobalHealthCheck;
 import com.googlesource.gerrit.plugins.healthcheck.check.HealthCheck;
 import com.googlesource.gerrit.plugins.healthcheck.check.HealthCheck.Result;
@@ -45,21 +46,23 @@
   public Response<Map<String, Object>> apply(ConfigResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
     if (failFlagFileExists()) {
-      return Response.withStatusCode(
-          HttpServletResponse.SC_INTERNAL_SERVER_ERROR, Map.of("reason", "Fail Flag File exists"));
+      throw new HealthCheckFailedException(Map.of("reason", "Fail Flag File exists"));
     }
     HealthCheck.StatusSummary globalHealthCheckStatus = healthChecks.run();
 
     Map<String, Object> result = globalHealthCheckStatus.subChecks();
     result.put("ts", globalHealthCheckStatus.ts());
     result.put("elapsed", globalHealthCheckStatus.elapsed());
-    return Response.withStatusCode(getHTTPResultCode(globalHealthCheckStatus), result);
+    return Response.withStatusCode(getHTTPResultCode(globalHealthCheckStatus, result), result);
   }
 
-  private int getHTTPResultCode(HealthCheck.StatusSummary checkStatus) {
-    return checkStatus.result() == Result.FAILED
-        ? HttpServletResponse.SC_INTERNAL_SERVER_ERROR
-        : HttpServletResponse.SC_OK;
+  private int getHTTPResultCode(HealthCheck.StatusSummary checkStatus, Map<String, Object> result)
+      throws HealthCheckFailedException {
+    if (checkStatus.result() == Result.FAILED) {
+      throw new HealthCheckFailedException(result);
+    }
+
+    return HttpServletResponse.SC_OK;
   }
 
   private boolean failFlagFileExists() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilter.java b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilter.java
index 1b52d4c..994da24 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilter.java
@@ -20,16 +20,19 @@
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.healthcheck.HealthCheckExceptionHook;
 import com.googlesource.gerrit.plugins.healthcheck.api.HealthCheckStatusEndpoint;
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
@@ -39,22 +42,25 @@
 import org.eclipse.jgit.lib.Config;
 
 public class HealthCheckStatusFilter extends AllRequestFilter {
+  public static final Gson GSON = OutputFormat.JSON.newGsonBuilder().create();
+
   private final HealthCheckStatusEndpoint statusEndpoint;
-  private final Gson gson;
   private final String pluginName;
   private final Config cfg;
   private final String uriPattern;
+  private final HealthCheckExceptionHook exceptionHook;
 
   @Inject
   public HealthCheckStatusFilter(
       HealthCheckStatusEndpoint statusEndpoint,
       @PluginName String pluginName,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      HealthCheckExceptionHook exceptionHook) {
     this.statusEndpoint = statusEndpoint;
-    this.gson = OutputFormat.JSON.newGsonBuilder().create();
     this.pluginName = pluginName;
     this.cfg = cfg;
     this.uriPattern = getUriPattern();
+    this.exceptionHook = exceptionHook;
   }
 
   private static List<String> extractUriPrefixes(String[] listenUrls) {
@@ -120,10 +126,11 @@
     return httpServletRequest.getRequestURI().matches(uriPattern);
   }
 
-  private void doStatusCheck(HttpServletResponse httpResponse) throws ServletException {
+  private void doStatusCheck(HttpServletResponse httpResponse)
+      throws ServletException, IOException {
     try {
       Response<Map<String, Object>> healthStatus = statusEndpoint.apply(new ConfigResource());
-      String healthStatusJson = gson.toJson(healthStatus.value());
+      String healthStatusJson = GSON.toJson(healthStatus.value());
       if (healthStatus.statusCode() == HttpServletResponse.SC_OK) {
         PrintWriter writer = httpResponse.getWriter();
         writer.print(new String(RestApiServlet.JSON_MAGIC));
@@ -132,7 +139,12 @@
         httpResponse.sendError(healthStatus.statusCode(), healthStatusJson);
       }
     } catch (Exception e) {
-      throw new ServletException(e);
+      Optional<ExceptionHook.Status> status = exceptionHook.getStatus(e);
+      if (status.isPresent()) {
+        httpResponse.sendError(status.get().statusCode(), status.get().statusMessage());
+      } else {
+        throw new ServletException(e);
+      }
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckStatusEndpointTest.java b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckStatusEndpointTest.java
index af09d07..4185a21 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckStatusEndpointTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/HealthCheckStatusEndpointTest.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.healthcheck;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.googlesource.gerrit.plugins.healthcheck.HealthCheckConfig.DEFAULT_CONFIG;
 
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -102,7 +103,7 @@
   }
 
   @Test
-  public void shouldReturnServerErrorWhenOneChecksTimesOut() throws Exception {
+  public void shouldThrowHealthCheckFailedWhenOneChecksTimesOut() throws Exception {
     Injector injector =
         testInjector(
             new AbstractModule() {
@@ -124,13 +125,12 @@
 
     HealthCheckStatusEndpoint healthCheckApi =
         injector.getInstance(HealthCheckStatusEndpoint.class);
-    Response<?> resp = healthCheckApi.apply(null);
 
-    assertThat(resp.statusCode()).isEqualTo(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    assertThrows(HealthCheckFailedException.class, () -> healthCheckApi.apply(null));
   }
 
   @Test
-  public void shouldReturnServerErrorWhenAtLeastOneCheckIsFailing() throws Exception {
+  public void shouldThrowHealthCheckFailedWhenAtLeastOneCheckIsFailing() throws Exception {
     Injector injector =
         testInjector(
             new AbstractModule() {
@@ -166,9 +166,8 @@
 
     HealthCheckStatusEndpoint healthCheckApi =
         injector.getInstance(HealthCheckStatusEndpoint.class);
-    Response<?> resp = healthCheckApi.apply(null);
 
-    assertThat(resp.statusCode()).isEqualTo(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+    assertThrows(HealthCheckFailedException.class, () -> healthCheckApi.apply(null));
   }
 
   private Injector testInjector(AbstractModule testModule) {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilterTest.java b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilterTest.java
index 9d44f21..6dc606d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/healthcheck/filter/HealthCheckStatusFilterTest.java
@@ -58,7 +58,7 @@
   private HealthCheckStatusFilter createFilter(List<String> listenUrl) {
     Config cfg = new Config();
     cfg.setStringList("httpd", null, "listenUrl", listenUrl);
-    return new HealthCheckStatusFilter(null, "healthcheck", cfg);
+    return new HealthCheckStatusFilter(null, "healthcheck", cfg, null);
   }
 
   private HttpServletRequest createRequest(String path) {