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 5ac66040917f CAMEL-25600: camel-telegram - an interrupted send also 
cancels the request future of the HTTP client (#27854)
5ac66040917f is described below

commit 5ac66040917fa4164ecb372c69933609220f58d1
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 15 11:17:31 2026 +0530

    CAMEL-25600: camel-telegram - an interrupted send also cancels the request 
future of the HTTP client (#27854)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../telegram/service/TelegramMessageHandler.java   |  12 +-
 .../telegram/TelegramProducerInterruptTest.java    | 239 +++++++++++++++++++++
 2 files changed, 247 insertions(+), 4 deletions(-)

diff --git 
a/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramMessageHandler.java
 
b/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramMessageHandler.java
index d92da68044a0..4da26ab2c504 100644
--- 
a/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramMessageHandler.java
+++ 
b/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramMessageHandler.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.component.telegram.service;
 
+import java.io.InputStream;
 import java.net.URI;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
@@ -65,17 +66,20 @@ abstract class TelegramMessageHandler<T extends 
TelegramMessage> {
 
         builder.POST(bodyPublisher.newPublisher());
         final TelegramAsyncHandler telegramAsyncHandler = new 
TelegramAsyncHandler(uri, resultClass, mapper, exchange);
-        final CompletableFuture<Object> response
-                = apiClient.client().sendAsync(builder.build(), 
HttpResponse.BodyHandlers.ofInputStream())
-                        
.thenApply(telegramAsyncHandler::handleCompressedResponse);
+        final CompletableFuture<HttpResponse<InputStream>> sent
+                = apiClient.client().sendAsync(builder.build(), 
HttpResponse.BodyHandlers.ofInputStream());
+        final CompletableFuture<Object> response = 
sent.thenApply(telegramAsyncHandler::handleCompressedResponse);
 
         try {
             // the calling thread waits for the answer, so a caller sends one 
message after the other
             response.get();
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            // nobody waits for the answer any more: keep the stage from 
writing to the completed exchange later
+            // nobody waits for the answer any more: keep the stage from 
writing to the completed exchange later,
+            // and abort the request. The futures of the JDK client pass the 
cancel of a dependent stage on to the
+            // request; the future of a custom client may not, so the one that 
sendAsync returned is cancelled too
             response.cancel(true);
+            sent.cancel(true);
             exchange.setException(new RuntimeException(e));
         } catch (ExecutionException e) {
             // an error status, a body that could not be read, or a request 
that failed without an answer
diff --git 
a/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerInterruptTest.java
 
b/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerInterruptTest.java
new file mode 100644
index 000000000000..67b080fb9dc1
--- /dev/null
+++ 
b/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerInterruptTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.camel.component.telegram;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.Authenticator;
+import java.net.CookieHandler;
+import java.net.InetAddress;
+import java.net.ProxySelector;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+
+import org.apache.camel.AsyncProducer;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.telegram.model.OutgoingTextMessage;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A caller that is interrupted while it waits for the answer of Telegram: the 
exchange fails once and the request is
+ * given up, so that nothing is left waiting for an answer that nobody reads.
+ */
+class TelegramProducerInterruptTest extends CamelTestSupport {
+
+    private static final String OPTIONS = 
"telegram:bots?authorizationToken=mock-token&chatId=1";
+
+    @Test
+    void interruptWhileWaitingForTheAnswerClosesTheConnection() throws 
Exception {
+        try (ServerSocket server = new ServerSocket(0, 1, 
InetAddress.getLoopbackAddress())) {
+            CountDownLatch requestArrived = new CountDownLatch(1);
+            CompletableFuture<String> connection = new CompletableFuture<>();
+            Thread telegram = new Thread(() -> neverAnswer(server, 
requestArrived, connection), "telegram-never-answers");
+            telegram.start();
+
+            Sent sent = sendAndInterrupt(OPTIONS + 
"&baseUri=http://localhost:"; + server.getLocalPort(), requestArrived);
+
+            assertInterruptedOnce(sent);
+            // the request is aborted: the client does not keep the connection 
for an answer that nobody reads
+            assertThat(connection.get(20, TimeUnit.SECONDS)).isEqualTo("closed 
by the client");
+            telegram.join(TimeUnit.SECONDS.toMillis(20));
+            assertThat(telegram.isAlive()).as("the server thread still 
runs").isFalse();
+        }
+    }
+
+    @Test
+    void interruptCancelsTheFutureOfACustomClient() throws Exception {
+        // the futures of this client are plain ones: cancelling a dependent 
stage does not reach them
+        PlainFutureClient client = new PlainFutureClient();
+        context.getRegistry().bind("plainFutureClient", client);
+
+        Sent sent = sendAndInterrupt(OPTIONS + 
"&baseUri=http://localhost:1&client=#plainFutureClient";, client.requested);
+
+        assertInterruptedOnce(sent);
+        assertThat(client.future.get().isCancelled()).as("future that 
sendAsync returned is cancelled").isTrue();
+    }
+
+    /**
+     * Sends a text message from another thread, interrupts that thread when 
the request is on its way, and returns when
+     * the producer has returned.
+     */
+    private Sent sendAndInterrupt(String uri, CountDownLatch requestArrived) 
throws Exception {
+        AsyncProducer producer = 
context.getEndpoint(uri).createAsyncProducer();
+        producer.start();
+        try {
+            Exchange exchange = producer.getEndpoint().createExchange();
+            OutgoingTextMessage message = new OutgoingTextMessage();
+            message.setText("hello");
+            exchange.getIn().setBody(message);
+
+            Sent sent = new Sent(exchange);
+            Thread caller = new Thread(() -> {
+                try {
+                    producer.process(exchange, doneSync -> 
sent.calls.incrementAndGet());
+                } catch (RuntimeException e) {
+                    sent.thrownByProcess.set(e);
+                }
+                
sent.interruptFlagKept.set(Thread.currentThread().isInterrupted());
+            }, "telegram-caller");
+            caller.start();
+
+            assertThat(requestArrived.await(20, TimeUnit.SECONDS)).as("the 
request was sent within 20 seconds").isTrue();
+            caller.interrupt();
+            caller.join(TimeUnit.SECONDS.toMillis(20));
+            assertThat(caller.isAlive()).as("the producer still waits after 
the interrupt").isFalse();
+            return sent;
+        } finally {
+            producer.stop();
+        }
+    }
+
+    private static void assertInterruptedOnce(Sent sent) {
+        assertThat(sent.thrownByProcess.get()).as("thrown by process() of the 
producer").isNull();
+        assertThat(sent.calls.get()).as("calls of the callback").isEqualTo(1);
+        assertThat(sent.interruptFlagKept.get()).as("interrupt flag 
kept").isTrue();
+        
assertThat(sent.exchange.getException()).isExactlyInstanceOf(RuntimeException.class)
+                .hasCauseInstanceOf(InterruptedException.class);
+    }
+
+    /**
+     * Reads the request and then waits, without an answer, for what the 
client does with the connection.
+     */
+    private static void neverAnswer(ServerSocket server, CountDownLatch 
requestArrived, CompletableFuture<String> connection) {
+        try (Socket socket = server.accept()) {
+            socket.setSoTimeout((int) TimeUnit.SECONDS.toMillis(20));
+            InputStream in = socket.getInputStream();
+            byte[] buffer = new byte[8192];
+            int read = in.read(buffer);
+            requestArrived.countDown();
+            // the client sends nothing more after the request; the cap only 
bounds the loop
+            for (int reads = 0; read != -1 && reads < 1000; reads++) {
+                read = in.read(buffer);
+            }
+            connection.complete(read == -1 ? "closed by the client" : "still 
sending after 1000 reads");
+        } catch (SocketTimeoutException e) {
+            connection.complete("still open after 20 seconds");
+        } catch (IOException e) {
+            // a reset is also the client giving up the connection
+            connection.complete("closed by the client");
+        }
+    }
+
+    private static final class Sent {
+        private final Exchange exchange;
+        private final AtomicInteger calls = new AtomicInteger();
+        private final AtomicReference<RuntimeException> thrownByProcess = new 
AtomicReference<>();
+        private final AtomicReference<Boolean> interruptFlagKept = new 
AtomicReference<>();
+
+        private Sent(Exchange exchange) {
+            this.exchange = exchange;
+        }
+    }
+
+    /**
+     * A client that never answers and returns a plain {@link 
CompletableFuture}, as a client other than the one of the
+     * JDK may do.
+     */
+    private static final class PlainFutureClient extends HttpClient {
+        private final CountDownLatch requested = new CountDownLatch(1);
+        private final AtomicReference<CompletableFuture<?>> future = new 
AtomicReference<>();
+
+        @Override
+        public <T> CompletableFuture<HttpResponse<T>> sendAsync(
+                HttpRequest request, HttpResponse.BodyHandler<T> 
responseBodyHandler) {
+            CompletableFuture<HttpResponse<T>> answer = new 
CompletableFuture<>();
+            future.set(answer);
+            requested.countDown();
+            return answer;
+        }
+
+        @Override
+        public <T> CompletableFuture<HttpResponse<T>> sendAsync(
+                HttpRequest request, HttpResponse.BodyHandler<T> 
responseBodyHandler,
+                HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
+            return sendAsync(request, responseBodyHandler);
+        }
+
+        @Override
+        public <T> HttpResponse<T> send(HttpRequest request, 
HttpResponse.BodyHandler<T> responseBodyHandler) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Optional<CookieHandler> cookieHandler() {
+            return Optional.empty();
+        }
+
+        @Override
+        public Optional<Duration> connectTimeout() {
+            return Optional.empty();
+        }
+
+        @Override
+        public Redirect followRedirects() {
+            return Redirect.NEVER;
+        }
+
+        @Override
+        public Optional<ProxySelector> proxy() {
+            return Optional.empty();
+        }
+
+        @Override
+        public SSLContext sslContext() {
+            return null;
+        }
+
+        @Override
+        public SSLParameters sslParameters() {
+            return null;
+        }
+
+        @Override
+        public Optional<Authenticator> authenticator() {
+            return Optional.empty();
+        }
+
+        @Override
+        public Version version() {
+            return Version.HTTP_1_1;
+        }
+
+        @Override
+        public Optional<Executor> executor() {
+            return Optional.empty();
+        }
+    }
+}

Reply via email to