Azure Service Bus transport
Last updated:
BabelQueue.AzureServiceBus is an Azure Service Bus transport on the .NET core. It sends
the canonical envelope as the Body with the
§4 native projection, and consumes by
routing each message to a handler by URN — so a message it produces is consumed by any
other BabelQueue SDK, and vice-versa.
Install
dotnet add package BabelQueue.AzureServiceBus
Requirements: .NET 8. It pulls BabelQueue.Core and Azure.Messaging.ServiceBus
transitively.
Produce
using Azure.Messaging.ServiceBus;
using BabelQueue.AzureServiceBus;
await using var client = new ServiceBusClient("<connection-string>"); // or a namespace + TokenCredential
var sender = client.CreateSender("orders");
var id = await new AsbPublisher(sender)
.PublishAsync("urn:babel:orders:created", new Dictionary<string, object?> { ["order_id"] = 1042 });
PublishAsync returns the message meta.id; pass a traceId to continue a trace, or a
delay (TimeSpan) to schedule native delayed delivery (ScheduledEnqueueTime).
Consume
var receiver = client.CreateReceiver("orders"); // PeekLock
var handlers = new Dictionary<string, BabelHandler>
{
["urn:babel:orders:created"] = (envelope, message, ct) =>
{
// envelope.Data, envelope.TraceId, envelope.Attempts ...
return Task.CompletedTask;
},
};
var consumer = new AsbConsumer(receiver, handlers, new AsbConsumerOptions
{
OnError = (err, env, msg) => Console.Error.WriteLine(err),
});
await consumer.RunAsync(cancellationToken);
A throwing handler Abandons the message — the broker redelivers it and increments
DeliveryCount (at-least-once); at MaxDeliveryCount it auto-moves to the native
dead-letter sub-queue. Auth is a connection string or the fully-qualified namespace + a
TokenCredential (DefaultAzureCredential).
Poison bodies and version-gated messages
A poison body (not JSON, not valid UTF-8, not an envelope, no valid job or urn, or an
unsupported meta.schema_version) and a version-gated message can never succeed, so the consumer
settles them on their first delivery, never hands them to a handler and never retries them
(ADR-0037, since 1.1.0).
Before decoding, the consumer version-gates on the bq-schema-version application property
(§4.7). A string "1" or any integral 1 passes; a floating-point or boolean value is
rejected. A missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF,
VT, FF, CR) changes nothing: the body is decoded as usual. A property present with a null
value counts as missing too. Any other value is compared with "1" exactly as sent, with no
trimming, so "2", "x", "01" and " 1" are all rejected without decoding the body, and
OnError is called with an empty envelope. A "1" value does not skip the body check: the
decoded envelope still goes through the schema_version validation, and a body that fails it is
a poison body.
What happens next depends on whether you set AsbConsumerOptions.DeadLetterSender, a
ServiceBusSender on the <queue>.dlq entity (an ordinary Service Bus queue that must exist):
var dlq = client.CreateSender("orders.dlq");
var consumer = new AsbConsumer(receiver, handlers, new AsbConsumerOptions
{
DeadLetterSender = dlq,
Queue = "orders", // dead_letter.original_queue; defaults to the receiver's entity path
});
| Configuration | Poison body | Version-gated message |
|---|---|---|
DeadLetterSender set |
sent to <queue>.dlq as the poison record (a strict JSON object body spliced with a dead_letter block, reason: "poison"; anything else wrapped under raw), then Completed |
the raw body, byte for byte and with no dead_letter block, sent to <queue>.dlq, then Completed |
no DeadLetterSender |
Abandoned, so MaxDeliveryCount moves it to the native sub-queue |
explicitly dead-lettered into the native $DeadLetterQueue (reason unsupported schema version) |
The original is completed only after the send succeeded; a failed send Abandons it and reports
the error, so the write is tried again on redelivery. DeadLetterSender is opt-in: with none,
nothing changes and the native sub-queue stays your safety net. With one set, abandoning towards
the native sub-queue is no longer a conformant settle. This consumer has no unknown-URN strategy
option, so it behaves as fail; the OnUnknownUrn callback applies to an unknown URN only.
Contract mapping (§4)
| Envelope | Azure Service Bus |
|---|---|
| body | Body (byte-identical across SDKs) |
job (URN) |
Subject |
trace_id |
CorrelationId |
meta.id |
MessageId |
meta.schema_version |
ApplicationProperties["bq-schema-version"] (set on publish; the consumer checks it before decoding the body, and also validates the body’s meta.schema_version after decoding) |
meta.lang |
ApplicationProperties["bq-source-lang"] |
meta.created_at |
ApplicationProperties["bq-created-at"] (ms) |
attempts |
max(body, DeliveryCount − 1) |
| reserve / ack / retry | PeekLock → Complete / Abandon |
ServiceBusSender / ServiceBusReceiver are mockable, so the unit tests use Moq +
ServiceBusModelFactory — no Azure, no network. The envelope is unchanged
(schema_version stays 1); Azure Service Bus is purely additive.