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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 832f3601d3ea CAMEL-26826: camel-opa - bound response body consumption 
by request timeout (#29628)
832f3601d3ea is described below

commit 832f3601d3eaa4737a1c1bf186a8d801f1efdd0b
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Nov 12 17:07:45 2026 +0100

    CAMEL-26826: camel-opa - bound response body consumption by request timeout 
(#29628)
    
    Apply requestTimeout to the complete OPA REST response, including body 
consumption, and cancel unfinished exchanges on timeout or interruption. 
Preserve fatal Error causes. Includes stalled-body timeout-cause assertions, 
finite truncated-JSON regressions for both fail-open settings, and transport 
documentation.
    
    Co-authored-by: Codex <[email protected]>
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../apache/camel/catalog/docs/opa-component.adoc   |   5 +-
 .../camel-opa/src/main/docs/opa-component.adoc     |   5 +-
 .../apache/camel/component/opa/OpaHttpClient.java  |  44 ++++++-
 .../camel/component/opa/OpaRestTransportTest.java  | 142 ++++++++++++++++++++-
 4 files changed, 188 insertions(+), 8 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
index 70c72ed5b535..f05907d8b19a 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
@@ -487,7 +487,10 @@ separate process that a timeout can abandon.
 In `rest` mode the component talks to OPA over HTTP, and both waits are 
bounded:
 
 * `connectionTimeout` (default 10s) — establishing the connection.
-* `requestTimeout` (default 30s) — waiting for the decision once connected.
+* `requestTimeout` (default 30s) — receiving the complete response for each 
HTTP request, including its body.
+
+Receiving response headers does not end the request deadline: a server that 
sends headers and then stalls while
+writing the decision still times out. SDK retries, when applicable, make 
separate HTTP requests with the same limit.
 
 Neither is optional in practice. A refused connection fails immediately, but a 
server that *accepts* and then
 stops answering — wedged, mid-restart, or behind a load balancer holding the 
socket — would otherwise park the
diff --git a/components/camel-opa/src/main/docs/opa-component.adoc 
b/components/camel-opa/src/main/docs/opa-component.adoc
index 70c72ed5b535..f05907d8b19a 100644
--- a/components/camel-opa/src/main/docs/opa-component.adoc
+++ b/components/camel-opa/src/main/docs/opa-component.adoc
@@ -487,7 +487,10 @@ separate process that a timeout can abandon.
 In `rest` mode the component talks to OPA over HTTP, and both waits are 
bounded:
 
 * `connectionTimeout` (default 10s) — establishing the connection.
-* `requestTimeout` (default 30s) — waiting for the decision once connected.
+* `requestTimeout` (default 30s) — receiving the complete response for each 
HTTP request, including its body.
+
+Receiving response headers does not end the request deadline: a server that 
sends headers and then stalls while
+writing the decision still times out. SDK retries, when applicable, make 
separate HTTP requests with the same limit.
 
 Neither is optional in practice. A refused connection fails immediately, but a 
server that *accepts* and then
 stops answering — wedged, mid-restart, or behind a load balancer holding the 
socket — would otherwise park the
diff --git 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaHttpClient.java
 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaHttpClient.java
index bd537e6173b9..2694ae1264ad 100644
--- 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaHttpClient.java
+++ 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaHttpClient.java
@@ -16,12 +16,18 @@
  */
 package org.apache.camel.component.opa;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
 import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 import javax.net.ssl.SSLContext;
 
@@ -38,11 +44,13 @@ import org.apache.camel.util.ObjectHelper;
  * are the JDK default of "wait indefinitely". A server that accepts the 
connection and then goes quiet parks the
  * calling thread for ever - and a component that fails closed never reaches 
the point of denying, it simply stops.
  * {@code failOpen} does not help, because it sits downstream of a call that 
never returns.</li>
- * <li><b>It builds a client per request.</b> On the Java 17 baseline {@link 
HttpClient} is not {@link AutoCloseable},
- * so each one holds its selector thread and executor until it is collected - 
once per message, on the path an
- * {@code OpaSecurityPolicy} guards.</li>
+ * <li><b>It builds a client per request.</b> Each {@link HttpClient} holds 
its selector thread and executor until it is
+ * closed or collected - once per message, on the path an {@code 
OpaSecurityPolicy} guards.</li>
  * </ul>
- * One client is built here per evaluator and reused, and every request is 
re-issued carrying a timeout.
+ * One client is built here per evaluator and reused, and every request is 
re-issued carrying a timeout. The response
+ * body is buffered rather than streamed with {@code ofInputStream}: the 
timeout of a request only covers the wait for
+ * the response headers, so the whole response, body included, is awaited 
under {@code requestTimeout} here, and a
+ * server that stalls or drips its body is cut off like one that never answers.
  */
 public class OpaHttpClient implements HTTPClient, AutoCloseable {
 
@@ -85,6 +93,32 @@ public class OpaHttpClient implements HTTPClient, 
AutoCloseable {
         if (bearerToken != null) {
             builder.header(AUTHORIZATION, "Bearer " + bearerToken);
         }
-        return client.send(builder.build(), 
HttpResponse.BodyHandlers.ofInputStream());
+        // A streaming body handler completes as soon as headers arrive, 
leaving the SDK's subsequent body read
+        // outside HttpRequest.timeout(). Complete the body before returning 
its stream and bound that entire wait.
+        CompletableFuture<HttpResponse<InputStream>> response = 
client.sendAsync(builder.build(),
+                info -> 
HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.ofByteArray(),
+                        bytes -> new ByteArrayInputStream(bytes)));
+        try {
+            return response.get(requestTimeout.toMillis(), 
TimeUnit.MILLISECONDS);
+        } catch (TimeoutException e) {
+            response.cancel(true);
+            throw new HttpTimeoutException("Timed out waiting for the complete 
OPA response after " + requestTimeout);
+        } catch (InterruptedException e) {
+            response.cancel(true);
+            throw e;
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof Error error) {
+                // an OutOfMemoryError from buffering the body is not an HTTP 
failure; keep it what it is
+                throw error;
+            }
+            if (cause instanceof IOException io) {
+                throw io;
+            }
+            if (cause instanceof RuntimeException runtime) {
+                throw runtime;
+            }
+            throw new IOException("OPA HTTP request failed", cause);
+        }
     }
 }
diff --git 
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaRestTransportTest.java
 
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaRestTransportTest.java
index 8bb3096a273f..6c70565f1ca0 100644
--- 
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaRestTransportTest.java
+++ 
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaRestTransportTest.java
@@ -21,10 +21,17 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
 import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
 import java.nio.charset.StandardCharsets;
 import java.security.KeyStore;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 
 import javax.net.ssl.KeyManagerFactory;
@@ -41,6 +48,8 @@ import org.apache.camel.test.junit6.CamelTestSupport;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -182,6 +191,136 @@ public class OpaRestTransportTest extends 
CamelTestSupport {
         return server.getAddress().getPort();
     }
 
+    /** Sends response headers and an incomplete JSON document, then waits for 
test cleanup. */
+    private int stalledBodyPort(CountDownLatch headersSent) throws IOException 
{
+        CountDownLatch release = new CountDownLatch(1);
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("localhost", 0), 0);
+        server.createContext("/v1/data/authz/allow", exchange -> {
+            exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+            exchange.sendResponseHeaders(200, 0);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.write('{');
+                out.flush();
+                headersSent.countDown();
+                try {
+                    release.await(60, TimeUnit.SECONDS);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+        });
+        server.start();
+        open.add(() -> {
+            release.countDown();
+            server.stop(0);
+        });
+        return server.getAddress().getPort();
+    }
+
+    @Test
+    @Timeout(10)
+    void failsClosedWhenTheResponseBodyStallsAfterHeaders() throws Exception {
+        CountDownLatch headersSent = new CountDownLatch(1);
+        Exchange out = template.request(
+                "opa:authz/allow?serverUrl=http://localhost:"; + 
stalledBodyPort(headersSent)
+                                        + "&requestTimeout=500",
+                e -> {
+                });
+
+        // the timeout, not some other failure, is what stopped the evaluation
+        
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class)
+                .hasRootCauseInstanceOf(HttpTimeoutException.class);
+        
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull();
+        assertThat(headersSent.getCount()).isZero();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    @Timeout(10)
+    void failsClosedWhenTheServerClosesAfterTruncatedJson(boolean failOpen) 
throws Exception {
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("localhost", 0), 0);
+        server.createContext("/v1/data/authz/allow", exchange -> {
+            exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+            exchange.sendResponseHeaders(200, 1);
+            try (OutputStream body = exchange.getResponseBody()) {
+                body.write('{');
+            }
+        });
+        server.start();
+        open.add(() -> server.stop(0));
+
+        Exchange out = template.request(
+                "opa:authz/allow?serverUrl=http://localhost:"; + 
server.getAddress().getPort()
+                                        + "&requestTimeout=1000&failOpen=" + 
failOpen,
+                e -> {
+                });
+
+        
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
+        
assertThat(out.getException()).hasRootCauseInstanceOf(com.fasterxml.jackson.core.JsonParseException.class);
+        
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull();
+        
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
+    }
+
+    @Test
+    @Timeout(10)
+    void reportsFailOpenWhenTheResponseBodyStallsAfterHeaders() throws 
Exception {
+        CountDownLatch headersSent = new CountDownLatch(1);
+        Exchange out = template.request(
+                "opa:authz/allow?serverUrl=http://localhost:"; + 
stalledBodyPort(headersSent)
+                                        + "&requestTimeout=500&failOpen=true",
+                e -> {
+                });
+
+        assertThat(out.getException()).isNull();
+        
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+        
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isEqualTo(true);
+        assertThat(headersSent.getCount()).isZero();
+    }
+
+    @Test
+    @Timeout(10)
+    void interruptsTheWaitForAStalledResponseBody() throws Exception {
+        CountDownLatch headersSent = new CountDownLatch(1);
+        CountDownLatch interrupted = new CountDownLatch(1);
+        HttpRequest request = HttpRequest.newBuilder(
+                URI.create("http://localhost:"; + stalledBodyPort(headersSent) 
+ "/v1/data/authz/allow")).build();
+
+        try (OpaHttpClient transport = new OpaHttpClient(1000, 30000, null, 
null);
+             var executor = Executors.newSingleThreadExecutor()) {
+            var call = executor.submit(() -> {
+                try {
+                    transport.send(request);
+                } catch (InterruptedException e) {
+                    interrupted.countDown();
+                    Thread.currentThread().interrupt();
+                } catch (IOException e) {
+                    throw new IllegalStateException(e);
+                }
+            });
+            assertThat(headersSent.await(5, TimeUnit.SECONDS)).isTrue();
+            assertThat(call.cancel(true)).isTrue();
+            assertThat(interrupted.await(5, TimeUnit.SECONDS)).isTrue();
+        }
+    }
+
+    @Test
+    @Timeout(10)
+    void retainsResponseMetadataAndTheCompletedBody() throws Exception {
+        URI uri = URI.create("http://localhost:"; + recordingPort(new 
AtomicReference<>()) + "/v1/data/authz/allow");
+        HttpRequest request = HttpRequest.newBuilder(uri).build();
+
+        try (OpaHttpClient transport = new OpaHttpClient(1000, 5000, null, 
null)) {
+            HttpResponse<InputStream> response = transport.send(request);
+            assertThat(response.statusCode()).isEqualTo(200);
+            assertThat(response.uri()).isEqualTo(uri);
+            assertThat(response.request().uri()).isEqualTo(request.uri());
+            
assertThat(response.headers().firstValue("Content-Type")).contains("application/json");
+            try (InputStream body = response.body()) {
+                assertThat(new String(body.readAllBytes(), 
StandardCharsets.UTF_8)).isEqualTo("{\"result\":true}");
+            }
+        }
+    }
+
     @Test
     @Timeout(60)
     void sendsTheBearerTokenWhenOneIsConfigured() throws Exception {
@@ -215,7 +354,8 @@ public class OpaRestTransportTest extends CamelTestSupport {
                 "opa:authz/allow?serverUrl=http://localhost:"; + mutePort() + 
"&requestTimeout=500", e -> {
                 });
 
-        
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
+        
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class)
+                .hasRootCauseInstanceOf(HttpTimeoutException.class);
         
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull();
     }
 

Reply via email to