diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt index 17fccaa45..146c5a471 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exporter-httpserver.txt @@ -1,2 +1,19 @@ Comparing source compatibility of prometheus-metrics-exporter-httpserver-1.8.1-SNAPSHOT.jar against prometheus-metrics-exporter-httpserver-1.8.0.jar -No changes. ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) STATIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder builder() ++++ NEW CLASS: PUBLIC(+) STATIC(+) FINAL(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy build() + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder errorReporter(java.util.function.Consumer) + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy$Builder unsafeDebugResponse(boolean) +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.HTTPServer (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 +*** MODIFIED CLASS: PUBLIC STATIC io.prometheus.metrics.exporter.httpserver.HTTPServer$Builder (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.exporter.httpserver.HTTPServer$Builder errorHandlingPolicy(io.prometheus.metrics.exporter.httpserver.HttpErrorHandlingPolicy) +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.exporter.httpserver.MetricsHandler (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + diff --git a/docs/content/exporters/httpserver.md b/docs/content/exporters/httpserver.md index c0db181c9..e5b24de8b 100644 --- a/docs/content/exporters/httpserver.md +++ b/docs/content/exporters/httpserver.md @@ -26,6 +26,41 @@ or [inetAddress()](). +## Scrape error handling + +By default, scrape failures return a generic HTTP 500 response. Exception details are not +included in the response or logged, because the server may run inside an application or a +Java agent with its own diagnostic pipeline. + +Configure a reporter to send exception details to an appropriate logging or telemetry sink: + +```java +HTTPServer server = HTTPServer.builder() + .port(9400) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.builder() + .errorReporter(error -> logger.log(Level.SEVERE, "Prometheus scrape failed", error)) + .build()) + .buildAndStart(); +``` + +The reporter runs synchronously on the request thread and may be called concurrently. Reporter +runtime exceptions do not prevent the generic HTTP 500 response from being sent. Rate limiting +or deduplication can be implemented in the reporter when needed. + +For local debugging, an unsafe response containing the full exception stack trace can be enabled +explicitly: + +```java +HttpErrorHandlingPolicy.builder() + .unsafeDebugResponse(true) + .build() +``` + +This setting is independent of the error reporter, so both can be configured when needed. The +unsafe debug response can disclose application internals and must not be enabled for an endpoint +reachable by untrusted clients. + ## Authentication and HTTPS - [authenticator()]() diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java index 5a80d8bdf..3ba88dcc1 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/ExporterIT.java @@ -157,7 +157,11 @@ void testErrorHandling() throws IOException { start("error"); Response response = scrape("GET", ""); assertThat(response.status).isEqualTo(500); - assertThat(response.stringBody()).contains("Simulating an error."); + assertErrorResponseBody(response.stringBody()); + } + + protected void assertErrorResponseBody(String body) { + assertThat(body).contains("Simulating an error."); } @Test diff --git a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java index 4c7e61472..6cc5bc817 100644 --- a/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java +++ b/integration-tests/it-exporter/it-exporter-test/src/test/java/io/prometheus/metrics/it/exporter/test/HttpServerIT.java @@ -1,5 +1,7 @@ package io.prometheus.metrics.it.exporter.test; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.IOException; import java.net.URISyntaxException; @@ -7,4 +9,13 @@ class HttpServerIT extends ExporterIT { public HttpServerIT() throws IOException, URISyntaxException { super("exporter-httpserver-sample"); } + + @Override + protected void assertErrorResponseBody(String body) { + assertThat(body) + .isEqualTo( + "An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n") + .doesNotContain("Simulating an error."); + } } diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java index e93b122b0..7759aac1b 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HTTPServer.java @@ -61,7 +61,8 @@ private HTTPServer( @Nullable String authenticatedSubjectAttributeName, @Nullable HttpHandler defaultHandler, @Nullable String metricsHandlerPath, - @Nullable Boolean registerHealthHandler) { + @Nullable Boolean registerHealthHandler, + HttpErrorHandlingPolicy errorHandlingPolicy) { if (httpServer.getAddress() == null) { throw new IllegalArgumentException("HttpServer hasn't been bound to an address"); } @@ -85,7 +86,7 @@ private HTTPServer( } registerHandler( metricsPath, - new MetricsHandler(config, registry), + new MetricsHandler(config, registry, errorHandlingPolicy), authenticator, authenticatedSubjectAttributeName); if (registerHealthHandler == null || registerHealthHandler) { @@ -211,6 +212,7 @@ public static class Builder { @Nullable private HttpHandler defaultHandler = null; @Nullable private String metricsHandlerPath = null; @Nullable private Boolean registerHealthHandler = null; + private HttpErrorHandlingPolicy errorHandlingPolicy = HttpErrorHandlingPolicy.builder().build(); private Builder(PrometheusProperties config) { this.config = config; @@ -295,6 +297,20 @@ public Builder registerHealthHandler(boolean registerHealthHandler) { return this; } + /** + * Configure how exceptions raised while scraping metrics are reported to the client and + * optionally to a caller-supplied diagnostic sink. + * + *

Default is {@code HttpErrorHandlingPolicy.builder().build()}. + */ + public Builder errorHandlingPolicy(HttpErrorHandlingPolicy errorHandlingPolicy) { + if (errorHandlingPolicy == null) { + throw new NullPointerException("errorHandlingPolicy"); + } + this.errorHandlingPolicy = errorHandlingPolicy; + return this; + } + /** Build and start the HTTPServer. */ public HTTPServer buildAndStart() throws IOException { if (registry == null) { @@ -318,7 +334,8 @@ public HTTPServer buildAndStart() throws IOException { authenticatedSubjectAttributeName, defaultHandler, metricsHandlerPath, - registerHealthHandler); + registerHealthHandler, + errorHandlingPolicy); } private InetSocketAddress makeInetSocketAddress() { diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java new file mode 100644 index 000000000..cad813438 --- /dev/null +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpErrorHandlingPolicy.java @@ -0,0 +1,103 @@ +package io.prometheus.metrics.exporter.httpserver; + +import io.prometheus.metrics.annotations.StableApi; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** + * Controls how the {@link HTTPServer} handles exceptions raised while scraping metrics. + * + *

The default policy built by {@link #builder()} does not expose exception details and does not + * report the exception. Configure the builder to route diagnostic details to an + * application-appropriate sink. + */ +@StableApi +public final class HttpErrorHandlingPolicy { + + private static final byte[] GENERIC_RESPONSE = + ("An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n") + .getBytes(StandardCharsets.UTF_8); + + private final boolean unsafeDebugResponse; + @Nullable private final Consumer errorReporter; + + private HttpErrorHandlingPolicy( + boolean unsafeDebugResponse, @Nullable Consumer errorReporter) { + this.unsafeDebugResponse = unsafeDebugResponse; + this.errorReporter = errorReporter; + } + + /** + * Returns a builder for configuring scrape error handling. + * + *

The builder defaults to a generic HTTP 500 response with no error reporter. This avoids + * exposing exception details to scrape clients or adding an implicit dependency on an + * application's logging configuration. + */ + public static Builder builder() { + return new Builder(); + } + + byte[] getErrorResponse(Exception exception) { + if (!unsafeDebugResponse) { + return GENERIC_RESPONSE; + } + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + printWriter.write("An Exception occurred while scraping metrics: "); + exception.printStackTrace(printWriter); + return stringWriter.toString().getBytes(StandardCharsets.UTF_8); + } + + void report(Throwable error) { + if (errorReporter != null) { + errorReporter.accept(error); + } + } + + /** Builder for {@link HttpErrorHandlingPolicy}. */ + public static final class Builder { + + private boolean unsafeDebugResponse = false; + @Nullable private Consumer errorReporter; + + private Builder() {} + + /** + * Pass scrape exceptions to {@code errorReporter}. + * + *

The reporter runs synchronously on the HTTP request thread. It should return promptly and + * must be safe to call concurrently. Runtime exceptions thrown by the reporter are isolated + * from HTTP response handling. + */ + public Builder errorReporter(Consumer errorReporter) { + if (errorReporter == null) { + throw new NullPointerException("errorReporter"); + } + this.errorReporter = errorReporter; + return this; + } + + /** + * Configure whether the HTTP 500 response includes the full exception stack trace. + * + *

Security warning: Setting this to {@code true} exposes internal exception + * information to scrape clients. Do not enable it for endpoints reachable by untrusted clients. + * + *

This setting is independent of {@link #errorReporter(Consumer)}. + */ + public Builder unsafeDebugResponse(boolean unsafeDebugResponse) { + this.unsafeDebugResponse = unsafeDebugResponse; + return this; + } + + /** Build the policy. */ + public HttpErrorHandlingPolicy build() { + return new HttpErrorHandlingPolicy(unsafeDebugResponse, errorReporter); + } + } +} diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java index df99837cb..fce6eba1f 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapter.java @@ -6,10 +6,7 @@ import io.prometheus.metrics.exporter.common.PrometheusHttpResponse; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; -import java.io.StringWriter; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Enumeration; import java.util.List; @@ -18,13 +15,21 @@ public class HttpExchangeAdapter implements PrometheusHttpExchange { + private static final Logger logger = Logger.getLogger(HttpExchangeAdapter.class.getName()); + private final HttpExchange httpExchange; + private final HttpErrorHandlingPolicy errorHandlingPolicy; private final HttpRequest request = new HttpRequest(); private final HttpResponse response = new HttpResponse(); private volatile boolean responseSent = false; public HttpExchangeAdapter(HttpExchange httpExchange) { + this(httpExchange, HttpErrorHandlingPolicy.builder().build()); + } + + HttpExchangeAdapter(HttpExchange httpExchange, HttpErrorHandlingPolicy errorHandlingPolicy) { this.httpExchange = httpExchange; + this.errorHandlingPolicy = errorHandlingPolicy; } public class HttpRequest implements PrometheusHttpRequest { @@ -92,52 +97,53 @@ public HttpResponse getResponse() { @Override public void handleException(IOException e) throws IOException { - sendErrorResponseWithStackTrace(e); + sendErrorResponse(e); } @Override public void handleException(RuntimeException e) { - sendErrorResponseWithStackTrace(e); + sendErrorResponse(e); } - private void sendErrorResponseWithStackTrace(Exception requestHandlerException) { + private void sendErrorResponse(Exception requestHandlerException) { if (!responseSent) { responseSent = true; + reportException(requestHandlerException); + byte[] errorResponse = errorHandlingPolicy.getErrorResponse(requestHandlerException); try { - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - printWriter.write("An Exception occurred while scraping metrics: "); - requestHandlerException.printStackTrace(new PrintWriter(printWriter)); - byte[] stackTrace = stringWriter.toString().getBytes(StandardCharsets.UTF_8); httpExchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - httpExchange.sendResponseHeaders(500, stackTrace.length); - httpExchange.getResponseBody().write(stackTrace); + httpExchange.sendResponseHeaders(500, errorResponse.length); + httpExchange.getResponseBody().write(errorResponse); } catch (IOException errorWriterException) { - // We want to avoid logging so that we don't mess with application logs when the HTTPServer - // is used in a Java agent. - // However, if we can't even send an error response to the client there's nothing we can do - // but logging a message. - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "The Prometheus metrics HTTPServer caught an Exception during scrape and " - + "failed to send an error response to the client.", - errorWriterException); - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "Original Exception that caused the Prometheus scrape error:", - requestHandlerException); + // If we can't even send an error response to the client, logging is the only remaining + // signal. + logger.log( + Level.SEVERE, + "The Prometheus metrics HTTPServer caught an Exception during scrape and " + + "failed to send an error response to the client.", + errorWriterException); + logger.log( + Level.SEVERE, + "Original Exception that caused the Prometheus scrape error:", + requestHandlerException); } } else { // If the exception occurs after response headers have been sent, it's too late to respond // with HTTP 500. - Logger.getLogger(this.getClass().getName()) - .log( - Level.SEVERE, - "The Prometheus metrics HTTPServer caught an Exception while trying to send " - + "the metrics response.", - requestHandlerException); + logger.log( + Level.SEVERE, + "The Prometheus metrics HTTPServer caught an Exception while trying to send " + + "the metrics response.", + requestHandlerException); + } + } + + private void reportException(Exception requestHandlerException) { + try { + errorHandlingPolicy.report(requestHandlerException); + } catch (RuntimeException ignored) { + // A caller-supplied reporter must not prevent the safe error response from being sent or + // implicitly fall back to application logging. } } diff --git a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java index 175696b68..b3ebf8ae0 100644 --- a/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java +++ b/prometheus-metrics-exporter-httpserver/src/main/java/io/prometheus/metrics/exporter/httpserver/MetricsHandler.java @@ -13,25 +13,40 @@ public class MetricsHandler implements HttpHandler { private final PrometheusScrapeHandler prometheusScrapeHandler; + private final HttpErrorHandlingPolicy errorHandlingPolicy; public MetricsHandler() { - prometheusScrapeHandler = new PrometheusScrapeHandler(); + this(new PrometheusScrapeHandler(), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusRegistry registry) { - prometheusScrapeHandler = new PrometheusScrapeHandler(registry); + this(new PrometheusScrapeHandler(registry), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusProperties config) { - prometheusScrapeHandler = new PrometheusScrapeHandler(config); + this(new PrometheusScrapeHandler(config), HttpErrorHandlingPolicy.builder().build()); } public MetricsHandler(PrometheusProperties config, PrometheusRegistry registry) { - prometheusScrapeHandler = new PrometheusScrapeHandler(config, registry); + this(new PrometheusScrapeHandler(config, registry), HttpErrorHandlingPolicy.builder().build()); + } + + MetricsHandler( + PrometheusProperties config, + PrometheusRegistry registry, + HttpErrorHandlingPolicy errorHandlingPolicy) { + this(new PrometheusScrapeHandler(config, registry), errorHandlingPolicy); + } + + private MetricsHandler( + PrometheusScrapeHandler prometheusScrapeHandler, + HttpErrorHandlingPolicy errorHandlingPolicy) { + this.prometheusScrapeHandler = prometheusScrapeHandler; + this.errorHandlingPolicy = errorHandlingPolicy; } @Override public void handle(HttpExchange t) throws IOException { - prometheusScrapeHandler.handleRequest(new HttpExchangeAdapter(t)); + prometheusScrapeHandler.handleRequest(new HttpExchangeAdapter(t, errorHandlingPolicy)); } } diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java index ff2d55048..59a3048aa 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HTTPServerTest.java @@ -25,6 +25,7 @@ import java.security.Principal; import java.util.List; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import javax.security.auth.Subject; import org.junit.jupiter.api.BeforeEach; @@ -160,18 +161,54 @@ void metricsCustomRootPath() throws Exception { @Test void registryThrows() throws Exception { + HTTPServer server = HTTPServer.builder().port(0).registry(throwingRegistry()).buildAndStart(); + run( + server, + "/metrics", + 500, + "Configure an HTTP error reporter for details.", + "IllegalStateException", + "test"); + } + + @Test + void registryExceptionIsPassedToConfiguredReporter() throws Exception { + AtomicReference reportedError = new AtomicReference<>(); HTTPServer server = HTTPServer.builder() .port(0) - .registry( - new PrometheusRegistry() { - @Override - public MetricSnapshots scrape(PrometheusScrapeRequest scrapeRequest) { - throw new IllegalStateException("test"); - } - }) + .registry(throwingRegistry()) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.builder().errorReporter(reportedError::set).build()) .buildAndStart(); - run(server, "/metrics", 500, "An Exception occurred while scraping metrics"); + + run( + server, + "/metrics", + 500, + "Configure an HTTP error reporter for details.", + "IllegalStateException", + "test"); + + assertThat(reportedError.get()).isInstanceOf(IllegalStateException.class).hasMessage("test"); + } + + @Test + void registryExceptionCanUseUnsafeDebugResponse() throws Exception { + HTTPServer server = + HTTPServer.builder() + .port(0) + .registry(throwingRegistry()) + .errorHandlingPolicy( + HttpErrorHandlingPolicy.builder().unsafeDebugResponse(true).build()) + .buildAndStart(); + + run( + server, + "/metrics", + 500, + "IllegalStateException: test", + "Configure an HTTP error reporter for details."); } @Test @@ -237,6 +274,25 @@ void healthDisabled() throws Exception { private static void run( HTTPServer server, String path, int expectedStatusCode, String expectedBody) throws Exception { + run(server, path, expectedStatusCode, expectedBody, new String[0]); + } + + private static PrometheusRegistry throwingRegistry() { + return new PrometheusRegistry() { + @Override + public MetricSnapshots scrape(PrometheusScrapeRequest scrapeRequest) { + throw new IllegalStateException("test"); + } + }; + } + + private static void run( + HTTPServer server, + String path, + int expectedStatusCode, + String expectedBody, + String... unexpectedBody) + throws Exception { // we cannot use try-with-resources or even client.close(), or the test will fail with Java 17 @SuppressWarnings("resource") final HttpClient client = HttpClient.newBuilder().build(); @@ -248,6 +304,9 @@ private static void run( client.send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(expectedStatusCode); assertThat(response.body()).contains(expectedBody); + if (unexpectedBody.length > 0) { + assertThat(response.body()).doesNotContain(unexpectedBody); + } } finally { server.stop(); } diff --git a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java index 19bc0d66e..4ec01c84c 100644 --- a/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java +++ b/prometheus-metrics-exporter-httpserver/src/test/java/io/prometheus/metrics/exporter/httpserver/HttpExchangeAdapterTest.java @@ -2,12 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; class HttpExchangeAdapterTest { @@ -46,4 +50,99 @@ void getHeadersWhenNotPresent() { HttpExchangeAdapter adapter = new HttpExchangeAdapter(httpExchange); assertThat(adapter.getRequest().getHeaders("Accept").hasMoreElements()).isFalse(); } + + @Test + void handleExceptionReturnsGenericMessageWithoutStackTrace() throws Exception { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + HttpExchangeAdapter adapter = new HttpExchangeAdapter(httpExchange); + + adapter.handleException(new IllegalStateException("secret failure")); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body) + .isEqualTo( + "An internal error occurred while scraping metrics. " + + "Configure an HTTP error reporter for details.\n"); + assertThat(body).doesNotContain("IllegalStateException"); + assertThat(body).doesNotContain("secret failure"); + assertThat(body).doesNotContain("at "); + assertThat(headers.getFirst("Content-Type")).isEqualTo("text/plain; charset=utf-8"); + verify(httpExchange).sendResponseHeaders(500, body.getBytes(StandardCharsets.UTF_8).length); + } + + @Test + void handleExceptionInvokesConfiguredReporter() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + AtomicReference reportedError = new AtomicReference<>(); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter( + httpExchange, + HttpErrorHandlingPolicy.builder().errorReporter(reportedError::set).build()); + IllegalStateException scrapeException = new IllegalStateException("secret failure"); + + adapter.handleException(scrapeException); + + assertThat(reportedError.get()).isSameAs(scrapeException); + } + + @Test + void reporterFailureDoesNotPreventGenericResponse() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter( + httpExchange, + HttpErrorHandlingPolicy.builder() + .errorReporter( + ignored -> { + throw new IllegalStateException("reporter failed"); + }) + .build()); + + adapter.handleException(new IllegalStateException("secret failure")); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body) + .contains("Configure an HTTP error reporter for details.") + .doesNotContain("secret failure") + .doesNotContain("reporter failed"); + } + + @Test + void unsafeDebugResponseIncludesStackTraceAndInvokesReporter() { + HttpExchange httpExchange = mock(HttpExchange.class); + Headers headers = new Headers(); + ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); + when(httpExchange.getResponseHeaders()).thenReturn(headers); + when(httpExchange.getResponseBody()).thenReturn(responseBody); + AtomicReference reportedError = new AtomicReference<>(); + HttpExchangeAdapter adapter = + new HttpExchangeAdapter( + httpExchange, + HttpErrorHandlingPolicy.builder() + .unsafeDebugResponse(true) + .errorReporter(reportedError::set) + .build()); + + IllegalStateException scrapeException = new IllegalStateException("diagnostic detail"); + adapter.handleException(scrapeException); + + String body = new String(responseBody.toByteArray(), StandardCharsets.UTF_8); + assertThat(body) + .contains("An Exception occurred while scraping metrics:") + .contains("IllegalStateException: diagnostic detail") + .contains("at "); + assertThat(reportedError.get()).isSameAs(scrapeException); + } }