shashank created CAMEL-25577:
--------------------------------
Summary: camel-telegram - when Telegram answers with an error
status or an unreadable body, the route first continues as if the message was
sent and then the exchange fails too (completed twice)
Key: CAMEL-25577
URL: https://issues.apache.org/jira/browse/CAMEL-25577
Project: Camel
Issue Type: Bug
Components: camel-telegram
Reporter: shashank
Assignee: shashank
{{TelegramProducer.process}} returns {{false}} (line 84 at main c3829069cb72)
and leaves the completion of the exchange to
{{TelegramMessageHandler.sendMessage}} (lines 70-71):
{code:java}
apiClient.client().sendAsync(builder.build(),
HttpResponse.BodyHandlers.ofInputStream())
.thenApply(telegramAsyncHandler::handleCompressedResponse).get();
{code}
{{TelegramAsyncHandler.handleCompressedResponse}} (lines 81-120) throws a
{{RuntimeCamelException}} for a status outside 2xx (line 108) and for a body it
cannot parse (line 114), and calls {{callback.done(false)}} in a {{finally}}
block (lines 115-117). The exception is not set on the exchange; it only
completes the future exceptionally. So for such a response:
# the {{finally}} block calls the callback while the exchange has no exception:
the route continues after the producer as if the message had been sent (on the
thread of the HTTP client);
# {{get()}} throws {{ExecutionException}}, {{sendMessage}} rethrows it as
{{RuntimeException}} (line 76) out of {{process()}};
# the caller of the producer catches it, sets it on the exchange and calls the
callback again ({{SendProcessor.sendUsingProducer}}, lines 273-276).
The exchange is completed twice, once as a success and once as a failure. With
{{from(...).to("telegram:bots").to("jms:sent")}} a message that Telegram
rejected (400 for an unknown chat, 403 when the bot was blocked, 429 when rate
limited, a 502 page of a proxy) is reported to {{jms:sent}} as sent. The
exception that {{process()}} throws afterwards is a {{RuntimeException}} that
wraps an {{ExecutionException}} that wraps the {{RuntimeCamelException}};
without an {{onException}} it is on the exchange that the caller of the route
gets back.
The failure arrives after the route has finished, so the error handling of the
route does not see it properly. Tested on main with a route-scoped
{{onException(Exception.class).handled(true).to("mock:failed")}} in front of
the producer: {{mock:after}} gets the exchange, {{mock:failed}} gets nothing,
and {{RedeliveryErrorHandler}} logs {{Cannot determine current route from
Exchange with id: ..., will fallback and use first error handler.}} I tested
this one case only (no redelivery, no context-scoped {{onException}}).
A request that fails without a response (connection refused) is handled
correctly on main: {{thenApply}} is skipped, so only the caller completes the
exchange.
h3. Reproduction
New {{TelegramProducerFailureTest}} (6 tests) with the mock Bot API of the
module ({{TelegramMockRoutes}}); all six fail with the two classes of main:
* {{errorStatusFailsTheExchangeOnce}} ({{sendMessage}} answers 400) and
{{unreadableAnswerFailsTheExchangeOnce}} (200 with an HTML body) send through
{{direct:telegram -> telegram:bots -> mock:after}} and with the producer and a
callback that records each call: {{[exchanges at the step after the producer]
expected: 0 but was: 1}}, {{[exchange failed when it was completed] expected:
true but was: false}}, {{[thrown by process() of the producer] expected: null
but was: java.lang.RuntimeException: java.util.concurrent.ExecutionException:
org.apache.camel.RuntimeCamelException: ... responded: 400...}}.
* {{errorStatusIsHandledByOnExceptionAndTheRouteDoesNotContinue}}: {{[exchanges
at the step after the producer] expected: 0 but was: 1}} and {{[exchanges at
onException] expected: 1 but was: 0}}.
* {{requestWithoutAnswerFailsTheExchangeOnce}} ({{baseUri}} with a port nothing
listens on) and {{interruptedCallerFailsTheExchangeOnce}} fail on main only
because {{process()}} throws instead of completing the exchange through the
callback; they cover the new handling of these two cases.
* {{twoSendsAreNotInterleavedAndSetTheResult}} (the mock API holds the first
answer back for up to a second): the order of the requests and the
{{MessageResult}} body are the same on main; it fails there only on the thread
that calls the callback (a thread of the HTTP client on main).
h3. Proposed fix
Keep the wait, fix the completion. {{handleCompressedResponse}} no longer calls
the callback. {{sendMessage}} still waits in {{get()}} on the calling thread,
then sets the failure on the exchange and calls {{callback.done(false)}} once
({{process()}} returns {{false}}, as before): for an answer, for a request that
failed without one, and when the wait was interrupted.
The blocking {{get()}} stays on purpose. It came with CAMEL-18506; removing it
would let asynchronous callers (a seda consumer, for example) send
concurrently, the order of the messages of one caller would no longer be kept,
and a 429 would become more likely. That would be a separate change.
Behaviour change, with a 4.24 upgrade-guide entry:
* a rejected message fails the exchange once and the route stops at the
producer;
* the exception of the exchange is the same as before ({{RuntimeException}} ->
{{ExecutionException}} -> {{RuntimeCamelException}} or {{IOException}}); on
main {{process()}} threw it, now the producer sets it on the exchange;
* the steps after the producer run on the calling thread. On main they ran on a
thread of the HTTP client after a successful answer, while the calling thread
was blocked in {{get()}}.
Not changed: the request has no timeout (a server that accepts the connection
and never answers leaves the calling thread waiting, as on main).
camel-telegram tests with the fix: 99 run, 0 failures, 1 skipped (the
integration tests need a bot token and were not run).
How it was found: a small TLA+ model of one send (request, outcome in
\{response 2xx, error status, unreadable body, no response\}, the handler
stage, the blocked caller). For the code of main TLC reports a violation of
{{NoFalseSuccess}} (3 states: Send, Handle: callback without exception) and of
{{CompletedOnce}} (4 states: then CallerGet: callback again) for an error
status and for an unreadable body; a 2xx response and a request without a
response hold. The model is a sketch of the code, not a proof of the fix; the
same outcome matrix was then reproduced on the real producer with the test
above.
Affected: main (c3829069cb72; its two classes were tested), camel-4.23.x
(197e70656a41: the same code at the same lines, {{TelegramAsyncHandler}}
108-117 and {{TelegramMessageHandler}} 70-77; read, not run), camel-4.22.x and
camel-4.18.x (the same two lines; read, not run). camel-4.14.x has a different
class layout and was not checked.
Duplicate check (2026-10-12): JIRA text "TelegramAsyncHandler" (none),
"telegram producer callback" (none), "telegram ExecutionException" (none),
"telegram responded" (11, none about the completion of the exchange; closest
CAMEL-13903 and CAMEL-18506, the two HTTP client changes); GitHub pull requests
"telegram producer" in title and "TelegramAsyncHandler": none.
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)