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 04cf4bd8a628 CAMEL-25660: camel-azure-servicebus - bound the
dead-letter reason and description, and report a rejected request (#27834)
04cf4bd8a628 is described below
commit 04cf4bd8a6282237fb4bace1bb9143982ce5b1bc
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 15 16:38:32 2026 +0200
CAMEL-25660: camel-azure-servicebus - bound the dead-letter reason and
description, and report a rejected request (#27834)
Co-Authored-By: Claude Opus 5 <[email protected]>
---
.../azure/servicebus/ServiceBusConsumer.java | 67 ++++++++++++---
.../azure/servicebus/ServiceBusConsumerTest.java | 97 ++++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 ++
3 files changed, 163 insertions(+), 10 deletions(-)
diff --git
a/components/camel-azure/camel-azure-servicebus/src/main/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumer.java
b/components/camel-azure/camel-azure-servicebus/src/main/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumer.java
index 2bfd5ec6f374..ca9d65d9f710 100644
---
a/components/camel-azure/camel-azure-servicebus/src/main/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumer.java
+++
b/components/camel-azure/camel-azure-servicebus/src/main/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumer.java
@@ -39,6 +39,7 @@ import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.Processor;
import org.apache.camel.ShutdownRunningTask;
+import org.apache.camel.spi.ExceptionHandler;
import org.apache.camel.spi.HeaderFilterStrategy;
import org.apache.camel.spi.PeriodTaskScheduler;
import org.apache.camel.spi.ShutdownAware;
@@ -46,6 +47,7 @@ import org.apache.camel.support.DefaultConsumer;
import org.apache.camel.support.PluginHelper;
import org.apache.camel.support.SynchronizationAdapter;
import org.apache.camel.util.ObjectHelper;
+import org.apache.camel.util.StringHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -53,6 +55,15 @@ public class ServiceBusConsumer extends DefaultConsumer
implements ShutdownAware
private static final Logger LOG =
LoggerFactory.getLogger(ServiceBusConsumer.class);
private static final int LOCK_RENEW_INTERVAL_SECONDS = 10;
+ // Azure recommends putting the exception type in the dead-letter reason
and its stack trace in the description,
+ // but both are length-limited: the .NET and JavaScript SDKs cap them at
4096 (MaxDeadLetterReasonLength, applied
+ // to the description as well), and Azure separately warns that a stack
trace here can push a message past the
+ // 256 KB quota of the Standard tier. The Java SDK does not check, so an
over-long value only fails at the broker -
+ // which leaves the message locked. Cap it here instead: the head of a
stack trace carries the cause, the tail is
+ // error-handler frames.
+ private static final int MAX_DEAD_LETTER_TEXT_LENGTH = 4096;
+ // so that whoever reads the dead-letter queue can see the text was cut
rather than the trace having ended
+ private static final String TRUNCATED_SUFFIX = "...";
private ServiceBusProcessorClient client;
private boolean clientCloseable;
@@ -385,25 +396,61 @@ public class ServiceBusConsumer extends DefaultConsumer
implements ShutdownAware
if (getConfiguration().getServiceBusReceiveMode() ==
ServiceBusReceiveMode.PEEK_LOCK) {
if (getConfiguration().isEnableDeadLettering() &&
(ObjectHelper.isEmpty(getConfiguration().getSubQueue())
||
ObjectHelper.equal(getConfiguration().getSubQueue(), SubQueue.NONE))) {
- DeadLetterOptions deadLetterOptions = new
DeadLetterOptions();
if (cause != null) {
- deadLetterOptions
- .setDeadLetterReason(
- String.format("%s: %s",
cause.getClass().getName(), cause.getMessage()));
-
deadLetterOptions.setDeadLetterErrorDescription(Arrays.stream(cause.getStackTrace())
- .map(StackTraceElement::toString)
- .collect(Collectors.joining("\n")));
- messageContext.deadLetter(deadLetterOptions);
+ DeadLetterOptions options =
deadLetterOptions(cause);
+ settle(exchange, "dead-letter", () ->
messageContext.deadLetter(options));
} else {
- messageContext.deadLetter();
+ settle(exchange, "dead-letter",
messageContext::deadLetter);
}
} else {
- messageContext.abandon();
+ settle(exchange, "abandon", messageContext::abandon);
}
}
} finally {
pendingExchanges.decrementAndGet();
}
}
+
+ /**
+ * Describes the failure for the dead-letter queue, bounded so that
the diagnostics cannot be what makes the
+ * broker reject the message.
+ */
+ private DeadLetterOptions deadLetterOptions(Exception cause) {
+ String stackTrace = Arrays.stream(cause.getStackTrace())
+ .map(StackTraceElement::toString)
+ .collect(Collectors.joining("\n"));
+
+ return new DeadLetterOptions()
+ .setDeadLetterReason(
+ limitToDeadLetterLength(String.format("%s: %s",
cause.getClass().getName(),
+ cause.getMessage())))
+
.setDeadLetterErrorDescription(limitToDeadLetterLength(stackTrace));
+ }
+
+ /**
+ * Caps a dead-letter field, leaving room for the suffix so the result
still fits.
+ */
+ private String limitToDeadLetterLength(String value) {
+ return StringHelper.limitLength(value, MAX_DEAD_LETTER_TEXT_LENGTH
- TRUNCATED_SUFFIX.length(),
+ TRUNCATED_SUFFIX);
+ }
+
+ /**
+ * Settles the message, reporting a rejected request through the
consumer's {@link ExceptionHandler}.
+ * <p/>
+ * Without this the exception only reaches {@code
UnitOfWorkHelper.doneSynchronization}, which logs it as
+ * "Exception occurred during onCompletion. This exception will be
ignored." - so the operator sees a generic
+ * core warning rather than a message from this consumer, and nothing
routable. Either way the message is not
+ * settled: it stays locked until the lock expires, Service Bus
redelivers it, and it ends up dead-lettered with
+ * {@code MaxDeliveryCountExceeded} instead of the reason collected
here.
+ */
+ private void settle(Exchange exchange, String operation, Runnable
settlement) {
+ try {
+ settlement.run();
+ } catch (Exception e) {
+ getExceptionHandler().handleException(
+ "Cannot " + operation + " the message. It stays locked
and will be redelivered.", exchange, e);
+ }
+ }
}
}
diff --git
a/components/camel-azure/camel-azure-servicebus/src/test/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumerTest.java
b/components/camel-azure/camel-azure-servicebus/src/test/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumerTest.java
index d63ee50844e1..d490e0ff2e67 100644
---
a/components/camel-azure/camel-azure-servicebus/src/test/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumerTest.java
+++
b/components/camel-azure/camel-azure-servicebus/src/test/java/org/apache/camel/component/azure/servicebus/ServiceBusConsumerTest.java
@@ -385,6 +385,103 @@ public class ServiceBusConsumerTest {
}
}
+ @Test
+ void synchronizationBoundsTheDeadLetterReasonAndDescription() throws
Exception {
+ try (ServiceBusConsumer consumer = new ServiceBusConsumer(endpoint,
processor)) {
+
when(configuration.getServiceBusReceiveMode()).thenReturn(ServiceBusReceiveMode.PEEK_LOCK);
+ when(configuration.isEnableDeadLettering()).thenReturn(true);
+
+ consumer.doStart();
+ when(messageContext.getMessage()).thenReturn(message);
+ processMessageCaptor.getValue().accept(messageContext);
+
+ Exchange exchange = exchangeCaptor.getValue();
+
exchange.setException(exceptionWithALongMessageAndADeepStackTrace());
+
+ Synchronization synchronization =
exchange.getExchangeExtension().handoverCompletions().get(0);
+ synchronization.onFailure(exchange);
+
+ ArgumentCaptor<DeadLetterOptions> captor = ArgumentCaptor.captor();
+ verify(messageContext).deadLetter(captor.capture());
+ DeadLetterOptions options = captor.getValue();
+
+ // the .NET and JavaScript SDKs cap both fields at 4096
(MaxDeadLetterReasonLength, applied to the
+ // description too); the Java SDK does not check, so an over-long
value would only fail at the broker
+ assertThat(options.getDeadLetterReason())
+ .hasSizeLessThanOrEqualTo(4096)
+ .startsWith(IllegalStateException.class.getName())
+ .endsWith("...");
+ assertThat(options.getDeadLetterErrorDescription())
+ .hasSizeLessThanOrEqualTo(4096)
+ .contains(getClass().getName())
+ .endsWith("...");
+ }
+ }
+
+ @Test
+ void synchronizationReportsARejectedDeadLetterInsteadOfThrowing() throws
Exception {
+ try (ServiceBusConsumer consumer = new ServiceBusConsumer(endpoint,
processor)) {
+
when(configuration.getServiceBusReceiveMode()).thenReturn(ServiceBusReceiveMode.PEEK_LOCK);
+ when(configuration.isEnableDeadLettering()).thenReturn(true);
+ consumer.setExceptionHandler(exceptionHandler);
+
+ consumer.doStart();
+ when(messageContext.getMessage()).thenReturn(message);
+ processMessageCaptor.getValue().accept(messageContext);
+
+ Exchange exchange = exchangeCaptor.getValue();
+ final Exception routeFailure = new Exception("Test exception");
+ exchange.setException(routeFailure);
+
+ // the broker refuses the request, for instance because the
message is over the size quota
+ final RuntimeException rejected = new
IllegalStateException("Dead-lettering rejected");
+
doThrow(rejected).when(messageContext).deadLetter(any(DeadLetterOptions.class));
+
+ Synchronization synchronization =
exchange.getExchangeExtension().handoverCompletions().get(0);
+ // must not escape into the SDK's processor callback, where
nothing would report it
+ synchronization.onFailure(exchange);
+
+ verify(exceptionHandler).handleException(anyString(),
eq(exchange), eq(rejected));
+ }
+ }
+
+ @Test
+ void synchronizationReportsARejectedAbandonInsteadOfThrowing() throws
Exception {
+ try (ServiceBusConsumer consumer = new ServiceBusConsumer(endpoint,
processor)) {
+
when(configuration.getServiceBusReceiveMode()).thenReturn(ServiceBusReceiveMode.PEEK_LOCK);
+ // dead-lettering off, so a failed exchange is abandoned instead
+ when(configuration.isEnableDeadLettering()).thenReturn(false);
+ consumer.setExceptionHandler(exceptionHandler);
+
+ consumer.doStart();
+ when(messageContext.getMessage()).thenReturn(message);
+ processMessageCaptor.getValue().accept(messageContext);
+
+ Exchange exchange = exchangeCaptor.getValue();
+ exchange.setException(new Exception("Test exception"));
+
+ final RuntimeException rejected = new
IllegalStateException("Abandon rejected");
+ doThrow(rejected).when(messageContext).abandon();
+
+ Synchronization synchronization =
exchange.getExchangeExtension().handoverCompletions().get(0);
+ synchronization.onFailure(exchange);
+
+ verify(exceptionHandler).handleException(anyString(),
eq(exchange), eq(rejected));
+ }
+ }
+
+ private static Exception exceptionWithALongMessageAndADeepStackTrace() {
+ Exception answer = new IllegalStateException("x".repeat(8192));
+ StackTraceElement[] frames = new StackTraceElement[512];
+ for (int i = 0; i < frames.length; i++) {
+ frames[i] = new StackTraceElement(
+ ServiceBusConsumerTest.class.getName(), "frame" + i,
+ "ServiceBusConsumerTest.java", i);
+ }
+ answer.setStackTrace(frames);
+ return answer;
+ }
+
@Test
void
synchronizationDeadLettersMessageWithoutOptionsWhenExceptionNotPresent() throws
Exception {
try (ServiceBusConsumer consumer = new ServiceBusConsumer(endpoint,
processor)) {
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 45283da3e7cd..ebcf624762ff 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -734,6 +734,15 @@ go through one of those paths, remove the `{` and `}`
characters from it in the
endpoint (Chunk has no escape for tags inside data), for example with a
processor that rewrites the body or
header.
+=== camel-azure-servicebus - the dead-letter reason and description are bounded
+
+When the consumer dead-letters a message after a failed route with
`enableDeadLettering=true`, the reason and the
+error description it writes are now capped at 4096 characters, the limit the
Azure SDKs apply to both fields. A value
+that was cut ends with `...`. The description holds the stack trace, so a deep
one is now truncated rather than sent
+in full and rejected by the broker.
+
+A settlement request the broker rejects is now reported through the consumer's
exception handler.
+
=== camel-telegram - a failed send fails the exchange once
When Telegram answers a message with an error status (for example `400` for an
unknown chat), or with a body that