A C# console app that generates D2C (device-to-cloud) message load against an Azure IoT Hub, to deliberately exercise its rate-throttling and daily-quota limits for testing purposes.
It provisions its own simulated device identities, sends messages from them concurrently, tallies what happens (success / throttled / quota-exceeded / other), and deletes the simulated devices again when it's done.
- Provisions N simulated devices on your hub via
RegistryManager, in batches of 100 (DeviceProvisioner.cs). Each gets its own client-generated symmetric key and a device ID likethrottle-test-3f9a2b7c-00000(see Device naming below). - Opens a
DeviceClientper device (MessageSender.cs), using AMQP with connection pooling by default so hundreds of devices share a handful of real TCP connections instead of one each. - Sends messages from a pool of concurrent workers, round-robining across the simulated devices, until either the message-count target or a time limit is hit (or you cancel).
- Retries are disabled (
NoRetry) on purpose — this tool exists to observe throttling and quota errors, not have the SDK silently swallow them via automatic retry. - Deletes the simulated devices in a
finallyblock, so cleanup runs even if the run is cancelled or crashes partway through (unless--keep-devicesis passed).
- .NET 8 SDK
- An Azure IoT Hub, and a connection string with
RegistryWrite+ServiceConnectpermissions (the built-iniothubownershared access policy has both). Get it from: Azure Portal → your IoT Hub → Shared access policies →iothubowner→ Primary connection string.
Set the connection string as an environment variable (PowerShell, current session only):
$env:IOTHUB_OWNER_CONNECTION_STRING = "HostName=<yourhub>.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=<key>"Then build and run:
dotnet run -- --devices 50 --messages 450000(Note the -- — needed so dotnet run passes the flags through to the app instead of parsing
them itself. Not needed when running the built .exe directly.)
Or pass the connection string inline instead of using the environment variable (ends up in shell history, so prefer the environment variable for anything beyond a one-off):
.\IotHubThrottleTester.exe --connection-string "HostName=...;SharedAccessKeyName=iothubowner;SharedAccessKey=..." --devices 50 --messages 450000| Flag | Default | Description |
|---|---|---|
--connection-string <s> |
IOTHUB_OWNER_CONNECTION_STRING env var |
iothubowner (or equivalent) connection string |
--devices <n> |
50 |
number of simulated devices to provision |
--messages <n> |
450000 |
total messages to send across all devices |
--message-size <bytes> |
1024 |
payload size per message |
--concurrency <n> |
200 |
max in-flight sends at a time |
--rate <n> |
0 (unlimited) |
cap on messages/sec |
--duration <seconds> |
0 (unbounded) |
stop after this many seconds regardless of --messages |
--device-prefix <s> |
throttle-test |
device ID prefix |
--keep-devices |
off | don't delete the provisioned devices when done |
--mqtt |
off | use MQTT instead of AMQP (AMQP with connection pooling is the default) |
Run --help for the same summary from the command line.
Azure IoT Hub enforces two distinct limits that are easy to conflate but need different load shapes to exercise:
| Throttling (HTTP 429) | Quota exceeded (error 403002) | |
|---|---|---|
| What it limits | Operations per second | Total messages per day |
| Behavior | Transient — back off and it recovers | Hard wall — every send fails until reset |
| Resets | Immediately (next second/minute) | UTC midnight |
| Azure Monitor metric | "Number of throttling errors" | No dedicated error-count metric — only the "Total number of messages used" usage gauge |
| SDK exception (this app) | IotHubThrottledException |
DeviceMaximumQueueDepthExceededException — see note below |
Because they need opposite load shapes, compose the flags differently for each:
Throttling burst — short, high-concurrency, unbounded rate, capped by time instead of count:
.\IotHubThrottleTester.exe --duration 20 --concurrency 500 --messages 100000000Quota drain — long, steady, paced under the per-second limit so only the daily cap gets exercised (not rate-throttling), capped by message count:
.\IotHubThrottleTester.exe --messages 450000 --rate 150Note: quota exhaustion is sticky for the rest of the UTC day. Once a run pushes the hub over its
daily quota, every subsequent run (including a throttling burst test) will report Quota exceeded
immediately for everything until the daily reset at UTC midnight — that's expected, not a bug.
The Microsoft.Azure.Devices.Client SDK never introduced a dedicated QuotaExceededException for
D2C sends, because doing so would have been a breaking change. Instead, when a send is rejected
for daily-quota-exceeded (error 403002), it throws DeviceMaximumQueueDepthExceededException — the
inner exception message says so explicitly. This app catches that exception type and counts it as
Quota exceeded, not Other errors; see Program.cs for the exact exception handling.
While running, a progress line is printed every 2 seconds:
[12:55:17] sent=16,284/450,000 ok=0 throttled=0 quota=0 other=16,284
At the end, a summary is printed:
=== Results ===
Stopped because : message target reached
Elapsed : 00:01:41
Sent (attempted) : 450,000
Succeeded : 402,103
Throttled (429) : 0
Quota exceeded : 47,897
Other errors : 0
Throughput : 4,431.7 msg/s
- Stopped because —
message target reached,duration limit reached (Ns), orcancelled by user(Ctrl+C). - Succeeded / Throttled / Quota exceeded — mutually exclusive outcome per message.
- Other errors — anything not matching a known throttling/quota exception; the first
occurrence of each distinct exception type is printed live as
[new error type] ..., and all are broken down by type in the summary, so a genuine bug or connectivity problem doesn't get silently lumped in as "some error happened."
Device IDs follow {devicePrefix}-{runId}-{index:D5}, e.g.
throttle-test-3f9a2b7c-00000 — a fresh 8-character runId per run, so devices from different
runs don't collide, and the whole set can be found in the portal by filtering on the prefix.
- Simulated devices are deleted automatically after each run (
finallyblock), including on Ctrl+C. Pass--keep-devicesto skip this (e.g. to inspect them, or reuse them across runs). - If a run is killed outright (process killed, terminal closed, machine crash) rather than
cancelled normally, cleanup won't run and devices will be left behind — check the portal for
leftover
throttle-test-*devices if that happens, and delete them manually or withRemoveDevices2Asyncvia a script. - The daily quota this app is designed to exceed is a property of your whole hub, shared with any real devices/traffic on it. Don't point this at a production hub unless you intend to actually exhaust its daily message budget for everyone using it.