Request and reply
Messaging is one-way. Request/reply builds a round trip on top of it: the sender names a queue to answer on, and the answer comes back correlated to the question.
using var requester = await mq.RequesterAsync();
var quote = await requester.RequestAsync<QuoteRequest, Quote>(
"pricing", "quote.requested", new QuoteRequest("A-1"));Answering:
using var responder = await mq.RespondAsync<QuoteRequest, Quote>(
"pricing.requests", async request => await _pricing.QuoteAsync(request));The responder replies to whatever queue the request named, with the request's id as the reply's correlation id.
Where the reply address lives
A request carries the address twice: on AMQP's own
reply-to property, and on an acemq-reply-to
application header. They always hold the same value.
The duplication is the fix for a real split. Up to 0.3.0 this library and Java wrote and read only the native property, while Go, Python and Ruby wrote and read only the header — so a .NET requester and a Go responder could not talk at all, in either direction, and nothing in the fixtures noticed. Since 0.5.0 every library writes both and reads either, and the read order is the same everywhere: the header first, the native property second.
Requester.ReplyToHeader // "acemq-reply-to"The header deliberately does not carry the x-acemq-
prefix. That namespace belongs to the engine and is stripped before a
handler ever sees it, so a responder could never read a reply address
hidden in it.
Nothing about this is visible in the API above — a
Requester sets both without being asked, and a
Responder finds whichever one arrived. It matters when the
requester or the responder on the other side of the queue is not this
library.
One reply queue per requester
Not one per request. A queue per request costs a declare and a delete on the broker for every call, which is the difference between request/reply being usable at rate and being a curiosity.
That means replies for many outstanding requests share a queue, and
matching them is the requester's job. Each reply is
handed to the caller whose request id it correlates to. A reply arriving
for a caller that has already given up is counted as
Unmatched and dropped:
requester.TimedOut // requests that gave up before an answer arrived
requester.Unmatched // replies with nobody left waiting for themHanding a late answer to whoever happens to ask next is worse than no answer, and it is exactly what a shared reply queue read in arrival order would do.
The reply queue is a classic queue, asked for explicitly rather than taking the library's quorum default. It belongs to one process and holds answers nobody will read once that process is gone, so there is nothing in it worth replicating — and RabbitMQ does not allow a quorum queue to be exclusive or auto-delete, so a queue shaped like this one could not be quorum even if it were worth it.
Timeouts
Thirty seconds by default. A request that is not answered in time
throws RequestTimedOutException:
try
{
var quote = await requester.RequestAsync<QuoteRequest, Quote>(
"pricing", "quote.requested", request,
TimeSpan.FromSeconds(5), cancellationToken);
}
catch (RequestTimedOutException)
{
// The request may still be processed. A timeout says no answer arrived,
// not that nothing happened.
}That distinction matters when the operation has an effect. A timed-out request whose handler succeeded a moment later has still been carried out, so anything that must not happen twice needs the request to be idempotent — the envelope's id is the key for that.
When the responder fails
A responder whose handler throws sends the failure back to the
caller, which throws ResponderFailedException at once
instead of waiting out its timeout. The failure travels as the
acemq-error header (Requester.ErrorHeader),
valued {ExceptionType}: {message}, on a reply with an empty
body; the requester checks for the header before decoding anything, and
Failure holds its text. Go, Python and Ruby use the same
header, so a failure crosses between any two of the four in either
direction.
try
{
var quote = await requester.RequestAsync<QuoteRequest, Quote>(
"pricing", "quote.requested", request);
}
catch (ResponderFailedException e)
{
logger.LogWarning("pricing could not quote: {Failure}", e.Failure);
}Only the description crosses the broker, not the exception: a caller
that has to tell failures apart needs that in the response type. Once
the failure is sent the request is dead-lettered to
{queue}.dlq with the same reason rather than retried — the
caller has its answer, and a retry would answer twice — and it is not
counted in Answered.
Before 0.7.13 a .NET responder sent nothing when its handler threw: the request went through the responder's retry policy and the caller timed out. A 0.7.12 or older requester that receives one of these failure replies does not know the header and decodes the empty body: with the JSON codec that is a decode error raised at once rather than a timeout. Upgrade requesters with responders to get the responder's own description.
Requests that cannot be answered
A request that names neither reply address — no
acemq-reply-to header and no native reply-to
property — is counted and accepted rather than retried:
responder.UnanswerableRedelivering it would not make a reply address appear, so retrying only moves the same message round the same loop.
A reply that cannot be delivered
The reply is published mandatory, like every publish. If its reply
queue has gone — the requester timed out and was disposed, say — the
publish fails, the request is not acknowledged, and the
responder's retry policy has it: the handler runs again and, once the
policy gives up, the request lands in {queue}.dlq. The
requester sees only its own timeout. Nothing is acknowledged into
nowhere.
What the counters promise
A responder reports two numbers, and both are safe to read the instant a round trip returns:
responder.Answered // requests answered, counted before the reply left
responder.Unanswerable // requests that named nowhere to replyAnswered is incremented before the
reply is published, so a caller holding its answer can rely on the count
already including it. The other order looks more natural and is wrong:
it leaves a window where the reply is in the caller's hands and the
responder still says nothing has been answered, which is a monitoring
dashboard reporting an idle service that is demonstrably working. A
publish that fails takes its increment back, so this counts replies that
were sent rather than replies that were attempted.
The counters 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. Neither number needs a wait before it can be trusted, and code that sleeps before reading one is working around a defect that is fixed.
When not to use it
Request/reply over a broker adds a network hop, a queue and a correlation to what an HTTP call does directly, and it couples the caller to the responder's availability for the duration — which is the property messaging usually exists to avoid.
It earns its place when the responder is already a consumer of the same broker, when the request should queue rather than fail while the responder is redeploying, or when the reply is genuinely optional. If the caller cannot proceed without the answer and both sides speak HTTP, use HTTP.