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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 652fcd3d fix(metrics): bound Prometheus query responses (#1006)
652fcd3d is described below

commit 652fcd3df316e48624bf447840fdf131c89159bd
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 5 02:43:52 2026 -0700

    fix(metrics): bound Prometheus query responses (#1006)
---
 .../studio/cluster/metrics/MetricsController.java  |  1 +
 .../cluster/metrics/PrometheusMetricsSource.java   | 62 +++++++++++++++++-----
 .../metrics/PrometheusMetricsSourceTest.java       | 53 ++++++++++++++++++
 3 files changed, 102 insertions(+), 14 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
index 02b6d855..2c79b35b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsController.java
@@ -54,6 +54,7 @@ public class MetricsController {
                 useReturnTypeSchema = true),
         @ApiResponse(responseCode = "400", description = "Invalid request or 
PromQL expression"),
         @ApiResponse(responseCode = "422", description = "Prometheus could not 
execute the expression"),
+        @ApiResponse(responseCode = "413", description = "Prometheus response 
exceeds Studio query limits"),
         @ApiResponse(responseCode = "502", description = "Prometheus 
connection or response failure"),
         @ApiResponse(responseCode = "503", description = "Prometheus is 
unavailable or not configured"),
         @ApiResponse(responseCode = "504", description = "Prometheus query 
timed out")
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
index 913cd56c..921b7402 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
 import org.springframework.http.MediaType;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Component;
@@ -30,9 +31,10 @@ import org.springframework.util.StringUtils;
 import org.springframework.web.client.ResourceAccessException;
 import org.springframework.web.client.RestClient;
 import org.springframework.web.client.RestClientException;
-import org.springframework.web.client.RestClientResponseException;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.SocketTimeoutException;
 import java.net.URI;
 import java.util.Iterator;
@@ -46,6 +48,9 @@ import java.util.stream.StreamSupport;
 public class PrometheusMetricsSource implements MetricsSource {
 
     private static final String QUERY_RANGE_PATH = "/api/v1/query_range";
+    private static final int MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
+    private static final int MAX_SERIES = 1_000;
+    private static final int MAX_TOTAL_SAMPLES = 100_000;
 
     private final RestClient restClient;
     private final ObjectMapper objectMapper;
@@ -80,13 +85,16 @@ public class PrometheusMetricsSource implements 
MetricsSource {
                     .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                     .headers(this::applyAuthentication)
                     .body(form)
-                    .retrieve()
-                    .body(JsonNode.class);
+                    .exchange((request, clientResponse) -> {
+                        JsonNode body = 
objectMapper.readTree(readResponseBody(clientResponse.getBody()));
+                        if (clientResponse.getStatusCode().isError()) {
+                            throw responseBodyException(body, 
responseStatus(clientResponse.getStatusCode()));
+                        }
+                        return body;
+                    });
             return parseResponse(response);
         } catch (PrometheusException exception) {
             throw exception;
-        } catch (RestClientResponseException exception) {
-            throw responseException(exception);
         } catch (ResourceAccessException exception) {
             if (hasCause(exception, SocketTimeoutException.class)) {
                 throw new 
PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(),
@@ -174,6 +182,7 @@ public class PrometheusMetricsSource implements 
MetricsSource {
             throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(),
                     "Prometheus returned a malformed response");
         }
+        validateResponseLimits(result);
 
         List<MetricDataVO.MetricSeriesVO> series = 
StreamSupport.stream(result.spliterator(), false)
                 .map(this::parseSeries)
@@ -250,19 +259,44 @@ public class PrometheusMetricsSource implements 
MetricsSource {
                 .toList();
     }
 
-    private PrometheusException responseException(RestClientResponseException 
exception) {
-        JsonNode response = null;
-        try {
-            response = 
objectMapper.readTree(exception.getResponseBodyAsString());
-        } catch (IOException ignored) {
-            log.debug("Failed to parse Prometheus error response");
+    private byte[] readResponseBody(InputStream input) throws IOException {
+        try (InputStream response = input; ByteArrayOutputStream output = new 
ByteArrayOutputStream()) {
+            byte[] buffer = new byte[8 * 1024];
+            int read;
+            while ((read = response.read(buffer)) != -1) {
+                if (output.size() > MAX_RESPONSE_BYTES - read) {
+                    throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
+                            "Prometheus response exceeds 5 MiB; narrow the 
query");
+                }
+                output.write(buffer, 0, read);
+            }
+            return output.toByteArray();
+        }
+    }
+
+    private void validateResponseLimits(JsonNode result) {
+        if (result.size() > MAX_SERIES) {
+            throw new PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
+                    "Prometheus query returned too many series; narrow the 
query");
         }
-        int upstreamStatus = exception.getStatusCode().value();
-        int statusCode = switch (upstreamStatus) {
+        long totalSamples = 0;
+        for (JsonNode series : result) {
+            totalSamples += series.path("values").size();
+            totalSamples += series.path("histograms").size();
+            if (totalSamples > MAX_TOTAL_SAMPLES) {
+                throw new 
PrometheusException(HttpStatus.PAYLOAD_TOO_LARGE.value(),
+                        "Prometheus query returned too many samples; increase 
step or narrow the query");
+            }
+        }
+    }
+
+    private int responseStatus(HttpStatusCode statusCode) {
+        int upstreamStatus = statusCode.value();
+        int mappedStatus = switch (upstreamStatus) {
             case 400, 422, 503 -> upstreamStatus;
             default -> HttpStatus.BAD_GATEWAY.value();
         };
-        return responseBodyException(response, statusCode);
+        return mappedStatus;
     }
 
     private PrometheusException responseBodyException(JsonNode response, int 
statusCode) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java
index 32d0e502..ff0c0873 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java
@@ -31,6 +31,7 @@ import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.Base64;
+import java.util.Collections;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -228,6 +229,40 @@ class PrometheusMetricsSourceTest {
                 .hasMessage("Prometheus returned a malformed response");
     }
 
+    @Test
+    void queryShouldRejectResponseWithTooManySeries() {
+        server.createContext("/api/v1/query_range", exchange -> 
respond(exchange, 200, responseWithSeries(1_001)));
+
+        assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(query()))
+                .isInstanceOf(PrometheusException.class)
+                .satisfies(exception -> assertThat(((PrometheusException) 
exception).getStatusCode())
+                        .isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE.value()))
+                .hasMessage("Prometheus query returned too many series; narrow 
the query");
+    }
+
+    @Test
+    void queryShouldRejectResponseWithTooManySamples() {
+        server.createContext("/api/v1/query_range", exchange -> 
respond(exchange, 200, responseWithSamples(100_001)));
+
+        assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(query()))
+                .isInstanceOf(PrometheusException.class)
+                .satisfies(exception -> assertThat(((PrometheusException) 
exception).getStatusCode())
+                        .isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE.value()))
+                .hasMessage("Prometheus query returned too many samples; 
increase step or narrow the query");
+    }
+
+    @Test
+    void queryShouldRejectResponseLargerThanFiveMebibytes() {
+        server.createContext("/api/v1/query_range", exchange -> 
respond(exchange, 200,
+                "{\"status\":\"success\",\"padding\":\"" + "x".repeat(5 * 1024 
* 1024) + "\"}"));
+
+        assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(query()))
+                .isInstanceOf(PrometheusException.class)
+                .satisfies(exception -> assertThat(((PrometheusException) 
exception).getStatusCode())
+                        .isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE.value()))
+                .hasMessage("Prometheus response exceeds 5 MiB; narrow the 
query");
+    }
+
     @Test
     void queryShouldRejectEndEarlierThanStart() {
         MetricQueryDTO invalidQuery = MetricQueryDTO.builder()
@@ -382,4 +417,22 @@ class PrometheusMetricsSourceTest {
     private String successResponse() {
         return 
"{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[]}}";
     }
+
+    private String responseWithSeries(int count) {
+        return 
"{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":["
+                + String.join(",", Collections.nCopies(count, 
"{\"metric\":{},\"values\":[]}"))
+                + "]}}";
+    }
+
+    private String responseWithSamples(int count) {
+        StringBuilder values = new StringBuilder();
+        for (int index = 0; index < count; index++) {
+            if (index > 0) {
+                values.append(',');
+            }
+            values.append("[1784107658,\"1\"]");
+        }
+        return 
"{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[{\"metric\":{},\"values\":["
+                + values + "]}]}}";
+    }
 }

Reply via email to