Request and reply

require "acemq/amqp/patterns"

Topology.new.queue("price.requests", true).apply(mq)

responder = Patterns.serve(mq, "price.requests") do |message|
  { "price" => catalogue.price(message.payload["sku"]) }   # the answer, not an Ack
end

prices = Patterns::Requester.new(mq, "price.requests", 5)
prices.call({ "sku" => "X-1" })   # => { "price" => 1299 }
prices.close

Two objects. Patterns.serve answers requests on a queue and hands back a Patterns::Responder; Patterns::Requester asks and waits. Everything else on this page is about what waiting costs and what the waiting is allowed to conclude.

That first line is not optional. Patterns.serve does not declare the request queue — it is consume underneath, and a subscription declares its queue passively, so a queue that is not there is a TransportError reading NOT_FOUND - no queue 'price.requests' in vhost '/' rather than a queue quietly created with whatever arguments this process felt like. The requester declares its own reply queue, because that one is nobody else's business; the request queue belongs to a topology.

Read this first

Messaging is asynchronous, and request-reply is a synchronous shape drawn on top of it. That is a real cost rather than a free convenience. A caller blocked on a reply is holding a Ruby thread, a connection and a deadline, and a responder's queue that backs up turns into a caller that stops responding — the queue depth is somewhere else, so the service that is failing is not the service anybody is looking at.

If the two sides can speak HTTP or gRPC to each other, they should. Those tools have timeouts, retries, load balancing, circuit breakers and health checks that a messaging library will not match, and every operator already knows how to read them.

What this is genuinely for:

Those cases are real, and doing them by hand means reply queues, correlation ids, and a timeout somebody always forgets.

It blocks, and there is no future

Requester#call blocks the calling thread until the reply arrives or the deadline passes. There is no call_async, no promise and no callback, and that is a deliberate difference from Java's requestAsync: Ruby has no future in the standard library that the library could hand back without picking a concurrency gem for you, and a pattern helper is the wrong place to make that choice for a whole application.

What Ruby has instead is threads, and they compose the way you would expect:

answers = skus.map { |sku| Thread.new { prices.call({ "sku" => sku }) } }.map(&)

One Requester is safe to call from several threads at once. It keeps a hash of who is waiting for what, guarded by a mutex, and each caller waits on a condition variable of its own — so a slow answer to one request does not hold up anybody else's.

That condition variable is there rather than a Thread::Queue because Queue#pop(timeout:) arrived in Ruby 3.2 and this library's floor is 3.1. It is waited in a loop: a condition variable may wake for no reason at all, and a spurious wake read as a reply would be a nil payload handed to a caller.

How it works on the wire

A request names its reply queue twice — AMQP's own reply-to property and an acemq-reply-to application header, always the same value — and carries a correlation_id. The responder publishes the answer to that queue with the same correlation id, and the requester matches it to the caller waiting for it.

A responder reads the header first and the property second. That order is the contract in all five libraries. The header is preferred because it is the half that survives a hop that rebuilds the message — a retry rung, a dead-letter, a shovel — where the property does not: a service that reads a message and publishes a new one keeps the headers and usually drops the properties.

Writing both is what makes a Ruby responder able to answer a Java requester and the other way round. The two halves of the family did not start out agreeing, and a requester that wrote only one of them went unanswered by half the fleet.

Neither name is in the reserved x-acemq- namespace, and that is deliberate rather than an oversight. That namespace belongs to the engine and is kept away from what a handler sees — the envelope strips it — so a responder could never read acemq-reply-to if it lived in there. acemq-error, the header that carries a responder's failure home, is an application header for the same reason.

Patterns.reply_address(message)   # => the header, or the property, or ""
message.reply_to                  # => the native AMQP property alone

Both are readable from an ordinary consume handler, which is how you write a responder that this library did not build for you.

The reply queue

Without reply_to:, a requester generates one:

prices = Patterns::Requester.new(mq, "price.requests")
prices.reply_queue   # => "acemq-reply-1f8c…"

It is declared classic, transient, auto-deleting and exclusive, and every one of those words is doing work. Exclusive ties the queue to this connection, so the broker deletes it when the process dies — which is the orphaned-queue problem solved without a timer. Java sets x-expires for the same reason; Ruby does not need to, because an exclusive queue cannot outlive the connection that made it.

Classic, because it could be nothing else. RabbitMQ refuses an exclusive or auto-delete quorum queue outright, so this is the one place the library's quorum default would have declared something the broker will not accept. It is written down in declare_reply_queue rather than left to fail, because the broker's refusal does not mention reply queues and reads like a bug in your own code.

A named reply queue is left to that default, so it comes out durable and quorum:

prices = Patterns::Requester.new(mq, "price.requests", "price.replies")

Which is what makes it safe to name a queue your topology also declares: both declarations then say the same thing, and the second is accepted rather than refused with PRECONDITION_FAILED. Name one only when replies have to survive a restart; otherwise the generated one is the right shape, because a reply queue that outlives its requester collects answers nobody is waiting for.

Build one requester and keep it. It holds a queue and a running consumer, so one per request is a queue per request, and a broker with ten thousand auto-delete queues on it is a real way to run out of memory with nothing obviously wrong.

The reply consumer has no retry policy and no dead-letter queues

Whatever the connection's retry_policy is, the consumer reading the reply queue takes raw bytes, has no retry policy, and declares no .dlq or .parked queue:

@consumer = connection.consume(@reply_queue, BytesCodec.new,
                               RetryPolicy.none, false) { … }

That handler cannot meaningfully fail — it hands the bytes to a waiter and accepts every reply — so a policy or a dead-letter queue could only ever add durable queues to an ephemeral reply queue, and they would outlive it. acemq-reply-1f8c….parked is not a queue anybody wants to find on their broker. Before 0.7.13 a generated reply queue left its .dlq and .parked behind on every requester.

The reply is decoded in call, with the connection's codec, as Java, .NET and Python do.

What call can raise

begin
  prices.call({ "sku" => "X-1" })
rescue AceMQ::AMQP::Patterns::RequestTimedOut => e
  # "no reply to 376c1d3b-… arrived within 5.0 seconds"
rescue AceMQ::AMQP::Patterns::ResponderFailed => e
  # "the responder failed: KeyError: key not found: \"nope\""
rescue AceMQ::AMQP::DecodeError => e
  # "this message is not JSON: …"
end

DecodeError means a reply arrived and the connection's codec cannot read it; call raises it as soon as the reply lands rather than waiting out the timeout. A responder failure is checked first, so a failure whose body is unreadable still raises ResponderFailed.

ResponderFailed means the far end answered and the answer was a failure. The message is the responder's exception class and message, carried home in acemq-error. It is a string rather than a marshalled exception on purpose: the far end may be Java, and an exception class name is the most that can cross a language boundary honestly.

RequestTimedOut is the absence of an answer, not evidence that nothing happened. The request may still be queued, may be running right now, or may have been handled with the reply lost on the way back. There is no way from here to tell those apart, and a retry is a decision about whether the responder is idempotent rather than a reflex. Where the work takes money or sends an email, a timeout is a question for a human or for the idempotency store, not for a loop.

The correlation id is generated inside call rather than taken from the caller, which is why you cannot set one. A caller reusing a correlation id across two requests would have them answer each other.

A reply that arrives after its caller gave up is dropped. The reply consumer looks up the waiter, finds nobody, and moves on rather than blocking — blocking there would stall every other caller waiting on the same queue. Nothing counts that drop, which is the one piece of instrumentation this library does not have and Java does; see what is still not counted below.

Writing a responder

responder = Patterns.serve(mq, "price.requests") do |message|
  { "price" => catalogue.price(message.payload["sku"]) }
end

The block returns the answer rather than an Ack, which is the one place this library's handlers do not look like every other handler in it. A responder's job is to answer, and what to do with the request afterwards follows from whether it could:

The block The caller gets The request is
returns a value that value accepted
raises ResponderFailed rejected, and dead-lettered
returns, but the reply will not publish nothing — it times out retried
returns, but nothing is listening at reply_to nothing — it times out accepted

Raising sends the failure back before settling, because somebody blocked on a reply should learn that it failed rather than wait out their timeout. Then the request is rejected rather than retried: replying and then retrying would answer the same caller twice.

The third row is the one worth sitting with. If the handler succeeded and the reply publish failed, the work is done and the answer did not get out, so the message is retried — and the work runs again. That is the concrete reason a responder that changes anything should be idempotent, and it is not hypothetical: a broker that drops a connection between the handler returning and the reply being confirmed produces exactly this.

The fourth row is deliberate. The reply is not published mandatory, so a reply queue that has gone — a requester that timed out and closed, or restarted — is confirmed and dropped by the broker and the request is accepted. The only one who could use that reply is the caller who is no longer there, and retrying the request would repeat the work for nobody. It surfaces as a timeout on the requester's side, which is where it belongs.

A request that names no reply address at all is rejected with a FatalError and dead-lettered rather than retried, because retrying cannot make a reply queue appear. Anything arriving on price.requests.dlq with that error is a caller using publish where it meant to use a requester.

Options go through to consume

Patterns.serve passes everything it does not recognise to Connection#consume, so the whole of that method is available:

Patterns.serve(mq, "price.requests", 10, 4) do |message|
  { "price" => catalogue.price(message.payload["sku"]) }
end

A responder handles one request at a time by default, and every caller behind a slow one is waiting. concurrency: is the first setting to reach for when request-reply feels slow, and it is the same trade as anywhere else: throughput in exchange for the order the broker offered the messages in, which a responder almost never cares about.

Shutting down

prices.close      # stops consuming replies; the generated queue goes with it
responder.cancel  # stops delivery, then waits for handlers already running

Responder#cancel waits up to 30 seconds for in-flight handlers by default — it is the consumer's own cancel, forwarded. That wait matters more for a responder than for an ordinary consumer: a request being answered right now has somebody blocked on the other side, and dropping it turns their call into a timeout they will not be able to explain. A responder forwards running? and queue too, and responder.consumer is the consumer itself for anything else.

mq.close cancels every consumer on the connection, requesters' reply consumers included, so a process that closes its connection at exit does not need to remember each one. Closing a requester twice is harmless.

What is counted

The round trip, from the caller's side

metrics = Telemetry::Registry.new
mq = AceMQ::AMQP::Connection.open(url, metrics)

Every call raises two series, tagged with routing.key, message.type and outcome:

acemq.request.total round trips, by outcome — answered, timed_out or failed
acemq.request.duration seconds, the same tags; how long the caller waited

The duration is the number nothing else can give you. The publish is already counted and so is the responder's delivery, but the caller's wait spans two queues, two processes and somebody else's work, and no single message's metrics see all of it. Set your timeouts from this and from acemq.consume.duration on the responder's queue.

timed_out is kept apart from failed on purpose: it says a reply did not arrive in time, not that anything went wrong. The request may still be queued, still being handled, or long since done with the reply lost on the way back — which is why resending is only safe against an idempotent responder. A duration distribution for timed_out sitting exactly on the deadline is a deadline set too short, not a broken responder.

The label names carry Java's and .NET's dots so that one dashboard panel covers all of them. Prometheus does not allow a dot in a label name — a single bad line makes the whole scrape unparseable — so to_prometheus renders them as routing_key and message_type, which is what Go writes and what a dashboard will be asking for.

The two numbers a responder keeps

responder = Patterns.serve(mq, "price.requests") { |m| price(m.payload) }

responder.answered      # requests answered, counted before each reply left
responder.unanswerable  # requests that named nowhere to reply

The same two numbers Java's Responder.answered() and unanswerable() report, with the same promise about when they may be read: never a wait. Both exist before the responder subscribes, so a request the broker hands over during start-up — what a queue with a backlog looks like from in here — is counted like any other. Code that sleeps before reading one is working around a defect that is not here.

answered is incremented before the reply is published. The other order reads more naturally and is wrong: it leaves a window in which the reply is in the caller's hands and the responder still says nothing has been answered, which is a dashboard reporting an idle service that is demonstrably working. A publish that fails hands its increment back, so this counts replies that were sent rather than replies that were attempted.

A responder that raised is counted in answered here, and this is the one place Ruby's number differs from Java's. A Ruby responder answers that request — the failure goes back in acemq-error and the caller raises ResponderFailed rather than waiting out its deadline — and a reply that was sent is a request that was answered. Java's responder does not reply at all in that case and so does not count it; the divergence is in what the two do with a failure, not in what the counter means. Split the two apart with acemq.consume.total, where the same delivery is acked or rejected.

unanswerable above zero means a caller is using publish where it meant to use a requester. Nothing can answer such a request, and nothing about redelivering it would make a reply address appear, so it is dead-lettered once rather than looped.

And the ordinary metrics

A requester and a responder are still built out of publish and consume, so they are counted by those too, under the queues they use:

acemq.publish.total{outcome="confirmed"} requests going out, and replies going back
acemq.consume.total{queue="price.requests",outcome="acked"} requests answered
acemq.consume.total{queue="price.requests",outcome="rejected"} requests that raised, or that named nowhere to reply
acemq.consume.duration{queue="price.requests"} how long answering takes — the responder's half of a caller's timeout

See metrics, tracing and health for the registry and the Prometheus rendering.

Tracing has a span shape made for this. The OpenTelemetry adapter does not know when you are making a request, because call is publish-then-wait like anything else, so you say so:

tracing = Telemetry::OpenTelemetry.new.install(mq)

quote = tracing.request("price.requests") { prices.call({ "sku" => "X-1" }) }

That opens a CLIENT span — client rather than producer, because a reader has to know this span's duration includes somebody else's work — and closes it with answered, timed_out or failed. timed_out is deliberately not marked as an error: a reply that did not arrive in time is usually a responder's queue being long rather than a failure of this process, and a trace view that fills with red for slow responders stops meaning anything. The exception still reaches you, and you are free to decide it was one.

What is still not counted

Java's Requester also exposes unmatched(): replies that arrived with nobody waiting. Ruby has no such number. A reply to a request that already gave up is dropped silently, and that count rising alongside timeouts is the signature of a responder slower than its callers expect — nothing broken, the timeout simply wrong. acemq.request.total{outcome="timed_out"} is most of that signal now, and reading it against acemq.consume.duration on the responder's queue is the rest.