Azure · lesson 15 of 19
Beginner
4Intermediate
13- 5 Azure Virtual Network Basics
- 6 Azure SQL Basics
- 7 Azure Functions Fundamentals
- 8 Bicep and ARM Templates Basics
- 9 Azure Monitor Basics
- 11 Azure Cosmos DB: Items and Partition Keys
- 12 Azure Cosmos DB: Queries
- 13 Azure Cosmos DB: ETags and Patch
- 14 Azure Service Bus: Queues and Settlement
- 15 Azure Service Bus: Dead-Lettering
- 16 Azure Service Bus: Topics and Filters
- 17 Azure Key Vault: Secrets and Versions
- 18 Azure Key Vault: Soft-Delete and Recovery
Also part of Azure
5- + Dead-Letter Queues: What Happens When Retries Keep Failing from Event-Driven Architecture
- + Idempotent Consumers: Surviving a Duplicate Delivery from Event-Driven Architecture
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
- + Deployments and Rollouts from Kubernetes
- + Services and Networking from Kubernetes
Azure Service Bus: Dead-Lettering
Reject a bad message into Azure Service Bus's dead-letter queue with the Python SDK against the official emulator, and read it back from the DLQ with the reason attached — a message a consumer actively rejects, not one that silently vanished.
By SkillByExample · Published
A consumer doesn’t have to just complete or abandon a message — it can actively reject one it knows it can’t process, with a reason attached, so the message goes somewhere a human or a later job can inspect it, rather than silently discarding it or leaving it to redeliver up to the queue’s delivery limit before the broker does the same thing automatically. This lesson continues against the same Service Bus Emulator image as the previous one.
Rejecting One Message While Completing the Others
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue
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-0"))
sender.send_messages(ServiceBusMessage("order-1-bad-total"))
sender.send_messages(ServiceBusMessage("order-2"))
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):
body = str(msg)
if "bad-total" in body:
receiver.dead_letter_message(msg, reason="bad-total")
print("dead-lettered:", body)
else:
receiver.complete_message(msg)
print("completed:", body)
with client.get_queue_receiver(
"orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
) as dlq_receiver:
for msg in dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5):
print("DLQ:", str(msg), "reason:", msg.dead_letter_reason)
dlq_receiver.complete_message(msg)
{
"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-15
4e13b1808b924be801881a972b9eea0c79242fb166d5c4f5af2ca4fa815416af
$ docker run -d --name az-sb-sql-15 --network az-sb-net-15 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/mssql/server:2025-CU10-ubuntu-24.04
0c1819475c4ac2c6c626daacb7f2792b32bd01a30f05c438c65ed64926d5b494
$ until docker logs az-sb-sql-15 2>&1 | grep -q 'SQL Server is now ready for client connections'; do sleep 1; done
$ docker run -d --name az-sb-emu-15 --network az-sb-net-15 -v "$(pwd)"/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json -e ACCEPT_EULA=Y -e SQL_SERVER=az-sb-sql-15 -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1
f288213a66ac94cdbb92dbb30e17c38624c50df9c1478f34dbbdf8741b2ea931
$ until docker logs az-sb-emu-15 2>&1 | grep -q 'Emulator Service is Successfully Up'; do sleep 1; done
$ docker run --rm --network container:az-sb-emu-15 -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_dlq.py"
[notice] To update, run: pip install --upgrade pip
completed: order-0
dead-lettered: order-1-bad-total
completed: order-2
DLQ: order-1-bad-total reason: bad-total
order-1-bad-total never reaches the “completed” branch — the consumer recognized it as bad and
called dead_letter_message with a reason instead of either completing it (which would discard
the problem silently) or abandoning it (which would just redeliver the same bad message, up to
MaxDeliveryCount times, before the broker dead-letters it automatically anyway — see Practice 3).
Reading from sub_queue=ServiceBusSubQueue.DEAD_LETTER afterward shows exactly that one message,
with dead_letter_reason carrying the string the consumer passed in.
Practice
1. Dead-letter a message with both a reason and an error_description, and read both back.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue
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-negative-total"))
with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
for msg in receiver.receive_messages(max_message_count=1, max_wait_time=5):
receiver.dead_letter_message(
msg,
reason="validation-failed",
error_description="total was negative",
)
with client.get_queue_receiver(
"orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
) as dlq_receiver:
for msg in dlq_receiver.receive_messages(max_message_count=1, max_wait_time=5):
print("reason:", msg.dead_letter_reason)
print("description:", msg.dead_letter_error_description)
dlq_receiver.complete_message(msg)
$ docker run --rm --network container:az-sb-emu-15 -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_dlq_description.py"
[notice] To update, run: pip install --upgrade pip
reason: validation-failed
description: total was negative
Both strings round-trip onto the DLQ message exactly as passed — error_description is a free-form
detail alongside the shorter, more categorical reason, and neither is interpreted by Service Bus
itself; they’re there purely for whatever reads the DLQ afterward.
2. Complete a message from the DLQ and confirm it doesn't come back a second time.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue
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-to-drain"))
with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
for msg in receiver.receive_messages(max_message_count=1, max_wait_time=5):
receiver.dead_letter_message(msg, reason="draining-practice")
with client.get_queue_receiver(
"orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
) as dlq_receiver:
first_pass = dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5)
print("DLQ messages before drain:", len(first_pass))
for msg in first_pass:
dlq_receiver.complete_message(msg)
with client.get_queue_receiver(
"orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
) as dlq_receiver:
second_pass = dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5)
print("DLQ messages after drain:", len(second_pass))
$ docker run --rm --network container:az-sb-emu-15 -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_dlq_drain.py"
[notice] To update, run: pip install --upgrade pip
DLQ messages before drain: 1
DLQ messages after drain: 0
Completing a DLQ message removes it from the DLQ the same way completing a main-queue message removes it from there — permanently, not just marking it read. The second read comes back empty because there’s nothing left to hand out, not because anything is being hidden from a receiver that already saw it once.
3. Abandon a message repeatedly, without ever dead-lettering it explicitly, until it exceeds MaxDeliveryCount — and see who dead-letters it this time.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue
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-poison"))
# Config.json sets MaxDeliveryCount to 3 — abandon it that many times, never completing it
for attempt in range(3):
with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
for m in receiver.receive_messages(max_message_count=1, max_wait_time=5):
print(f"attempt {attempt}: delivery_count={m.delivery_count}")
receiver.abandon_message(m)
with client.get_queue_receiver(
"orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
) as dlq_receiver:
for m in dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5):
print("auto-dead-lettered:", str(m), "reason:", m.dead_letter_reason)
dlq_receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-15 -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_auto_dlq.py"
[notice] To update, run: pip install --upgrade pip
attempt 0: delivery_count=0
attempt 1: delivery_count=1
attempt 2: delivery_count=2
auto-dead-lettered: order-poison reason: MaxDeliveryCountExceeded
Nobody ever called dead_letter_message on this one — Service Bus itself moved it to the DLQ
once its delivery count hit MaxDeliveryCount, and set dead_letter_reason to
MaxDeliveryCountExceeded rather than whatever string application code would have passed.
That reason string is exactly how code reading the DLQ later tells “the broker gave up on this”
apart from “a consumer explicitly rejected this.”
Cleanup
$ docker rm -f az-sb-emu-15 az-sb-sql-15
az-sb-emu-15
az-sb-sql-15
$ docker network rm az-sb-net-15
az-sb-net-15
Next: topics and subscriptions — one message fanned out to multiple consumers, filtered by a SQL expression on each subscription.