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 914328b3555f CAMEL-25577: camel-telegram - Complete the exchange once 
when Telegram answers with an error (#27761)
914328b3555f is described below

commit 914328b3555f35ccec2b2f2c984091d1fb31fbc9
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 12:26:01 2026 +0530

    CAMEL-25577: camel-telegram - Complete the exchange once when Telegram 
answers with an error (#27761)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../telegram/service/TelegramAsyncHandler.java     |  12 +-
 .../telegram/service/TelegramMessageHandler.java   |  23 +-
 .../telegram/TelegramProducerFailureTest.java      | 303 +++++++++++++++++++++
 3 files changed, 324 insertions(+), 14 deletions(-)

diff --git 
a/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramAsyncHandler.java
 
b/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramAsyncHandler.java
index 3b180356d1a8..50b3f0bb18d3 100644
--- 
a/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramAsyncHandler.java
+++ 
b/components/camel-telegram/src/main/java/org/apache/camel/component/telegram/service/TelegramAsyncHandler.java
@@ -26,7 +26,6 @@ import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
-import org.apache.camel.AsyncCallback;
 import org.apache.camel.Exchange;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.component.telegram.model.MessageResult;
@@ -42,15 +41,12 @@ public class TelegramAsyncHandler {
     private final Class<? extends MessageResult> resultClass;
     private final ObjectMapper mapper;
     private final Exchange exchange;
-    private final AsyncCallback callback;
 
-    TelegramAsyncHandler(String uri, Class<? extends MessageResult> 
resultClass, ObjectMapper mapper, Exchange exchange,
-                         AsyncCallback callback) {
+    TelegramAsyncHandler(String uri, Class<? extends MessageResult> 
resultClass, ObjectMapper mapper, Exchange exchange) {
         this.uri = uri;
         this.resultClass = resultClass;
         this.mapper = mapper;
         this.exchange = exchange;
-        this.callback = callback;
     }
 
     public static String extractCharset(String line, String defaultValue) {
@@ -78,6 +74,10 @@ public class TelegramAsyncHandler {
 
     }
 
+    /**
+     * Sets the result of a successful response as the message body. Throws 
for a response with an error status and for
+     * a body that cannot be read; the caller fails the exchange with that 
exception and completes it.
+     */
     public Object handleCompressedResponse(HttpResponse<InputStream> response) 
{
         Object result;
         final boolean success = response.statusCode() >= 200 && 
response.statusCode() < 300;
@@ -112,8 +112,6 @@ public class TelegramAsyncHandler {
             throw new RuntimeException(ex);
         } catch (IOException ex) {
             throw new RuntimeCamelException("Could not parse the response from 
" + uri, ex);
-        } finally {
-            callback.done(false);
         }
 
         return exchange;
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 a2f9738ac25e..d92da68044a0 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
@@ -19,6 +19,7 @@ package org.apache.camel.component.telegram.service;
 import java.net.URI;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -63,18 +64,26 @@ abstract class TelegramMessageHandler<T extends 
TelegramMessage> {
         builder.setHeader("Accept", "application/json");
 
         builder.POST(bodyPublisher.newPublisher());
-        try {
-            final TelegramAsyncHandler telegramAsyncHandler
-                    = new TelegramAsyncHandler(uri, resultClass, mapper, 
exchange, callback);
+        final TelegramAsyncHandler telegramAsyncHandler = new 
TelegramAsyncHandler(uri, resultClass, mapper, exchange);
+        final CompletableFuture<Object> response
+                = apiClient.client().sendAsync(builder.build(), 
HttpResponse.BodyHandlers.ofInputStream())
+                        
.thenApply(telegramAsyncHandler::handleCompressedResponse);
 
-            apiClient.client().sendAsync(builder.build(), 
HttpResponse.BodyHandlers.ofInputStream())
-                    
.thenApply(telegramAsyncHandler::handleCompressedResponse).get();
+        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();
-            throw new RuntimeException(e);
+            // nobody waits for the answer any more: keep the stage from 
writing to the completed exchange later
+            response.cancel(true);
+            exchange.setException(new RuntimeException(e));
         } catch (ExecutionException e) {
-            throw new RuntimeException(e);
+            // an error status, a body that could not be read, or a request 
that failed without an answer
+            exchange.setException(new RuntimeException(e));
         }
+
+        // the only place that completes the exchange. The producer returns 
false from process(), hence done(false)
+        callback.done(false);
     }
 
     protected abstract void addBody(T message);
diff --git 
a/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerFailureTest.java
 
b/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerFailureTest.java
new file mode 100644
index 000000000000..b90c81755157
--- /dev/null
+++ 
b/components/camel-telegram/src/test/java/org/apache/camel/component/telegram/TelegramProducerFailureTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.AsyncProducer;
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.telegram.model.MessageResult;
+import org.apache.camel.component.telegram.model.OutgoingTextMessage;
+import org.apache.camel.component.telegram.model.SendLocationMessage;
+import org.apache.camel.component.telegram.model.SendVenueMessage;
+import org.apache.camel.component.telegram.util.TelegramMockRoutes;
+import org.apache.camel.component.telegram.util.TelegramTestSupport;
+import org.apache.camel.component.telegram.util.TelegramTestUtil;
+import org.apache.camel.test.AvailablePortFinder;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
+
+/**
+ * A request that Telegram answers with an error, with a body that cannot be 
read, or not at all, must fail the exchange
+ * and complete it once, and the calling thread waits for the answer.
+ */
+class TelegramProducerFailureTest extends TelegramTestSupport {
+
+    // reserved for this test, and nothing listens on it
+    @RegisterExtension
+    private static final AvailablePortFinder.Port CLOSED_PORT = 
AvailablePortFinder.find();
+
+    private final HoldingApi venueApi = new HoldingApi();
+
+    @EndpointInject("mock:after")
+    private MockEndpoint after;
+
+    @EndpointInject("mock:failed")
+    private MockEndpoint failed;
+
+    @Test
+    void errorStatusFailsTheExchangeOnce() throws Exception {
+        Throwable failure = assertFailsOnce("direct:telegram", telegramUri(), 
textMessage());
+
+        
assertThat(failure).isInstanceOf(RuntimeCamelException.class).hasMessageContaining("responded:
 400");
+    }
+
+    @Test
+    void unreadableAnswerFailsTheExchangeOnce() throws Exception {
+        Throwable failure = assertFailsOnce("direct:telegram", telegramUri(), 
new SendLocationMessage(59.9386292, 30.3141308));
+
+        
assertThat(failure).isInstanceOf(RuntimeCamelException.class).hasMessageContaining("Could
 not parse the response");
+    }
+
+    @Test
+    void requestWithoutAnswerFailsTheExchangeOnce() throws Exception {
+        Throwable failure = assertFailsOnce("direct:closed", closedPortUri(), 
textMessage());
+
+        assertThat(failure).isInstanceOf(IOException.class);
+    }
+
+    @Test
+    void errorStatusIsHandledByOnExceptionAndTheRouteDoesNotContinue() {
+        Exchange result = template.send("direct:handled", e -> 
e.getIn().setBody(textMessage()));
+
+        assertSoftly(softly -> {
+            softly.assertThat(after.getReceivedCounter()).as("exchanges at the 
step after the producer").isZero();
+            softly.assertThat(failed.getReceivedCounter()).as("exchanges at 
onException").isEqualTo(1);
+            softly.assertThat(result.getException()).as("exception left on the 
handled exchange").isNull();
+        });
+    }
+
+    @Test
+    void interruptedCallerFailsTheExchangeOnce() throws Exception {
+        Completion completion;
+        boolean stillInterrupted;
+        Thread.currentThread().interrupt();
+        try {
+            completion = process(telegramUri(), textMessage());
+        } finally {
+            // also clears the flag for the rest of the test
+            stillInterrupted = Thread.interrupted();
+        }
+
+        assertSoftly(softly -> completedOnce(softly, completion, true));
+        assertThat(stillInterrupted).as("interrupt flag kept").isTrue();
+        
assertThat(completion.exchange.getException()).isExactlyInstanceOf(RuntimeException.class)
+                .hasCauseInstanceOf(InterruptedException.class);
+    }
+
+    /**
+     * The order of the requests is the same without the fix: the calling 
thread has always waited for the answer, and
+     * it must stay so. What is new for an answered message is the thread that 
completes the exchange.
+     */
+    @Test
+    void twoSendsAreNotInterleavedAndSetTheResult() throws Exception {
+        AsyncProducer producer = 
context.getEndpoint(telegramUri()).createAsyncProducer();
+        producer.start();
+        try {
+            for (int i = 0; i < 2; i++) {
+                Completion completion = process(producer, new 
SendVenueMessage(59.9386292, 30.3141308, "title", "address"));
+
+                assertSoftly(softly -> completedOnce(softly, completion, 
false));
+                assertThat(completion.exchange.getException()).isNull();
+                
assertThat(completion.exchange.getMessage().getBody()).isInstanceOf(MessageResult.class);
+            }
+        } finally {
+            producer.stop();
+        }
+
+        assertThat(venueApi.events).containsExactly("request 1", "answer 1", 
"request 2", "answer 2");
+    }
+
+    /**
+     * Sends the message through a route and with the producer alone, and 
returns the failure that Telegram caused.
+     */
+    private Throwable assertFailsOnce(String routeUri, String producerUri, 
Object body) throws Exception {
+        Exchange routed = template.send(routeUri, e -> 
e.getIn().setBody(body));
+        Completion completion = process(producerUri, body);
+
+        assertSoftly(softly -> {
+            softly.assertThat(after.getReceivedCounter()).as("exchanges at the 
step after the producer").isZero();
+            softly.assertThat(routed.getException()).as("exception of the 
routed exchange").isNotNull();
+            completedOnce(softly, completion, true);
+        });
+        // the same exception as before, when process() threw it
+        Throwable exception = completion.exchange.getException();
+        
assertThat(exception).isExactlyInstanceOf(RuntimeException.class).hasCauseInstanceOf(ExecutionException.class);
+        
assertThat(routed.getException()).isExactlyInstanceOf(RuntimeException.class)
+                .hasCauseInstanceOf(ExecutionException.class);
+        return exception.getCause().getCause();
+    }
+
+    private static void completedOnce(SoftAssertions softly, Completion 
completion, boolean failed) {
+        softly.assertThat(completion.thrownByProcess).as("thrown by process() 
of the producer").isNull();
+        softly.assertThat(completion.callsWhenProcessReturned).as("calls of 
the callback when process() returned")
+                .isEqualTo(1);
+        softly.assertThat(completion.calls.get()).as("calls of the 
callback").isEqualTo(1);
+        softly.assertThat(completion.callingThread.get()).as("thread that 
called the callback")
+                .isSameAs(Thread.currentThread());
+        softly.assertThat(completion.failedAtFirstCall.get()).as("exchange 
failed when it was completed")
+                .isEqualTo(failed);
+    }
+
+    private Completion process(String uri, Object body) throws Exception {
+        AsyncProducer producer = 
context.getEndpoint(uri).createAsyncProducer();
+        producer.start();
+        try {
+            return process(producer, body);
+        } finally {
+            producer.stop();
+        }
+    }
+
+    /**
+     * Calls the producer the way a route does and records every call of the 
callback.
+     */
+    private static Completion process(AsyncProducer producer, Object body) 
throws Exception {
+        Exchange exchange = producer.getEndpoint().createExchange();
+        exchange.getIn().setBody(body);
+        Completion completion = new Completion(exchange);
+        try {
+            producer.process(exchange, doneSync -> {
+                if (completion.calls.getAndIncrement() == 0) {
+                    completion.callingThread.set(Thread.currentThread());
+                    completion.failedAtFirstCall.set(exchange.getException() 
!= null);
+                }
+                completion.called.countDown();
+            });
+        } catch (RuntimeException e) {
+            completion.thrownByProcess = e;
+        }
+        completion.callsWhenProcessReturned = completion.calls.get();
+        if (completion.thrownByProcess == null && 
completion.callsWhenProcessReturned == 0) {
+            // a producer that does not wait for the answer gets the time to 
complete the exchange, so nothing hangs
+            assertThat(completion.called.await(20, TimeUnit.SECONDS))
+                    .as("the exchange was completed within 20 
seconds").isTrue();
+        }
+        return completion;
+    }
+
+    private String telegramUri() {
+        return "telegram:bots?authorizationToken=mock-token&chatId=" + chatId;
+    }
+
+    private String closedPortUri() {
+        return telegramUri() + "&baseUri=http://localhost:"; + 
CLOSED_PORT.getPort();
+    }
+
+    private static OutgoingTextMessage textMessage() {
+        OutgoingTextMessage message = new OutgoingTextMessage();
+        message.setText("hello");
+        return message;
+    }
+
+    private static final class Completion {
+        private final Exchange exchange;
+        private final AtomicInteger calls = new AtomicInteger();
+        private final AtomicReference<Boolean> failedAtFirstCall = new 
AtomicReference<>();
+        private final AtomicReference<Thread> callingThread = new 
AtomicReference<>();
+        private final CountDownLatch called = new CountDownLatch(1);
+        // written and read by the test thread only
+        private RuntimeException thrownByProcess;
+        private int callsWhenProcessReturned;
+
+        private Completion(Exchange exchange) {
+            this.exchange = exchange;
+        }
+    }
+
+    /**
+     * Answers sendVenue. It keeps the first answer back for a second, or 
until a second request arrives: a producer
+     * that sends the second message before the first is answered shows up in 
the order of the events.
+     */
+    private static final class HoldingApi implements Processor {
+        private final List<String> events = new CopyOnWriteArrayList<>();
+        private final AtomicInteger requests = new AtomicInteger();
+        private final CountDownLatch secondArrived = new CountDownLatch(1);
+
+        @Override
+        public void process(Exchange exchange) throws Exception {
+            int request = requests.incrementAndGet();
+            events.add("request " + request);
+            if (request == 1) {
+                if (secondArrived.await(1, TimeUnit.SECONDS)) {
+                    events.add("request 2 arrived before answer 1");
+                }
+            } else {
+                secondArrived.countDown();
+            }
+            events.add("answer " + request);
+            byte[] bytes = 
TelegramTestUtil.stringResource("messages/send-venue.json").getBytes(StandardCharsets.UTF_8);
+            exchange.getMessage().setBody(bytes);
+            exchange.getMessage().setHeader("Content-Length", bytes.length);
+            exchange.getMessage().setHeader("Content-Type", "application/json; 
charset=UTF-8");
+        }
+    }
+
+    @Override
+    protected RoutesBuilder[] createRouteBuilders() {
+        return new RoutesBuilder[] {
+                getMockRoutes(),
+                new RouteBuilder() {
+                    @Override
+                    public void configure() {
+                        from("netty-http:http://localhost:"; + port.getPort()
+                             + 
"/botmock-token/sendVenue?httpMethodRestrict=POST")
+                                .process(venueApi);
+
+                        from("direct:telegram")
+                                .to(telegramUri())
+                                .to("mock:after");
+
+                        from("direct:closed")
+                                .to(closedPortUri())
+                                .to("mock:after");
+
+                        from("direct:handled")
+                                
.onException(Exception.class).handled(true).to("mock:failed").end()
+                                .to(telegramUri())
+                                .to("mock:after");
+                    }
+                } };
+    }
+
+    @Override
+    protected TelegramMockRoutes createMockRoutes() {
+        return new TelegramMockRoutes(port.getPort())
+                // Telegram answers 400, for example for a chat that does not 
exist
+                .addErrorEndpoint("sendMessage", "POST", 400)
+                // 200 with a body that is not JSON, for example the error 
page of a proxy
+                .addEndpoint("sendLocation", "POST", String.class, "<html>Bad 
Gateway</html>");
+    }
+}

Reply via email to