Error Handling - protobuf message to DLT instead of Retry in case of connection exception - spring-boot

I've implemented an KafkaListener using SpringBoot 2.7.6, Confluent platform and now I need to implement the Error Handler for it.
The listener manages to pick up a protobuf topic message and POST the payload to an HTTP endpoint properly. But in case of the java.net.ConnectException happens I need to send the same protobuf message to a DLT instead of Retry.
I implemented this using the following Listener:
#Component
class ConsumerListener(
private val apiPathsConfig: ApiPathsConfig,
private val myHttpClient: MyHttpClient,
#Value("\${ingestion.config.httpClientTimeOutInSeconds}") private val httpRequestTimeout: Long
) {
val log: Logger = LoggerFactory.getLogger(ConsumerListener::class.java)
#RetryableTopic(
attempts = "4",
backoff = Backoff(delay = 5000, multiplier = 2.0), //TODO: env var?
autoCreateTopics = "false",
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
timeout = "3000", //TODO: env var?
dltStrategy = DltStrategy.FAIL_ON_ERROR
)
#KafkaListener(
id = "ingestionConsumerListener",
topics = ["#{'\${ingestion.config.topic.name}'}"],
groupId = "#{'\${ingestion.consumer.group.id}'}",
concurrency = "#{'\${ingestion.config.consumer.concurrency}'}"
)
fun consume(ingestionHttpRequest: ConsumerRecord<String, HttpRequest.HttpRequest>) {
...
try {
val response: HttpResponse<Void> = myHttpClient.send(request, HttpResponse.BodyHandlers.discarding())
if (response.statusCode() in 400..520) {
val ingestionResponseError = "Ingestion response status code [${response.statusCode()}] - headers [${response.headers()}] - body [${response.body()}]"
log.error(ingestionResponseError)
throw RuntimeException(ingestionResponseError)
}
} catch (e: IOException) {
log.error("IOException stackTrace : ${e.printStackTrace()}")
throw RuntimeException(e.stackTrace.contentToString())
} catch (e: InterruptedException) {
log.error("InterruptedException stackTrace : ${e.printStackTrace()}")
throw RuntimeException(e.stackTrace.contentToString())
} catch (e: IllegalArgumentException) {
log.error("IllegalArgumentException stackTrace : ${e.printStackTrace()}")
throw RuntimeException(e.stackTrace.contentToString())
}
}
...
}
When the java.net.ConnectException happens the DeadLetterPublishingRecoverFactory show this:
15:19:44.546 [kafka-producer-network-thread | producer-1] INFO org.apache.kafka.clients.producer.internals.TransactionManager - [Producer clientId=producer-1] ProducerId set to 3330155 with epoch 0
15:19:44.547 [ingestionConsumerListener-2-C-1] ERROR org.springframework.kafka.retrytopic.DeadLetterPublishingRecovererFactory$1 - Dead-letter publication to ingestion-topic-retry-0failed for: ingestion-topic-5#32
org.apache.kafka.common.errors.SerializationException: Can't convert value of class com.xxx.ingestion.IngestionHttpRequest$HttpRequest to class org.apache.kafka.common.serialization.StringSerializer specified in value.serializer
...
Caused by: java.lang.ClassCastException: class com.xxx.ingestion.IngestionHttpRequest$HttpRequest cannot be cast to class java.lang.String (com.xxx.ingestion.IngestionHttpRequest$HttpRequest is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')
at org.apache.kafka.common.serialization.StringSerializer.serialize(StringSerializer.java:29)
Please, how to resend the protobuf message to a DLT instead of Retry in case of ConnectionException and how to keep the Retry in case of the the HTTP endpoint response 4xx or 5xx code?
Please user:2756547

You either need a protobuf serializer to re-serialize the data, or don't deserialize the prototbuf in a deserializer, use a ByteArrayDeserializer and convert it in your listener instead; then use a ByteArraySerializer.
You can also configure certain exception types to go straight to the DLT.
EDIT
See https://docs.spring.io/spring-kafka/docs/current/reference/html/#retry-topic-global-settings
#Configuration
public class MyRetryTopicConfiguration extends RetryTopicConfigurationSupport {
#Override
protected void manageNonBlockingFatalExceptions(List<Class<? extends Throwable>> nonBlockingFatalExceptions) {
nonBlockingFatalExceptions.add(MyNonBlockingException.class);
}
}
/**
* Override this method to manage non-blocking retries fatal exceptions.
* Records which processing throws an exception present in this list will be
* forwarded directly to the DLT, if one is configured, or stop being processed
* otherwise.
* #param nonBlockingRetriesExceptions a {#link List} of fatal exceptions
* containing the framework defaults.
*/
protected void manageNonBlockingFatalExceptions(List<Class<? extends Throwable>> nonBlockingRetriesExceptions) {
}

Related

Camel no error handler invoked on exception with multipleConsumer=true and POJO #Consume Annotation

I've been trying to figure out how route errors to my own error handler with the following, seemingly simple configuration, but Camel is swallowing the exception without routing it to any error handler I configure. I've run out of ideas. Any help would be much appreciated.
I've got a seda route that supports multiple consumers:
#Component
public class MessageGenerator {
public static final String ERROR_GENERATOR_CHANNEL = "seda:my-error-generator?multipleConsumers=true&concurrentConsumers=3";
private final FluentProducerTemplate producerTemplate;
public MessageGenerator(FluentProducerTemplate producerTemplate) {
this.producerTemplate = producerTemplate;
}
public void generateMessage() {
producerTemplate
.to(ERROR_GENERATOR_CHANNEL)
.withBody("Hello World")
.asyncSend();
}
}
I've got two separate POJO consumers:
#Configuration
public class MessageConsumer1 {
#Consume(ERROR_GENERATOR_CHANNEL)
void receiveMessage(String message) {
System.out.println("Received message 1: " + message);
throw new NullPointerException("Error generated");
}
}
#Configuration
public class MessageConsumer2 {
#Consume(ERROR_GENERATOR_CHANNEL)
void receiveMessage(String message) {
System.out.println("Received message 2: " + message);
}
}
When I run the following example, the NullPointerException gets swallowed by the underlying Camel MulticastProcessor as we can see in the logs:
Received message 2: Hello World
Received message 1: Hello World
2022-01-15 13:40:23.711 DEBUG 32945 --- [error-generator] o.a.camel.processor.MulticastProcessor : Message exchange has failed: Multicast processing failed for number 0 for exchange: Exchange[] Exception: java.lang.NullPointerException: Error generated
2022-01-15 13:40:23.711 DEBUG 32945 --- [error-generator] o.a.camel.processor.MulticastProcessor : Message exchange has failed: Multicast processing failed for number 0 for exchange: Exchange[] Exception: java.lang.NullPointerException: Error generated
The exception only gets logged as debug and never gets propagated to any error handler I set up.
Any thoughts on how I could receive the error in my own error handler rather than Camel swallowing the exception as a debug statement?
Note1: I've attempted many variations on both default error handling and default dead letter handling to no avail. I could just be doing it wrong...
Note2: that I'm using Spring[Boot] here too, hence the #Configuration annotation.
Note1: I've attempted many variations on both default error handling and default dead letter handling to no avail. I could just be doing it wrong...
Haven't used #Consume annotations but generally if you want Camel route not to handle any errors you can use .errorHandler(noErrorHandler()). This can be used to pass the error back to parent route or all the way to the code calling ProducerTemplate.sendBody.
Example:
public class ExampleTest extends CamelTestSupport {
#Test
public void noErrorHandlerTest() {
try {
template.sendBody("direct:noErrorHandler", null);
fail();
} catch (Exception e) {
System.out.println("Caught Exception: " + e.getMessage());
}
}
#Override
protected RoutesBuilder createRouteBuilder() throws Exception {
return new RouteBuilder(){
#Override
public void configure() throws Exception {
from("direct:noErrorHandler")
.errorHandler(noErrorHandler())
.log("Throwing error")
.throwException(Exception.class, "Test Exception");
}
};
}
}

How to know which exception is thrown from errorhandler in dead letter queue listener?

I have a quorum queue (myQueue) and it's dead letter queue (myDLQueue). We have several exceptions which we separated as Retryable or Fatal. But sometimes in below listener we make an api call that throws RateLimitException. In this case the application should increase both of retry count and retry delay.
#RabbitListener(queues = "#{myQueue.getName()}", errorHandler = "myErrorHandler")
#SendTo("#{myStatusQueue.getName()}")
public Status process(#Payload MyMessage message, #Headers MessageHeaders headers) {
int retries = headerProcessor.getRetries(headers);
if (retries > properties.getMyQueueMaxRetries()) {
throw new RetriesExceededException(retries);
}
if (retries > 0) {
logger.info("Message {} has been retried {} times. Process it again anyway", kv("task_id", message.getTaskId()), retries);
}
// here we send a request to an api. but sometimes api returns rate limit error in case we send too many requests.
// In that case makeApiCall throws RateLimitException which extends RetryableException
makeApiCall() // --> it will throw RateLimitException
if(/* a condition that needs to retry sending the message*/) {
throw new RetryableException()
}
if(/* a condition that should not retry*/){
throw new FatalException()
}
return new Status("Step 1 Success!");
}
I have also an error handler (myErrorHandler) that catches thrown exceptions from above rabbit listener and manages retry process according to the type of the exception.
public class MyErrorHandler implements RabbitListenerErrorHandler {
#Override
public Object handleError(Message amqpMessage,
org.springframework.messaging.Message<?> message,
ListenerExecutionFailedException exception) {
// Check if error is fatal or retryable
if (exception.getCause() /* ..is fatal? */) {
return new Status("FAIL!");
}
// Retryable exception, rethrow it and let message to be NACKed and retried via DLQ
throw exception;
}
}
Last part I have is a DLQHandler that listens dead letter queue messages and send them to original queue (myQueue).
#Service
public class MyDLQueueHandler {
private final MyAppProperties properties;
private final MessageHeaderProcessor headerProcessor;
private final RabbitProducerService rabbitProducerService;
public MyDLQueueHandler(MyProperties properties, MessageHeaderProcessor headerProcessor, RabbitProducerService rabbitProducerService) {
this.properties = properties;
this.headerProcessor = headerProcessor;
this.rabbitProducerService = rabbitProducerService;
}
/**
* Since message TTL is not available with quorum queues manually listen DL Queue and re-send the message with delay.
* This allows messages to be processed again.
*/
#RabbitListener(queues = {"#{myDLQueue.getName()}"}"})
public void handleError(#Payload Object message, #Headers MessageHeaders headers) {
String routingKey = headerProcessor.getRoutingKey(headers);
Map<String, Object> newHeaders = Map.of(
MessageHeaderProcessor.DELAY, properties.getRetryDelay(), // I need to send increased delay in case of RateLimitException.
MessageHeaderProcessor.RETRIES_HEADER, headerProcessor.getRetries(headers) + 1
);
rabbitProducerService.sendMessageDelayed(message, routingKey, newHeaders);
}
}
In the above handleError method inputs there is not any information related to exception instance thrown from MyErrorHandler or MyQueue listener. Currently I have to pass retry delay by reading it from app.properties. But I need to increase this delay if RateLimitException is thrown. So my question is how do I know which error is thrown from MyErrorHandler while in the MyDLQueueHandler?
When you use the normal dead letter mechanism in RabbitMQ, there is no exception information provided - the message is the original rejected message. However, Spring AMQP provides a RepublishMessageRecoverer which can be used in conjunction with a retry interceptor. In that case, exception information is published in headers.
See https://docs.spring.io/spring-amqp/docs/current/reference/html/#async-listeners
The RepublishMessageRecoverer publishes the message with additional information in message headers, such as the exception message, stack trace, original exchange, and routing key. Additional headers can be added by creating a subclass and overriding additionalHeaders().
#Bean
RetryOperationsInterceptor interceptor() {
return RetryInterceptorBuilder.stateless()
.maxAttempts(5)
.recoverer(new RepublishMessageRecoverer(amqpTemplate(), "something", "somethingelse"))
.build();
}
The interceptor is added to the container's advice chain.
https://github.com/spring-projects/spring-amqp/blob/57596c6a26be2697273cd97912049b92e81d3f1a/spring-rabbit/src/main/java/org/springframework/amqp/rabbit/retry/RepublishMessageRecoverer.java#L55-L61
public static final String X_EXCEPTION_STACKTRACE = "x-exception-stacktrace";
public static final String X_EXCEPTION_MESSAGE = "x-exception-message";
public static final String X_ORIGINAL_EXCHANGE = "x-original-exchange";
public static final String X_ORIGINAL_ROUTING_KEY = "x-original-routingKey";
The exception type can be found in the stack trace header.

How to detect topic does not exist within Spring when using #KafkaListener

When trying to subscribe to non-existing topic with #KafkaListener, it logs a warning:
2021-04-22 13:03:56.710 WARN 20188 --- [ntainer#0-0-C-1] org.apache.kafka.clients.NetworkClient : [Consumer clientId=consumer-gg-2, groupId=gg] Error while fetching metadata with correlation id 174 : {not_exist=UNKNOWN_TOPIC_OR_PARTITION}
How to detect and handle this? I tried errorHandler, it isn't got called:
#KafkaListener(topics = "not_exist", groupId = "gg", errorHandler = "onError")
public void receive(String m) {
log.info("Rcd: " + m);
}
...
#Bean
public KafkaListenerErrorHandler onError() {
return new KafkaListenerErrorHandler() {
#Override
public Object handleError(Message<?> message, ListenerExecutionFailedException e) {
log.error("handleError Error: {} message: {}", e.toString(), message);
return message;
}
};
}
I think you can find the answer in org/springframework/kafka/listener/KafkaListenerErrorHandler.java
* #return the return value is ignored unless the annotated method has a {#code #SendTo} annotation.

Set maximum AMQP client attempts

I have a case where I might have Java NPE into the listener which accepts queue payload. I get multiple attempts and errors:
18:41:50.549 [processingeContainer-1] WARN o.s.a.r.l.ConditionalRejectingErrorHandler - Execution of Rabbit message listener failed.
2019-09-24 18:41:50,551 INFO [stdout] (processingContainer-1) org.springframework.amqp.rabbit.listener.exception.ListenerExecutionFailedException: Listener method 'transactionProcess' threw exception
Is there some way to limit the AMQP client attempts?
You should really fix the NPE but you can configure the listener container error handler.
The default ConditionalRejectingErrorHandler treats certain exceptions as fatal.
It uses a DefaultExceptionStrategy which has the following code:
private boolean isCauseFatal(Throwable cause) {
return cause instanceof MessageConversionException // NOSONAR boolean complexity
|| cause instanceof org.springframework.messaging.converter.MessageConversionException
|| cause instanceof MethodArgumentResolutionException
|| cause instanceof NoSuchMethodException
|| cause instanceof ClassCastException
|| isUserCauseFatal(cause);
}
/**
* Subclasses can override this to add custom exceptions.
* #param cause the cause
* #return true if the cause is fatal.
*/
protected boolean isUserCauseFatal(Throwable cause) {
return false;
}
So, configure your own ConditionalRejectingErrorHandler with a subclass of DefaultExceptionStrategy which overrides isUserCauseFatal() return true for NullPointerException.
You would then inject your error hander into the listener container or listener container factory.
Another technique would be to add a retry interceptor; by default, the error is just logged after the retries are exhausted. With spring boot, the default recoverer is a RejectAndDontRequeueRecoverer.
EDIT
I just tested it and it worked fine...
#SpringBootApplication
public class So58087354Application {
public static void main(String[] args) {
SpringApplication.run(So58087354Application.class, args);
}
#RabbitListener(queues = "foo")
public void listen(String in) {
System.out.println("here");
throw new NullPointerException("Test");
}
}
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.initial-interval=1000ms
spring.rabbitmq.listener.simple.retry.max-attempts=2
and
here
here
2019-10-01 09:07:11.936 WARN 75435 --- [ntContainer#0-1] o.s.a.r.r.RejectAndDontRequeueRecoverer : Retries exhausted for message (Body:'[B#6d890bbc(byte[3])' MessageProperties [headers={}, contentLength=0, receivedDeliveryMode=NON_PERSISTENT, redelivered=false, receivedExchange=, receivedRoutingKey=foo, deliveryTag=1, consumerTag=amq.ctag-mwYtmPtBplrefsOa05hG0w, consumerQueue=foo])
...
2019-10-01 09:07:11.937 WARN 75435 --- [ntContainer#0-1] s.a.r.l.ConditionalRejectingErrorHandler : Execution of Rabbit message listener failed.
...
Caused by: org.springframework.amqp.AmqpRejectAndDontRequeueException: null
... 19 common frames omitted
EDIT2
To add a retry advice to the container factory manually...
#Component
class ContainerRetryConfigurer {
ContainerRetryConfigurer(AbstractRabbitListenerContainerFactory<?> factory) {
factory.setAdviceChain(RetryInterceptorBuilder.stateless()
.maxAttempts(2)
.backOffOptions(1000, 1.0, 1000)
.build());
}
}

Why spring-cloud-stream not populating JMSMessageID while publishing messages to Solace topics?

Summarization of the problem
In my project, we are trying to use Spring-Cloud-Stream (SCS) to connect to Solace. Eventually we plan to move to Kafka. So using SCS will help us move over to Kafka quite easily without any code changes and very minimal configuration & dependency changes.
We had been using Solace for a while using JMS. Now when we tried to publish messages to Solace using SCS, we observed that in the message, some crucial JMS Headers (JMSMessageID, JMSType, JMSPriority,JMSCorrelationID, JMSExpiration) are blank.
Do we need to configure the JMS headers separately ? If yes, how ?
What I've already tried
I tried to set headers like this, but this is just resulting in duplicate headers with the same name.
#Output(SendReport.TO_NMR)
public void sendMessage(String request) {
log.info("****************** Got this Report Request: " + request);
MessageBuilder<String> builder = MessageBuilder.withPayload(request);
builder.setHeader("JMSType","report-request");
builder.setHeader("JMSMessageId","1");
builder.setHeader("JMSCorrelationId","11");
builder.setHeader("JMSMessageID","4");
builder.setHeader("JMSCorrelationID","114");
builder.setHeader("ApplicationMessageId","111");
builder.setHeader("ApplicationMessageID","112");
builder.setCorrelationId("23434");
Message message = builder.build();
sendReport.output().send(message);
}
JMS Header of the message in Solace looks like this
JMSMessageID
JMSDestination TOPIC_NAME
JMSTimestamp Wed Dec 31 18:00:00 CST 1969
JMSType
JMSReplyTo
JMSCorrelationID
JMSExpiration 0
JMSPriority 0
JMSType nmr-report-request
JMSMessageId 1
JMSMessageID 4
_isJavaSerializedObject-contentType true
_isJavaSerializedObject-id true
solaceSpringCloudStreamBinderVersion 0.1.0
ApplicationMessageId 111
ApplicationMessageID 112
JMSCorrelationId 11
JMSCorrelationID 114
correlationId 23434
id [-84,-19,0,5,115,114,0,14,106,97,118,97,46,117,116,105,108,46,85,85,73,68,-68,-103,3,-9,-104,109,-123,47,2,0,2,74,0,12,108,101,97,115,116,83,105,103,66,105,116,115,74,0,11,109,111,115,116,83,105,103,66,105,116,115,120,112,13,-26,2,-51,111,-17,73,73,-18,-32,-26,-11,-46,-89,50,-37] (offset=377, length=80)
contentType [-84,-19,0,5,115,114,0,33,111,114,103,46,115,112,114,105,110,103,102,114,97,109,101,119,111,114,107,46,117,116,105,108,46,77,105,109,101,84,121,112,101,56,-76,29,-63,64,96,-36,-81,2,0,3,76,0,10,112,97,114,97,109,101,116,101,114,115,116,0,15,76,106,97,118,97,47,117,116,105,108,47,77,97,112,59,76,0,7,115,117,98,116,121,112,101,116,0,18,76,106,97,118] (offset=473, length=190)
timestamp 1555707627482
Code used to connect to Solace
Spring Boot Main Class
#SpringBootApplication
#EnableDiscoveryClient
#Slf4j
#EnableBinding({SendReport.class})
public class ReportServerApplication {
public static void main(final String[] args) {
ApplicationContext ctx = new ClassPathXmlApplicationContext("applicationContext-server.xml");
new SpringApplicationBuilder(ReportServerApplication.class).listeners(new EnvironmentPreparedListener()) .run(args);
}
Class to connect channel to topic:
public interface SendReport {
String TO_NMR = "solace-poc-outbound";
#Output(SendReport.TO_NMR)
MessageChannel output();
}
Message Handler:
#Slf4j
#Component
#EnableBinding({SendReport.class})
public class MessageHandler {
private SendReport sendReport;
public MessageHandler(SendReport sendReport){
this.sendReport = sendReport;
}
#Output(SendReport.TO_NMR)
public void sendMessage(String request) {
log.info("****************** Got this Report Request: " + request);
var message = MessageBuilder.withPayload(request).build();
sendReport.output().send(message);
}
}
Properties used for configuration : application.yml
spring:
cloud:
# spring cloud stream binding
stream:
bindings:
solace-poc-outbound:
destination: TOPIC_NAME
contentType: text/plain
solace:
java:
host: tcp://xyz.abc.com
#port: xxx
msgVpn: yyy
clientUsername: aaa
Dependencies used:
'org.springframework.cloud:spring-cloud-stream',
'com.solace.spring.cloud:spring-cloud-starter-stream-solace:1.1.+'
Observation
Expected result : All JMS headers should get populated by SCS.
Actual result : Some JMS headers are not getting populated.
See JMS Message JavaDocs:
/** Sets the message ID.
*
* <P>This method is for use by JMS providers only to set this field
* when a message is sent. This message cannot be used by clients
* to configure the message ID. This method is public
* to allow a JMS provider to set this field when sending a message
* whose implementation is not its own.
*
* #param id the ID of the message
*
* #exception JMSException if the JMS provider fails to set the message ID
* due to some internal error.
*
* #see javax.jms.Message#getJMSMessageID()
*/
void
setJMSMessageID(String id) throws JMSException;
So, this property cannot be populated from the application level.
In the ActiveMQ I see the code like this:
msg.setMessageId(new MessageId(producer.getProducerInfo().getProducerId(), sequenceNumber));
// Set the message id.
if (msg != message) {
message.setJMSMessageID(msg.getMessageId().toString());
But still: it is not what we can control from the application level.
The priority, deliveryMode and timeToLive ca be populated from the JmsSendingMessageHandler:
if (this.jmsTemplate instanceof DynamicJmsTemplate && this.jmsTemplate.isExplicitQosEnabled()) {
Integer priority = StaticMessageHeaderAccessor.getPriority(message);
if (priority != null) {
DynamicJmsTemplateProperties.setPriority(priority);
}
if (this.deliveryModeExpression != null) {
Integer deliveryMode =
this.deliveryModeExpression.getValue(this.evaluationContext, message, Integer.class);
if (deliveryMode != null) {
DynamicJmsTemplateProperties.setDeliveryMode(deliveryMode);
}
}
if (this.timeToLiveExpression != null) {
Long timeToLive = this.timeToLiveExpression.getValue(this.evaluationContext, message, Long.class);
if (timeToLive != null) {
DynamicJmsTemplateProperties.setTimeToLive(timeToLive);
}
}
}
The JmsCorrelationID must be populated by the JmsHeaders.CORRELATION_ID. The JmsType by the JmsHeaders.TYPE, respectively:
public void fromHeaders(MessageHeaders headers, javax.jms.Message jmsMessage) {
try {
Object jmsCorrelationId = headers.get(JmsHeaders.CORRELATION_ID);
if (jmsCorrelationId instanceof Number) {
jmsCorrelationId = jmsCorrelationId.toString();
}
if (jmsCorrelationId instanceof String) {
try {
jmsMessage.setJMSCorrelationID((String) jmsCorrelationId);
}
catch (Exception e) {
this.logger.info("failed to set JMSCorrelationID, skipping", e);
}
}
Object jmsReplyTo = headers.get(JmsHeaders.REPLY_TO);
if (jmsReplyTo instanceof Destination) {
try {
jmsMessage.setJMSReplyTo((Destination) jmsReplyTo);
}
catch (Exception e) {
this.logger.info("failed to set JMSReplyTo, skipping", e);
}
}
Object jmsType = headers.get(JmsHeaders.TYPE);
if (jmsType instanceof String) {
try {
jmsMessage.setJMSType((String) jmsType);
}
catch (Exception e) {
this.logger.info("failed to set JMSType, skipping", e);
}
}
See DefaultJmsHeaderMapper for more info.

Resources