RPC (Direct Reply-To)
Native in-protocol request/reply over AMQP 0-9-1 — amq.rabbitmq.reply-to with correlation-id, the responder a plain AMQP consumer on a KubeMQ Queue.
RPC turns messaging into request/response: a client sends a request and blocks for a reply. AMQP RPC on this connector is fully native and in-protocol — there is no gRPC responder anywhere. The responder is just a normal AMQP consumer of a request queue that publishes a reply. The recommended mechanism is RabbitMQ's Direct Reply-To (amq.rabbitmq.reply-to), a pseudo-queue that avoids declaring a real reply queue per request. Every queue involved — the request queue and the reply path — resolves onto ordinary KubeMQ Queue channels.
Overview
The requester consumes the amq.rabbitmq.reply-to pseudo-queue (it declares no queue) and publishes each request to a request queue, carrying reply-to = amq.rabbitmq.reply-to and a unique correlation-id. The connector mints an opaque address (amq.rabbitmq.reply-to.g1.{node}.{id}) and rewrites the reply-to so the responder only ever sees the minted address. The responder is a normal consumer of the request queue; it publishes its reply to the default exchange keyed by that minted address, echoing the same correlation-id. The requester matches each reply to its request by correlation-id.
| Operation | AMQP action | KubeMQ mapping |
|---|---|---|
| Requester consumes replies | basic.consume("amq.rabbitmq.reply-to", no-ack=true) | Pseudo-queue (no real channel declared) |
| Requester sends request | basic.publish(routing-key="rpc-queue", reply-to=..., correlation-id=...) | SendQueueMessage to amqp.default.rpc-queue |
| Responder consumes | basic.consume("rpc-queue") | Competing-consumer pull on the request queue |
| Responder replies | basic.publish(routing-key=req.reply-to, correlation-id=req.correlation-id) | Reply routed to the minted reply address |
| Match | requester correlates by correlation-id | Reply paired to its request |
How it works
The connector rewrites the requester's reply-to to an opaque, node-local minted address before the request reaches the responder. The responder echoes that address and the correlation-id; the requester pairs each reply to the request it issued.
The requester's reply-to is rewritten to a minted address; the responder echoes the correlation-id, and the requester pairs each reply to its request.
Request and reply
Each example runs a responder that consumes rpc-queue and echoes each request, and a requester that consumes amq.rabbitmq.reply-to (no-ack) and issues five correlated requests, matching each response by correlation-id. Every client reads the broker endpoint from KUBEMQ_AMQP_URL (default amqp://guest:guest@localhost:5672/).
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
const (
rpcQueue = "rpc-queue"
replyTo = "amq.rabbitmq.reply-to"
calls = 5
)
func amqpURL() string {
if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
return v
}
return "amqp://guest:guest@localhost:5672/"
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
conn, err := amqp.Dial(amqpURL())
if err != nil {
log.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
// Responder: consume rpc-queue, reply to the minted reply-to address.
serverCh, _ := conn.Channel()
if _, err := serverCh.QueueDeclare(rpcQueue, false, false, false, false, nil); err != nil {
log.Fatalf("declare rpc-queue: %v", err)
}
requests, err := serverCh.Consume(rpcQueue, "responder", true, false, false, false, nil)
if err != nil {
log.Fatalf("responder consume: %v", err)
}
go func() {
for req := range requests {
// The requester's reply-to is rewritten to a minted opaque address.
if !strings.HasPrefix(req.ReplyTo, "amq.rabbitmq.reply-to.g1.") {
log.Printf("warning: responder expected a minted address, got %q", req.ReplyTo)
}
_ = serverCh.PublishWithContext(ctx, "", req.ReplyTo, false, false, amqp.Publishing{
ContentType: "text/plain",
CorrelationId: req.CorrelationId,
Body: append([]byte("echo:"), req.Body...),
})
}
}()
// Requester: consume the pseudo-queue with no-ack (manual ack → 406).
clientCh, _ := conn.Channel()
replies, err := clientCh.Consume(replyTo, "", true, false, false, false, nil)
if err != nil {
log.Fatalf("consuming %s requires no-ack: %v", replyTo, err)
}
for i := 1; i <= calls; i++ {
corr := fmt.Sprintf("corr-%d", i)
body := fmt.Sprintf("request-%d", i)
if err := clientCh.PublishWithContext(ctx, "", rpcQueue, false, false, amqp.Publishing{
ContentType: "text/plain",
CorrelationId: corr,
ReplyTo: replyTo,
Body: []byte(body),
}); err != nil {
log.Fatalf("request %d: %v", i, err)
}
select {
case reply := <-replies:
if reply.CorrelationId != corr {
log.Fatalf("call %d: correlation-id %q != %q", i, reply.CorrelationId, corr)
}
log.Printf(" [rpc] %s (corr=%s) → %s", body, corr, reply.Body)
case <-time.After(30 * time.Second):
log.Fatalf("timed out waiting for reply %d", i)
}
}
log.Printf(" [✓] %d RPC calls round-tripped over %s, correlation-id matched", calls, replyTo)
if _, err := serverCh.QueueDelete(rpcQueue, false, false, false); err != nil {
log.Printf("warning: rpc-queue delete: %v", err)
}
}import os
import pika
URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")
RPC_QUEUE = "rpc-queue"
REPLY_TO = "amq.rabbitmq.reply-to"
CALLS = 5
def main() -> None:
# Responder: consume requests, echo back to the minted reply address.
server_conn = pika.BlockingConnection(pika.URLParameters(URL))
server_ch = server_conn.channel()
server_ch.queue_declare(queue=RPC_QUEUE, durable=False)
def on_request(ch, method, props, body):
# The connector rewrites reply-to to an opaque minted address; the
# responder only ever sees that, never the pseudo-queue name.
ch.basic_publish(
exchange="",
routing_key=props.reply_to,
body=b"echo:" + body,
properties=pika.BasicProperties(content_type="text/plain", correlation_id=props.correlation_id),
)
ch.basic_ack(method.delivery_tag)
server_ch.basic_consume(queue=RPC_QUEUE, on_message_callback=on_request)
# Requester: consume amq.rabbitmq.reply-to (no-ack is mandatory).
client_conn = pika.BlockingConnection(pika.URLParameters(URL))
client_ch = client_conn.channel()
responses: dict[str, str] = {}
client_ch.basic_consume(queue=REPLY_TO, on_message_callback=lambda c, m, p, b: responses.__setitem__(p.correlation_id, b.decode()), auto_ack=True)
print(" [client] issuing RPC requests over amq.rabbitmq.reply-to:")
for i in range(1, CALLS + 1):
corr = f"corr-{i}"
client_ch.basic_publish(
exchange="",
routing_key=RPC_QUEUE,
body=f"request-{i}".encode(),
properties=pika.BasicProperties(content_type="text/plain", correlation_id=corr, reply_to=REPLY_TO),
)
# Pump the responder to handle the request, then the client to receive.
while corr not in responses:
server_conn.process_data_events(time_limit=1)
client_conn.process_data_events(time_limit=1)
print(f" request-{i} [{corr}] -> {responses[corr]!r}")
print(f" [x] all {CALLS} responses correlation-id matched (native AMQP RPC)")
server_ch.queue_delete(queue=RPC_QUEUE)
client_conn.close()
server_conn.close()
if __name__ == "__main__":
main()import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;
public final class Main {
private static final String RPC_QUEUE = "rpc-queue";
private static final String REPLY_TO = "amq.rabbitmq.reply-to";
private static final int CALLS = 5;
public static void main(String[] args) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/"));
if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) {
factory.setVirtualHost("/");
}
try (Connection serverConn = factory.newConnection();
Connection clientConn = factory.newConnection()) {
// Responder: consume requests, reply to the minted address.
Channel serverCh = serverConn.createChannel();
serverCh.queueDeclare(RPC_QUEUE, false, false, false, null);
DeliverCallback onRequest = (tag, req) -> {
String replyAddr = req.getProperties().getReplyTo(); // minted address
AMQP.BasicProperties replyProps = new AMQP.BasicProperties.Builder()
.contentType("text/plain")
.correlationId(req.getProperties().getCorrelationId())
.build();
byte[] body = ("echo:" + new String(req.getBody(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
serverCh.basicPublish("", replyAddr, replyProps, body);
};
serverCh.basicConsume(RPC_QUEUE, true, onRequest, tag -> { });
// Requester: consume the pseudo-queue (auto-ack), then call.
Channel clientCh = clientConn.createChannel();
ConcurrentHashMap<String, SynchronousQueue<String>> pending = new ConcurrentHashMap<>();
DeliverCallback onReply = (tag, reply) -> {
SynchronousQueue<String> slot = pending.get(reply.getProperties().getCorrelationId());
if (slot != null) {
slot.offer(new String(reply.getBody(), StandardCharsets.UTF_8));
}
};
clientCh.basicConsume(REPLY_TO, true, onReply, tag -> { });
for (int i = 1; i <= CALLS; i++) {
String corr = "corr-" + i;
String request = "request-" + i;
SynchronousQueue<String> slot = new SynchronousQueue<>();
pending.put(corr, slot);
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.contentType("text/plain")
.correlationId(corr)
.replyTo(REPLY_TO)
.build();
clientCh.basicPublish("", RPC_QUEUE, props, request.getBytes(StandardCharsets.UTF_8));
String reply = slot.poll(30, TimeUnit.SECONDS);
pending.remove(corr);
if (reply == null) throw new IllegalStateException("timed out on reply " + i);
System.out.println("[rpc] " + corr + ": '" + request + "' -> '" + reply + "'");
}
System.out.println("[x] " + CALLS + " RPC calls matched by correlation-id over amq.rabbitmq.reply-to");
}
}
}import amqp from "amqplib";
const RPC_QUEUE = "rpc-queue";
const REPLY_TO = "amq.rabbitmq.reply-to";
const CALLS = 5;
function url(): string {
return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/";
}
async function main(): Promise<void> {
const connection = await amqp.connect(url());
// Responder: consume rpc-queue, echo back to the reply-to address.
const serverCh = await connection.createChannel();
await serverCh.assertQueue(RPC_QUEUE, { durable: false });
await serverCh.consume(RPC_QUEUE, (req) => {
if (req === null) return;
const replyAddr = req.properties.replyTo; // minted address
serverCh.publish("", replyAddr, Buffer.concat([Buffer.from("echo:"), req.content]), {
contentType: "text/plain",
correlationId: req.properties.correlationId,
});
}, { noAck: true });
// Requester: consume amq.rabbitmq.reply-to with no-ack (manual ack → 406).
const clientCh = await connection.createChannel();
const pending = new Map<string, (body: string) => void>();
await clientCh.consume(REPLY_TO, (reply) => {
if (reply === null) return;
const resolve = pending.get(reply.properties.correlationId);
if (resolve) {
pending.delete(reply.properties.correlationId);
resolve(reply.content.toString());
}
}, { noAck: true });
for (let i = 1; i <= CALLS; i++) {
const corr = `corr-${i}`;
const body = `request-${i}`;
const response = new Promise<string>((resolve) => pending.set(corr, resolve));
clientCh.publish("", RPC_QUEUE, Buffer.from(body), {
contentType: "text/plain",
correlationId: corr,
replyTo: REPLY_TO,
});
console.log(`[client] ${body} (${corr}) -> ${await response}`);
}
console.log(`[client] completed ${CALLS} RPC round-trips, all correlation-id matched`);
await serverCh.deleteQueue(RPC_QUEUE);
await connection.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});using System.Collections.Concurrent;
using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
const string requestQueue = "rpc-queue";
const string replyToPseudoQueue = "amq.rabbitmq.reply-to";
const int calls = 5;
static string Url() =>
Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
? v
: "amqp://guest:guest@localhost:5672/";
var factory = new ConnectionFactory { Uri = new Uri(Url()) };
await using var connection = await factory.CreateConnectionAsync("rpc");
// Responder: consume rpc-queue, echo back to the delivered reply-to.
await using var serverCh = await connection.CreateChannelAsync();
await serverCh.QueueDeclareAsync(requestQueue, durable: false, exclusive: false, autoDelete: false);
var serverConsumer = new AsyncEventingBasicConsumer(serverCh);
serverConsumer.ReceivedAsync += async (_, ea) =>
{
var replyTo = ea.BasicProperties.ReplyTo ?? ""; // minted address
var replyProps = new BasicProperties
{
ContentType = "text/plain",
CorrelationId = ea.BasicProperties.CorrelationId,
};
var replyBody = Encoding.UTF8.GetBytes("echo:" + Encoding.UTF8.GetString(ea.Body.Span));
await serverCh.BasicPublishAsync("", replyTo, mandatory: false, basicProperties: replyProps, body: replyBody);
};
await serverCh.BasicConsumeAsync(requestQueue, autoAck: true, consumer: serverConsumer);
// Requester: consume the pseudo-queue with no-ack (manual ack → 406).
await using var clientCh = await connection.CreateChannelAsync();
var pending = new ConcurrentDictionary<string, TaskCompletionSource<string>>();
var clientConsumer = new AsyncEventingBasicConsumer(clientCh);
clientConsumer.ReceivedAsync += (_, ea) =>
{
if (pending.TryRemove(ea.BasicProperties.CorrelationId ?? "", out var tcs))
tcs.TrySetResult(Encoding.UTF8.GetString(ea.Body.Span));
return Task.CompletedTask;
};
await clientCh.BasicConsumeAsync(replyToPseudoQueue, autoAck: true, consumer: clientConsumer);
try
{
for (var i = 1; i <= calls; i++)
{
var corr = $"corr-{i}";
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
pending[corr] = tcs;
var reqProps = new BasicProperties
{
ContentType = "text/plain",
CorrelationId = corr,
ReplyTo = replyToPseudoQueue,
};
var request = $"request-{i}";
await clientCh.BasicPublishAsync("", requestQueue, mandatory: false,
basicProperties: reqProps, body: Encoding.UTF8.GetBytes(request));
Console.WriteLine($"[rpc] {corr}: '{request}' → '{await tcs.Task}'");
}
Console.WriteLine($"[x] All {calls} RPC calls correlation-id matched over native amq.rabbitmq.reply-to");
}
finally
{
await serverCh.QueueDeleteAsync(requestQueue, ifUnused: false, ifEmpty: false);
}# frozen_string_literal: true
require "bunny"
require "amq/uri"
RPC_QUEUE = "rpc-queue"
REPLY_TO = "amq.rabbitmq.reply-to"
REQUEST_COUNT = 5
opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/"))
opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty?
conn = Bunny.new(opts)
conn.start
# Responder: consume rpc-queue, echo back with the correlation-id.
server_ch = conn.create_channel
rpc_q = server_ch.queue(RPC_QUEUE, durable: false)
rpc_q.subscribe(manual_ack: false, block: false) do |_di, props, body|
server_ch.default_exchange.publish("echo:#{body}", routing_key: props.reply_to, correlation_id: props.correlation_id)
end
# Requester: consume amq.rabbitmq.reply-to (no-ack MUST be true) and fire 5 requests.
client_ch = conn.create_channel
replies = {}
mutex = Mutex.new
cond = ConditionVariable.new
client_ch.basic_consume(REPLY_TO, "", true, false) do |_di, props, body|
mutex.synchronize do
replies[props.correlation_id] = body
cond.signal
end
end
requests = {}
REQUEST_COUNT.times do |i|
cid = "corr-#{i + 1}"
payload = "request-#{i + 1}"
requests[cid] = payload
client_ch.default_exchange.publish(payload, routing_key: RPC_QUEUE, reply_to: REPLY_TO, correlation_id: cid)
puts " [>] request #{cid} body=#{payload.inspect}"
end
deadline = Time.now + 10
mutex.synchronize do
cond.wait(mutex, 0.5) while replies.size < REQUEST_COUNT && Time.now < deadline
end
requests.keys.sort.each { |cid| puts " [<] response #{cid} -> #{replies[cid].inspect}" }
puts " [x] All #{REQUEST_COUNT} RPC round-trips matched by correlation-id"
rpc_q.delete
conn.closeuse futures_lite::StreamExt;
use lapin::{
options::{BasicConsumeOptions, BasicPublishOptions, QueueDeclareOptions, QueueDeleteOptions},
types::FieldTable,
BasicProperties, Connection, ConnectionProperties,
};
use std::collections::BTreeMap;
const RPC_QUEUE: &str = "rpc-queue";
const REPLY_TO: &str = "amq.rabbitmq.reply-to";
const REQUESTS: usize = 5;
fn amqp_url() -> String {
let url = std::env::var("KUBEMQ_AMQP_URL")
.unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into());
match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) {
host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"),
_ => url,
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = amqp_url();
// Responder: consume rpc-queue and reply to each request's reply-to address.
let responder_conn = Connection::connect(&url, ConnectionProperties::default()).await?;
let responder_ch = responder_conn.create_channel().await?;
responder_ch
.queue_declare(RPC_QUEUE, QueueDeclareOptions::default(), FieldTable::default())
.await?;
let mut requests = responder_ch
.basic_consume(RPC_QUEUE, "responder", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default())
.await?;
let responder = tokio::spawn(async move {
let mut handled = 0usize;
while handled < REQUESTS {
let Some(delivery) = requests.next().await else { break };
let delivery = delivery?;
let reply_to = delivery.properties.reply_to().as_ref().map(|s| s.to_string()).unwrap_or_default();
let corr = delivery.properties.correlation_id().clone();
let mut body = b"echo:".to_vec();
body.extend_from_slice(&delivery.data);
// The responder only ever sees the minted reply-to address.
responder_ch
.basic_publish(
"",
reply_to.as_str(),
BasicPublishOptions::default(),
&body,
BasicProperties::default().with_correlation_id(corr.unwrap_or_else(|| "".into())),
)
.await?
.await?;
handled += 1;
}
Ok::<_, lapin::Error>(())
});
// Requester: consume amq.rabbitmq.reply-to (no-ack is mandatory; manual ack → 406).
let requester_conn = Connection::connect(&url, ConnectionProperties::default()).await?;
let requester_ch = requester_conn.create_channel().await?;
let mut replies = requester_ch
.basic_consume(REPLY_TO, "requester", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default())
.await?;
let mut pending: BTreeMap<String, String> = BTreeMap::new();
for i in 1..=REQUESTS {
let corr = format!("corr-{i}");
let body = format!("request-{i}");
pending.insert(corr.clone(), format!("echo:{body}"));
requester_ch
.basic_publish(
"",
RPC_QUEUE,
BasicPublishOptions::default(),
body.as_bytes(),
BasicProperties::default()
.with_reply_to(REPLY_TO.into())
.with_correlation_id(corr.as_str().into()),
)
.await?
.await?;
}
let mut matched = 0usize;
while matched < REQUESTS {
let delivery = replies.next().await.ok_or("reply stream closed")??;
let corr = delivery.properties.correlation_id().as_ref().map(|s| s.to_string()).unwrap_or_default();
let body = String::from_utf8_lossy(&delivery.data).into_owned();
if let Some(want) = pending.remove(&corr) {
assert_eq!(body, want, "reply must echo the request");
println!("[requester] matched {corr}: {body}");
matched += 1;
}
}
println!("[x] All {REQUESTS} RPC request/response pairs matched by correlation-id");
responder.await??;
let _ = requester_ch.queue_delete(RPC_QUEUE, QueueDeleteOptions::default()).await;
requester_conn.close(0, "done").await?;
responder_conn.close(0, "done").await?;
Ok(())
}Reply-to must be no-ack
Consuming amq.rabbitmq.reply-to requires no-ack=true. A manual-ack consume on the pseudo-queue is rejected with 406 precondition-failed — a deliberate constraint of the direct-reply-to mechanism. The requester declares no reply queue at all; the connector mints the opaque amq.rabbitmq.reply-to.g1.* address per request, and the responder only ever sees that minted address, never the literal pseudo-queue name.
No gRPC responder — RPC is fully in-protocol. Unlike some connectors where the wire-protocol client cannot act as an RPC responder (forcing an embedded gRPC responder), AMQP RPC here is entirely in-protocol. No KubeMQ SDK, no gRPC, and no embedded responder are involved — the responder is a plain AMQP consumer on a KubeMQ Queue channel.
Direct reply-to is node-local in a cluster. The amq.rabbitmq.reply-to pseudo-queue is single-node. In a cluster, the requester and responder must land on the same node (use load-balancer session affinity), or switch to an explicit reply queue + correlation-id instead. Single-node deployments are unaffected.
Related
Work Queues
Competing consumers on a durable queue — the request queue here is an ordinary work queue.
Routing (direct)
Selective delivery by exact routing key — the default exchange routes replies by queue name.
Channel mapping
How the request queue and minted reply address resolve to KubeMQ Queue channels.
Was this page helpful?
Routing (Direct)
Selective delivery over AMQP 0-9-1 — a direct exchange routes by exact routing-key match, unmatched keys dropped, resolved onto KubeMQ Queue channels.
TLS and mTLS
Securing the KubeMQ RabbitMQ connector — amqps on port 5671 via the server Security block, server-auth and mutual TLS, and encrypting the JWT in the password.