This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 60db87d07 UNOMI-1007: Align health check JSON with the 3.0 response 
format (#937)
60db87d07 is described below

commit 60db87d075dcbd60fe15af8836529319d300604d
Author: Serge Huber <[email protected]>
AuthorDate: Sun Nov 1 07:12:22 2026 +0100

    UNOMI-1007: Align health check JSON with the 3.0 response format (#937)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 extensions/healthcheck/README.md                   | 24 +++++--
 .../unomi/healthcheck/HealthCheckService.java      |  7 +-
 .../healthcheck/servlet/HealthCheckServlet.java    | 17 +++--
 .../HealthCheckResponseJsonFormatTest.java         | 81 ++++++++++++++++++++++
 .../unomi/healthcheck/HealthCheckServiceTest.java  | 30 ++++++++
 .../org/apache/unomi/itests/HealthCheckIT.java     | 47 ++++++++++---
 manual/src/main/asciidoc/configuration.adoc        | 24 +++++--
 7 files changed, 200 insertions(+), 30 deletions(-)

diff --git a/extensions/healthcheck/README.md b/extensions/healthcheck/README.md
index defee637c..fe6c3a68d 100644
--- a/extensions/healthcheck/README.md
+++ b/extensions/healthcheck/README.md
@@ -42,15 +42,12 @@ All healthcheck can have a status :
 
 Any subsystem health check uses the configured timeout (default 400 ms); a 
check that does not answer in time is cancelled and returned as an error.
 
-Typical response to /health/check when unomi NOT started is :
+Typical response to `/health/check` when Unomi is not started yet 
(Elasticsearch distribution).
+Items are sorted by `name` (same as Unomi 3.0). Empty `data` is omitted; 
search backends may
+add cluster details when the probe answers:
 
 ```json
 [
-  {
-    "name":"karaf",
-    "status":"LIVE",
-    "collectingTime":0
-  },
   {
     "name":"cluster",
     "status":"DOWN",
@@ -59,7 +56,17 @@ Typical response to /health/check when unomi NOT started is :
   {
     "name":"elasticsearch",
     "status":"LIVE",
-    "collectingTime":6
+    "collectingTime":6,
+    "data":{
+      "cluster_name":"elasticsearch",
+      "status":"green",
+      "number_of_nodes":1
+    }
+  },
+  {
+    "name":"karaf",
+    "status":"LIVE",
+    "collectingTime":0
   },
   {
     "name":"persistence",
@@ -74,6 +81,9 @@ Typical response to /health/check when unomi NOT started is :
 ]
 ```
 
+Each object has `name`, `status` (`DOWN` | `UP` | `LIVE` | `ERROR`), 
`collectingTime` (ms), and
+optional `data`. HTTP 200 only when every status is `LIVE`; otherwise 206.
+
 ## Configuration
 
 Configuration is located in the file `etc/org.apache.unomi.healthcheck.cfg` 
(installed by the Elasticsearch or OpenSearch healthcheck feature).
diff --git 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
index f3f7d25e9..43c0a5b37 100644
--- 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
+++ 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
@@ -32,6 +32,7 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -210,7 +211,11 @@ public class HealthCheckService {
                     return;
                 } catch (Exception e) {
                     LOGGER.error("Error while executing health check {}", 
provider.name(), e);
-                    health.add(provider.timeout());
+                    // Match provider-level errors (and 3.0's error field): 
status ERROR with
+                    // data.error. Do not report these as timeouts.
+                    Throwable cause = e instanceof ExecutionException && 
e.getCause() != null ? e.getCause() : e;
+                    String message = cause.getMessage() != null ? 
cause.getMessage() : cause.getClass().getName();
+                    
health.add(HealthCheckResponse.named(provider.name()).error().withData("error", 
message).build());
                 }
             }
             health.sort(Comparator.comparing(HealthCheckResponse::getName));
diff --git 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
index e1f5f3755..eb63f0634 100644
--- 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
+++ 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
@@ -32,6 +32,8 @@ import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 
 /**
@@ -74,15 +76,18 @@ public class HealthCheckServlet extends HttpServlet {
             response.sendError(HttpServletResponse.SC_FORBIDDEN);
             return;
         }
-        List<HealthCheckResponse> checks = service.check();
+        // Sort a mutable copy (3.0 sorted in the servlet). The service cache 
is an
+        // unmodifiable list, so sorting it in place would fail.
+        List<HealthCheckResponse> checks = new ArrayList<>(service.check());
+        checks.sort(Comparator.comparing(HealthCheckResponse::getName));
         response.getWriter().println(mapper.writeValueAsString(checks));
         response.setContentType("application/json");
         response.setHeader("Cache-Control", "no-cache");
-            if (checks.stream().allMatch(HealthCheckResponse::isLive)) {
-                response.setStatus(HttpServletResponse.SC_OK);
-            } else {
-                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
-            }
+        if (checks.stream().allMatch(HealthCheckResponse::isLive)) {
+            response.setStatus(HttpServletResponse.SC_OK);
+        } else {
+            response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+        }
         response.flushBuffer();
     }
 }
diff --git 
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
new file mode 100644
index 000000000..5f8cf885e
--- /dev/null
+++ 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.unomi.healthcheck;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Locks the JSON wire format of {@link HealthCheckResponse} to the 3.0 shape:
+ * {@code name}, {@code status} (enum name), {@code collectingTime}, and 
optional {@code data}
+ * (omitted when empty).
+ */
+class HealthCheckResponseJsonFormatTest {
+
+    private ObjectMapper mapper;
+
+    @BeforeEach
+    void setUp() {
+        mapper = new ObjectMapper();
+        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+    }
+
+    @Test
+    void liveWithoutDataOmitsDataField() throws Exception {
+        JsonNode node = 
mapper.readTree(mapper.writeValueAsString(HealthCheckResponse.live("karaf")));
+        assertEquals("karaf", node.get("name").asText());
+        assertEquals("LIVE", node.get("status").asText());
+        assertTrue(node.get("collectingTime").isNumber());
+        assertFalse(node.has("data"));
+        assertEquals(3, node.size());
+    }
+
+    @Test
+    void timeoutUsesErrorCauseLike30() throws Exception {
+        HealthCheckProvider provider = new HealthCheckProvider() {
+            @Override
+            public String name() {
+                return "persistence";
+            }
+
+            @Override
+            public HealthCheckResponse execute() {
+                return HealthCheckResponse.live(name());
+            }
+        };
+        JsonNode node = 
mapper.readTree(mapper.writeValueAsString(provider.timeout()));
+        assertEquals("persistence", node.get("name").asText());
+        assertEquals("ERROR", node.get("status").asText());
+        assertEquals("timeout", node.get("data").get("error.cause").asText());
+        assertFalse(node.get("data").has("error"));
+    }
+
+    @Test
+    void errorUsesErrorFieldLikeProviderFailures() throws Exception {
+        JsonNode node = mapper.readTree(mapper.writeValueAsString(
+                
HealthCheckResponse.named("elasticsearch").error().withData("error", 
"connection refused").build()));
+        assertEquals("ERROR", node.get("status").asText());
+        assertEquals("connection refused", 
node.get("data").get("error").asText());
+    }
+}
diff --git 
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
index 0fe4e0bb3..f3fe66610 100644
--- 
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
+++ 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
@@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Supplier;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -99,6 +100,35 @@ class HealthCheckServiceTest {
         assertTrue(response(health, "postgresql").isLive());
     }
 
+    @Test
+    void throwingProviderIsReportedAsErrorWithItsMessage() {
+        service.bind(new TestProvider("fast", () -> 
HealthCheckResponse.live("fast")));
+        service.bind(new TestProvider("blocked", () -> {
+            throw new IllegalStateException("boom");
+        }));
+
+        List<HealthCheckResponse> health = service.check();
+
+        HealthCheckResponse blocked = response(health, "blocked");
+        assertTrue(blocked.isError());
+        assertEquals("boom", blocked.getData().get("error"));
+        assertFalse(blocked.getData().containsKey("error.cause"));
+    }
+
+    @Test
+    void resultsAreSortedByName() throws Exception {
+        HealthCheckConfig config = new HealthCheckConfig();
+        config.setEnabledProviders(List.of("postgresql", "elasticsearch"));
+        config.setTimeout(TIMEOUT);
+        service.setConfig(config);
+        service.bind(new TestProvider("postgresql", () -> 
HealthCheckResponse.live("postgresql")));
+        service.bind(new TestProvider("elasticsearch", () -> 
HealthCheckResponse.live("elasticsearch")));
+
+        List<String> names = 
service.check().stream().map(HealthCheckResponse::getName).toList();
+
+        assertEquals(List.of("elasticsearch", "karaf", "postgresql"), names);
+    }
+
     @Test
     void secondCallWithinOneSecondReturnsTheCachedResults() {
         TestProvider fast = new TestProvider("fast", () -> 
HealthCheckResponse.live("fast"));
diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java 
b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
index 6fcf81798..413d711a6 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -65,7 +65,8 @@ public class HealthCheckIT extends BaseIT {
     @Test
     public void testHealthCheck() {
         try {
-            List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new 
TypeReference<>() {});
+            // When every probe is LIVE, clients (and HA proxies) expect HTTP 
200 — same as Unomi 3.0.
+            List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new 
TypeReference<>() {}, 200);
             LOGGER.info("configured persistence provider: {}", 
getPersistenceBackend().providerId());
             LOGGER.info("health check response: {}", response);
             Assert.assertNotNull(response);
@@ -87,11 +88,14 @@ public class HealthCheckIT extends BaseIT {
             List<Future<List<HealthCheckResponse>>> futures = new 
ArrayList<>();
             for (int i = 0; i < NB_ITERATIONS; i++) {
                 for (int j = 0; j < NB_THREADS; j++) {
-                    Future<List<HealthCheckResponse>> future = 
executorService.submit(() -> get(HEALTHCHECK_ENDPOINT, new TypeReference<>() 
{}));
+                    // UNOMI-924: concurrent calls must not fail with HTTP 
500; accept 200 or 206.
+                    Future<List<HealthCheckResponse>> future = 
executorService.submit(
+                            () -> get(HEALTHCHECK_ENDPOINT, new 
TypeReference<>() {}, 200, 206));
                     futures.add(future);
                 }
                 for (Future<List<HealthCheckResponse>> future : futures) {
                     List<HealthCheckResponse> health = future.get(10, 
TimeUnit.SECONDS);
+                    Assert.assertNotNull("Concurrent health check returned 
null (unexpected HTTP status)", health);
                     assertHealthCheckLive(health);
                 }
                 Thread.sleep(10);
@@ -110,6 +114,7 @@ public class HealthCheckIT extends BaseIT {
 
     private void assertHealthCheckLive(List<HealthCheckResponse> response) {
         Assert.assertNotNull(response);
+        assertResponseFormatLike30(response);
         Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("karaf") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
         Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("unomi") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
         Assert.assertTrue(response.stream().anyMatch(r -> 
r.getName().equals("persistence") && r.getStatus() == 
HealthCheckResponse.Status.LIVE));
@@ -136,6 +141,24 @@ public class HealthCheckIT extends BaseIT {
         }
     }
 
+    /**
+     * Wire format shared with Unomi 3.0: each item has {@code name}, {@code 
status},
+     * {@code collectingTime}; optional {@code data}; items sorted by name.
+     */
+    private static void assertResponseFormatLike30(List<HealthCheckResponse> 
response) {
+        List<String> names = new ArrayList<>();
+        for (HealthCheckResponse item : response) {
+            Assert.assertNotNull("Health check item missing name: " + item, 
item.getName());
+            Assert.assertFalse("Health check name must not be blank", 
item.getName().isBlank());
+            Assert.assertNotNull("Health check item missing status: " + item, 
item.getStatus());
+            Assert.assertTrue("collectingTime must be >= 0: " + item, 
item.getCollectingTime() >= 0);
+            names.add(item.getName());
+        }
+        List<String> sorted = new ArrayList<>(names);
+        sorted.sort(String::compareTo);
+        Assert.assertEquals("Health check items must be sorted by name (Unomi 
3.0 servlet behaviour)", sorted, names);
+    }
+
     @Test
     public void testHealthCheck_wrongPasswordRejected() throws Exception {
         final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT));
@@ -165,7 +188,7 @@ public class HealthCheckIT extends BaseIT {
         try {
             updateHealthCheckConfiguration(original, 
Map.of("healthcheck.providers", "unomi,persistence,notInstalled"));
             keepTrying("Health check should only report karaf and the 
configured providers",
-                    () -> probeNames(get(HEALTHCHECK_ENDPOINT, new 
TypeReference<List<HealthCheckResponse>>() {})),
+                    () -> probeNames(get(HEALTHCHECK_ENDPOINT, new 
TypeReference<List<HealthCheckResponse>>() {}, 200, 206)),
                     names -> Set.of("karaf", "unomi", 
"persistence").equals(names),
                     DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
             Assert.assertEquals(401, healthCheckStatus(AuthType.NONE, null));
@@ -199,7 +222,7 @@ public class HealthCheckIT extends BaseIT {
     private void restoreHealthCheckConfiguration(Dictionary<String, Object> 
original) throws IOException, InterruptedException {
         configurationAdmin.getConfiguration(HEALTHCHECK_PID, 
null).update(original);
         keepTrying("Health check should be back to its original configuration",
-                () -> get(HEALTHCHECK_ENDPOINT, new 
TypeReference<List<HealthCheckResponse>>() {}),
+                () -> get(HEALTHCHECK_ENDPOINT, new 
TypeReference<List<HealthCheckResponse>>() {}, 200),
                 response -> {
                     try {
                         assertHealthCheckLive(response);
@@ -228,16 +251,22 @@ public class HealthCheckIT extends BaseIT {
         return response == null ? null : 
response.stream().map(HealthCheckResponse::getName).collect(Collectors.toSet());
     }
 
-    protected <T> T get(final String url, TypeReference<T> typeReference) {
+    protected <T> T get(final String url, TypeReference<T> typeReference, 
int... acceptedStatuses) {
+        int[] statuses = (acceptedStatuses == null || acceptedStatuses.length 
== 0)
+                ? new int[] { 200, 206 }
+                : acceptedStatuses;
         CloseableHttpResponse response = null;
         try {
             final HttpGet httpGet = new HttpGet(getFullUrl(url));
             response = executeHttpRequest(httpGet, AuthType.CUSTOM_BASIC, 
HEALTHCHECK_AUTH_USER_NAME, HEALTHCHECK_AUTH_PASSWORD);
-            if (response.getStatusLine().getStatusCode() == 200 || 
response.getStatusLine().getStatusCode() == 206) {
-                return 
getObjectMapper().readValue(response.getEntity().getContent(), typeReference);
-            } else {
-                return null;
+            int code = response.getStatusLine().getStatusCode();
+            for (int accepted : statuses) {
+                if (code == accepted) {
+                    return 
getObjectMapper().readValue(response.getEntity().getContent(), typeReference);
+                }
             }
+            LOGGER.error("Unexpected health check HTTP status {} (accepted 
{})", code, java.util.Arrays.toString(statuses));
+            return null;
         } catch (Exception e) {
             LOGGER.error("Error performing GET request with url {}", url, e);
         } finally {
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index a5803449e..778f71627 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -1997,16 +1997,13 @@ All healthcheck can have a status :
 
 Any subsystem health check uses the configured timeout (default 400 ms); a 
check that does not answer in time is cancelled and returned as an error.
 
-Typical response to /health/check when unomi NOT started is :
+Typical response to `/health/check` when Unomi is not started yet 
(Elasticsearch distribution).
+Items are sorted by `name` (same as Unomi 3.0). Empty `data` is omitted; 
search backends may
+add cluster details when the probe answers:
 
 [source,json]
 ----
 [
-  {
-    "name":"karaf",
-    "status":"LIVE",
-    "collectingTime":0
-  },
   {
     "name":"cluster",
     "status":"DOWN",
@@ -2015,7 +2012,17 @@ Typical response to /health/check when unomi NOT started 
is :
   {
     "name":"elasticsearch",
     "status":"LIVE",
-    "collectingTime":6
+    "collectingTime":6,
+    "data":{
+      "cluster_name":"elasticsearch",
+      "status":"green",
+      "number_of_nodes":1
+    }
+  },
+  {
+    "name":"karaf",
+    "status":"LIVE",
+    "collectingTime":0
   },
   {
     "name":"persistence",
@@ -2030,6 +2037,9 @@ Typical response to /health/check when unomi NOT started 
is :
 ]
 ----
 
+Each object has `name`, `status` (`DOWN` | `UP` | `LIVE` | `ERROR`), 
`collectingTime` (milliseconds), and
+optional `data`. HTTP 200 only when every status is `LIVE`; otherwise 206.
+
 Existing health check can be extended by adding specific provider in the 
extension. A provider is a class that implements the HealthCheckProvider 
interface.
 
 [source,java]

Reply via email to