package com.ankurm.rabbit; import org.junit.jupiter.api.Test; import org.springframework.amqp.AmqpIOException; import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.QueueBuilder; import org.springframework.amqp.core.ReturnedMessage; import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.CopyOnWriteArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** * Two ways a RabbitMQ topology loses your work quietly. * * @see docs/03-the-silent-drop.md * @see docs/06-changing-your-mind.md */ @SpringBootTest class TopologyTrapsTest { @Autowired RabbitTemplate template; @Autowired RabbitAdmin admin; @Test void anUnroutableMessageIsDiscardedUnlessYouAskForItBack() throws Exception { List returned = new CopyOnWriteArrayList<>(); this.template.setReturnsCallback(returned::add); // "amend" matches no binding on orders.direct. The broker has nowhere to put it. this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-1")); for (int i = 0; i < 100 && returned.isEmpty(); i++) { Thread.sleep(20); } // spring.rabbitmq.template.mandatory=true is what turns a silent discard into a return. assertThat(this.template.isMandatoryFor(null)).isTrue(); assertThat(returned).hasSize(1); ReturnedMessage message = returned.get(0); System.out.println("=== returned message ==="); System.out.println(" replyCode " + message.getReplyCode()); System.out.println(" replyText " + message.getReplyText()); System.out.println(" exchange " + message.getExchange()); System.out.println(" routingKey " + message.getRoutingKey()); assertThat(message.getReplyCode()).isEqualTo(312); assertThat(message.getReplyText()).isEqualTo("NO_ROUTE"); assertThat(message.getRoutingKey()).isEqualTo("amend"); // Note what did NOT happen: convertAndSend returned normally. Publishing is fire and // forget at the protocol level, so even with mandatory=true the failure arrives // asynchronously on another thread. Nothing throws. } @Test void redeclaringAQueueWithDifferentArgumentsIsAPreconditionFailure() { // orders.ttl already exists with x-message-ttl=1500. Same name, different argument. Queue conflicting = QueueBuilder.durable(Topology.Q_TTL) .ttl(9999) .deadLetterExchange(Topology.DLX) .deadLetterRoutingKey("failed") .build(); assertThatExceptionOfType(AmqpIOException.class) .isThrownBy(() -> this.admin.declareQueue(conflicting)) .satisfies((ex) -> { String detail = rootMessage(ex); System.out.println("=== redeclaring orders.ttl with x-message-ttl=9999 ==="); System.out.println(" " + detail); assertThat(detail).contains("PRECONDITION_FAILED") .contains("inequivalent arg 'x-message-ttl'"); }); // Queue arguments are immutable. There is no ALTER QUEUE. Changing a TTL, a max-length // or a dead-letter exchange on an existing queue means: declare a new queue, move the // consumers, drain the old one, delete it. Plan the rename into the change. } private static String rootMessage(Throwable throwable) { // The useful text is on the cause. AmqpIOException's own message is just // "java.io.IOException", which is why this failure is so often reported as "IOException" // with no further detail. Throwable current = throwable; StringBuilder all = new StringBuilder(); while (current != null) { all.append(current.getMessage()).append(" | "); current = current.getCause(); } return all.toString(); } }