Queues, exchanges and bindings

The question AMQP cannot answer

AMQP gives you two ways to ask about a queue, and neither returns its configuration.

A passive declare tells you whether the queue exists. A real declare tells you whether the arguments you passed match the ones the broker holds — by succeeding, or by killing your channel with a 406. Neither reports what the arguments are.

That is not a gap in AceMQ for Java; it is the protocol. The consequence is that a topology mismatch can be detected but not explained:

QueueInfo queue = admin.queue("orders.new").orElseThrow();

queue.arguments();      // {x-message-ttl=60000, x-dead-letter-exchange=dlx, x-queue-type=classic}
queue.argument("x-message-ttl");    // 60000

Comparing against a topology

RabbitMQ 4 records x-queue-type on every queue, including queues declared with no arguments at all. So this is wrong:

// Reports drift on every classic queue in the estate.
boolean matches = queue.arguments().equals(expected);

Compare the arguments you care about, not the whole map:

boolean matches = expected.entrySet().stream()
        .allMatch(e -> Objects.equals(queue.argument(e.getKey()), e.getValue()));

That also has the property you want when RabbitMQ adds another broker-maintained argument in a future version: a queue you did not change does not suddenly start reporting drift.

Counters

Method What it is
messagesReady() Waiting, not yet delivered. The depth number.
messagesUnacknowledged() Delivered to a consumer, not yet acked
messages() The sum of the two
consumers() Consumers currently attached
state() "running", "idle", "flow"

messages() stays high while a healthy consumer works through a batch, so a depth check built on it fires during normal operation. Use messagesReady().

For depth across many queues at once, the management API is the wrong tool — one HTTP request per queue does not scale to an estate. Use the metrics endpoint, which returns every queue in one scrape.

Listing

for (QueueInfo q : admin.queues()) {
    System.out.printf("%-30s %8d %s%n", q.name(), q.messagesReady(), q.type());
}

queues() is scoped to the current vhost. On a large broker this is a big response; the metrics endpoint is cheaper if you only want the numbers.

Exchanges and bindings

ExchangeInfo dlx = admin.exchange("dlx").orElseThrow();
dlx.type();        // "topic"
dlx.internal();    // false

for (BindingInfo b : admin.bindingsForQueue("orders.new")) {
    System.out.println(b.source() + " --[" + b.routingKey() + "]--> " + b.destination());
}

The default exchange has an empty name. Every queue is implicitly bound to it by its own name, and those bindings come back from bindings() with an empty source(). isDefaultExchangeBinding() identifies them, and they are almost never what you want in a topology export — you did not declare them and you cannot delete them.

The empty name is also why exchange("") is found by scanning the listing rather than by requesting a path: the URL would collapse to the listing endpoint and return an array where an object was expected.

Two encoding details that cost a test failure each

The default vhost is literally / and must reach the URL as %2F. Unencoded it produces /api/queues///orders.new, which 404s — and a 404 is indistinguishable from a queue that is not there. The library encodes it; the detail is here because it will bite you if you build URLs yourself.

URLEncoder turns a space into +. Correct for a form body, wrong for a path: a queue named dead letters would be looked up as dead+letters and reported missing. Path segments are percent-encoded here, not form-encoded.

Declaring and deleting

AMQP can declare a queue, and usually should: a queue an application depends on is best declared by that application at startup, so it exists before the first publish. These exist for the cases AMQP cannot serve — provisioning for a team that has not deployed yet, or restoring from a backup.

admin.declareQueue("orders.new", true, Map.of("x-message-ttl", 60000));
admin.declareExchange("dlx", "topic", true, Map.of());
admin.bindQueue("dlx", "orders.new", "orders.#", Map.of());

Both declares are idempotent in the same way PUT is: running the code twice succeeds twice.

admin.deleteQueue("orders.new");     // with everything in it
admin.deleteExchange("dlx");         // and its bindings

Purging

admin.purgeQueue("orders.dlq");

Discards every message and keeps the queue. Irreversible, with no confirmation. It is genuinely right for a dead-letter queue whose contents have been analysed and cannot be replayed, and it is exactly as fast on a queue holding something important.

Unbinding takes the binding, not a routing key

BindingInfo binding = admin.bindingsForQueue("orders.new").stream()
        .filter(b -> !b.isDefaultExchangeBinding())
        .findFirst()
        .orElseThrow();

admin.unbind(binding);

A binding has no name. Several may join the same pair of objects with different routing keys, so the broker identifies one by a properties_key — and that key arrives already percent-encoded. A routing key of a.# has a properties key of a.%23, which belongs in the URL exactly as given; encoding it again produces a.%2523 and matches nothing.

That is why unbind takes the whole object. Reconstructing the key correctly is not something a caller should have to know about, and getting it wrong produces a 404 that looks like a binding which was already gone.

The default-exchange binding is refused rather than attempted — the broker maintains it, and it exists for as long as the queue does.

Errors

Situation What you get
Queue absent Optional.empty()
Bad credentials AdminException, saying the user needs monitoring or administrator
Plugin disabled AdminException — the API answers 406, not 404
Broker unreachable AdminException wrapping the IOException

The 406 is worth knowing. A disabled rabbitmq_shovel_management plugin does not look like a missing endpoint; it looks like a refusal to negotiate, and treating it as "not found" would report an empty shovel list on a broker that has shovels.