Skip to main content
Azure · lesson 14 of 19
Azure intermediate Lesson 14 of 19

Azure Service Bus: Queues and Settlement

Send and receive messages through an Azure Service Bus queue with the Python SDK against the official Service Bus Emulator in Docker, and see what complete_message actually does: a message a reader already received doesn't come back on the next receive.

By SkillByExample · Published

azure service-bus messaging

Azure Service Bus is a managed message broker: producers send messages to a queue, and consumers receive and settle them — explicitly marking each one done, abandoned, or rejected. This lesson runs against Microsoft’s own Service Bus Emulator in Docker, which needs a SQL Server container alongside it and a Config.json declaring the queue up front.

Sending Three Messages and Completing Them

from azure.servicebus import ServiceBusClient, ServiceBusMessage

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        for order_id in ["order-0", "order-1", "order-2"]:
            sender.send_messages(ServiceBusMessage(order_id))
        print("sent 3")

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        for msg in receiver.receive_messages(max_message_count=3, max_wait_time=5):
            print("got", str(msg), "delivery", msg.delivery_count)
            receiver.complete_message(msg)

    with client.get_queue_receiver("orders", max_wait_time=3) as receiver:
        remaining = receiver.receive_messages(max_message_count=3, max_wait_time=3)
        print("after completing all three, remaining:", len(remaining))
{
  "UserConfig": {
    "Namespaces": [
      {
        "Name": "sbemulatorns",
        "Queues": [
          {
            "Name": "orders",
            "Properties": {
              "DeadLetteringOnMessageExpiration": false,
              "DefaultMessageTimeToLive": "PT1H",
              "DuplicateDetectionHistoryTimeWindow": "PT20S",
              "ForwardDeadLetteredMessagesTo": "",
              "ForwardTo": "",
              "LockDuration": "PT30S",
              "MaxDeliveryCount": 3,
              "RequiresDuplicateDetection": false,
              "RequiresSession": false
            }
          }
        ],
        "Topics": []
      }
    ],
    "Logging": { "Type": "File" }
  }
}
$ docker network create az-sb-net-14
def447444ad2a831f729664f07e6239558762731443d3b1052439c0a05ec098b
$ docker run -d --name az-sb-sql-14 --network az-sb-net-14 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/mssql/server:2025-CU10-ubuntu-24.04
1d767c4b2ee73368855c85055aba0e1cd046799d118b2d953d649dd647ac8e57
$ until docker logs az-sb-sql-14 2>&1 | grep -q 'SQL Server is now ready for client connections'; do sleep 1; done
$ docker run -d --name az-sb-emu-14 --network az-sb-net-14 -v "$(pwd)"/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json -e ACCEPT_EULA=Y -e SQL_SERVER=az-sb-sql-14 -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1
b352b338a39a88d0df7a94f3867b41b73d00c217397d88adb9b97b2c1430e6b0
$ until docker logs az-sb-emu-14 2>&1 | grep -q 'Emulator Service is Successfully Up'; do sleep 1; done
$ docker run --rm --network container:az-sb-emu-14 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_queue.py"
[notice] To update, run: pip install --upgrade pip
sent 3
got order-0 delivery 0
got order-1 delivery 0
got order-2 delivery 0
after completing all three, remaining: 0

The emulator needs two containers and a mounted Config.json just to provision one queue — a real namespace’s queues already exist and this setup step disappears entirely. delivery is 0 on this first-ever receive of each message, not 1; it counts redeliveries, so a message read for the first time hasn’t been redelivered yet. (That 0 is specific to how the Python SDK exposes the underlying AMQP delivery-count header — the .NET SDK and the Azure portal report the same first delivery as 1.) After complete_message runs for all three, the queue is empty — remaining comes back as 0, not because the messages are still there and locked, but because they’re actually gone.

Peek-lock receive is why Service Bus delivers at least once, not exactly once: a message stays in the queue, locked but not removed, until something explicitly completes it — so a consumer that crashes after processing a message but before calling complete_message sees that same message again once the lock expires. Code that isn’t naturally safe to run twice on the same message (charging a card, incrementing a counter) needs to de-duplicate itself; see idempotent consumers for the general pattern.

Practice

1. Receive a message and let the Python with block exit without calling complete_message, then check whether it comes back.
from azure.servicebus import ServiceBusClient, ServiceBusMessage

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-unsettled"))

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        msgs = receiver.receive_messages(max_message_count=1, max_wait_time=5)
        for m in msgs:
            print("received, not settled:", str(m), "delivery_count:", m.delivery_count)
        # no complete_message call — the lock on this message is left to expire

    with client.get_queue_receiver("orders", max_wait_time=35) as receiver:
        again = receiver.receive_messages(max_message_count=1, max_wait_time=35)
        for m in again:
            print("received again after lock expiry:", str(m), "delivery_count:", m.delivery_count)
            receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-14 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_no_settle.py"
[notice] To update, run: pip install --upgrade pip
received, not settled: order-unsettled delivery_count: 0
received again after lock expiry: order-unsettled delivery_count: 1

The message comes back on the second receiver because the first one never settled it — Config.json set this queue’s LockDuration to PT30S (30 seconds), so the message becomes visible to another receiver again once that lock expires, rather than staying invisible forever because one consumer forgot to call complete_message.

2. Call abandon_message explicitly instead of waiting for the lock to expire, and confirm the message is available again immediately.
from azure.servicebus import ServiceBusClient, ServiceBusMessage

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-abandon-me"))

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        msgs = receiver.receive_messages(max_message_count=1, max_wait_time=5)
        for m in msgs:
            print("delivery count before abandon:", m.delivery_count)
            receiver.abandon_message(m)

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        again = receiver.receive_messages(max_message_count=1, max_wait_time=5)
        for m in again:
            print("delivery count after abandon+redelivery:", m.delivery_count)
            receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-14 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_abandon.py"
[notice] To update, run: pip install --upgrade pip
delivery count before abandon: 0
delivery count after abandon+redelivery: 1

abandon_message releases the lock immediately instead of waiting out LockDuration — the message is available to the very next receive_messages call. The delivery count, which the lock-expiry version of this also increments, is the queue’s own tally of how many times this message has been handed to a receiver, independent of which receiver instance did the receiving.

3. Peek a message without receiving it, then confirm a real receive still gets it.
from azure.servicebus import ServiceBusClient, ServiceBusMessage

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-peek-me"))

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        peeked = receiver.peek_messages(max_message_count=1)
        for m in peeked:
            print("peeked:", str(m), "delivery_count:", m.delivery_count)

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        received = receiver.receive_messages(max_message_count=1, max_wait_time=5)
        for m in received:
            print("still receivable after peek:", str(m), "delivery_count:", m.delivery_count)
            receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-14 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_peek.py"
[notice] To update, run: pip install --upgrade pip
peeked: order-peek-me delivery_count: 0
still receivable after peek: order-peek-me delivery_count: 0

peek_messages is a read with no side effect on the message at all — no lock taken, no delivery count bumped, nothing to abandon or complete afterward. It’s for inspecting a queue without affecting anything a real consumer will later receive and settle, which is exactly why the same message is still there, fully receivable, right after being peeked.

Cleanup

Each lesson in this Service Bus sequence starts its own fresh SQL Server and emulator pair — on a machine running through all of them without cleaning up, that’s a SQL Server and an emulator container left running per lesson. Remove this lesson’s containers and network before moving on:

$ docker rm -f az-sb-emu-14 az-sb-sql-14
az-sb-emu-14
az-sb-sql-14
$ docker network rm az-sb-net-14
az-sb-net-14

Next: dead-lettering — what happens to a message a consumer actively rejects.

Frequently Asked Questions

Can I run Azure Service Bus locally without a real namespace?
Yes, with Microsoft's own Service Bus Emulator, a Docker image that needs a SQL Server container alongside it (the emulator uses SQL Server to persist entity metadata and message state) and a Config.json file declaring the queues, topics, and subscriptions to create at startup. The emulator does have a real runtime management endpoint (plain HTTP, port 5300) under the hood, but the Python SDK's admin client can't reach it — see the next question — so this lesson's examples treat Config.json as the only way in.
Why does the emulator need a Config.json instead of creating queues through the SDK?
Not because the emulator lacks a runtime admin API — it has one, a plain-HTTP endpoint on port 5300 that a raw PUT request can create a queue against. The problem is specifically azure-servicebus's ServiceBusAdministrationClient: it always speaks HTTPS, so pointing it at the emulator either gets a connection refused (it defaults to port 443, which the emulator doesn't listen on) or, pointed at 5300 directly, an SSL: WRONG_VERSION_NUMBER error (TLS handshake against a plain-HTTP port). Config.json sidesteps the SDK's admin client entirely by provisioning everything before the container starts.
What does complete_message actually do, and what happens if a consumer never calls it?
complete_message tells Service Bus the message was processed successfully, so it's permanently removed from the queue. A message that's received but never completed (or abandoned) becomes visible to other receivers again once its lock duration expires — Service Bus assumes a consumer that never settles a message might have crashed, so it releases the lock rather than leaving the message stuck invisible forever.
Does receive_messages guarantee it will return exactly the number of messages requested?
No — max_message_count is an upper bound, not a guarantee. receive_messages returns as soon as it has at least one message and either hits that count or the max_wait_time elapses, so a receiver has to be written to handle getting fewer messages than it asked for, including zero.