Broker Testing

Test Which MQTT Features a Broker Supports (MQTT 5, QoS 2, Retain)

Probe an MQTT broker's real capabilities: read CONNACK properties and check maximum QoS, retained messages, wildcards and shared subscriptions in Python.

Updated · 9 min read

Test Which MQTT Features a Broker Supports (MQTT 5, QoS 2, Retain)

Documentation and comparison tables tell you what a broker can do. What matters is what your broker, with your configuration and your credentials, actually allows. The fastest reliable way to find out is to probe it: connect with MQTT 5, read the CONNACK properties, then try each feature and look at the reason codes that come back.

This article explains which signals to check and gives you a Python script (paho-mqtt 2.x) that prints a capability report for any broker.

What a broker tells you in CONNACK

An MQTT 5 broker announces its limits in the CONNACK packet. The important rule: a property is only sent when it differs from the default. A missing RetainAvailable does not mean "unknown" — it means "yes".

CONNACK propertyDefault if absentWhat breaks if it is restricted
Maximum QoS2Publishing above it gets 0x9B QoS not supported and the connection is closed
Retain AvailableyesRetained publishes get 0x9A Retain not supported
Wildcard Subscription Availableyes+ / # subscriptions get 0xA2 Wildcard Subscriptions not supported
Shared Subscription Availableyes$share/... subscriptions get 0x9E Shared Subscriptions not supported
Subscription Identifiers AvailableyesSubscriptions with an identifier get 0xA1
Receive Maximum65,535How many QoS 1/2 messages you may have in flight towards the broker
Maximum Packet Sizeno limit (protocol max)Larger packets get 0x95 Packet too large
Topic Alias Maximum0 (aliases not accepted)Using an alias the broker did not allow is 0x94 Topic Alias invalid
Server Keep Aliveyour valueThe broker overrides your keep-alive; your client must use its value
Assigned Client Identifier—Sent when you connect with an empty client ID

MQTT 3.1.1 has none of this. A 3.1.1 broker only tells you whether the connection was accepted, so everything has to be tested by trying it. You can look up any of these codes in the reason code reference.

The probe script

The script below runs six checks: protocol versions, CONNACK properties, granted QoS per subscription, wildcard and shared subscriptions, retained messages, and an end-to-end QoS 2 delivery. Configure it with environment variables and run it against every environment you care about.

probe.py
"""Report which MQTT features a broker supports. Requires paho-mqtt >= 2.0."""
import os, threading, time, uuid
import paho.mqtt.client as mqtt

HOST = os.environ.get("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.environ.get("MQTT_PORT", "1883"))
USER, PASS = os.environ.get("MQTT_USER"), os.environ.get("MQTT_PASS")
BASE = f"testmqtt/probe/{uuid.uuid4().hex[:8]}"

DEFAULTS = {  # value that applies when the broker omits the property
    "MaximumQoS": 2, "RetainAvailable": 1, "WildcardSubscriptionAvailable": 1,
    "SharedSubscriptionAvailable": 1, "SubscriptionIdentifierAvailable": 1,
    "ReceiveMaximum": 65535, "MaximumPacketSize": "no limit",
    "TopicAliasMaximum": 0, "ServerKeepAlive": "client value",
}

def connect(protocol=mqtt.MQTTv5, timeout=10):
    c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                    client_id=f"probe-{uuid.uuid4().hex[:8]}", protocol=protocol)
    if USER:
        c.username_pw_set(USER, PASS)
    if PORT == 8883:
        c.tls_set()
    done, res = threading.Event(), {}
    def on_connect(client, userdata, flags, reason_code, properties):
        res.update(rc=reason_code, props=properties)
        done.set()
    c.on_connect = on_connect
    c.connect(HOST, PORT, keepalive=30)
    c.loop_start()
    if not done.wait(timeout):
        c.loop_stop()
        raise TimeoutError("no CONNACK")
    return c, res["rc"], res["props"]

def close(*clients):
    for c in clients:
        c.disconnect()
        c.loop_stop()

def subscribe(c, topic_filter, qos=2, timeout=5):
    done, res = threading.Event(), {}
    def on_subscribe(client, userdata, mid, reason_code_list, properties):
        res["rc"] = reason_code_list[0]
        done.set()
    c.on_subscribe = on_subscribe
    c.subscribe(topic_filter, qos=qos)
    return res["rc"] if done.wait(timeout) else "no SUBACK"

def show(label, value):
    print(f"  {label:34} {value}")

def main():
    print(f"Broker {HOST}:{PORT}  (test topics under {BASE})\n")

    print("Protocol versions")
    for name, proto in (("MQTT 3.1.1", mqtt.MQTTv311), ("MQTT 5", mqtt.MQTTv5)):
        try:
            c, rc, _ = connect(proto)
            show(name, "OK" if not rc.is_failure else f"refused: {rc}")
            close(c)
        except Exception as e:
            show(name, f"FAILED ({e})")

    c, rc, props = connect()
    if rc.is_failure:
        raise SystemExit(f"MQTT 5 connect refused: {rc}")

    print("\nCONNACK properties")
    for name, default in DEFAULTS.items():
        value = getattr(props, name, None)
        show(name, value if value is not None else f"{default} (default)")

    print("\nSubscriptions (granted QoS or reason code)")
    for q in (0, 1, 2):
        show(f"QoS {q} subscription", subscribe(c, f"{BASE}/qos/{q}", qos=q))
    show("Wildcard filter (+)", subscribe(c, f"{BASE}/+/wild", qos=1))
    show("Shared subscription ($share)", subscribe(c, f"$share/probe/{BASE}/shared", qos=1))

    print("\nActive tests")
    got = {}
    received = threading.Event()
    def on_message(client, userdata, msg):
        got[msg.topic] = msg
        received.set()
    c.on_message = on_message

    # End-to-end QoS 2: publish at 2 to a QoS 2 subscription, check delivered QoS
    received.clear()
    c.publish(f"{BASE}/qos/2", b"qos2-probe", qos=2)
    if received.wait(5):
        show("QoS 2 delivery", f"delivered at QoS {got[f'{BASE}/qos/2'].qos}")
    else:
        show("QoS 2 delivery", "NOT delivered")

    # Retained message: publish, then subscribe with a fresh client
    topic = f"{BASE}/retain"
    try:
        c.publish(topic, b"retained-probe", qos=1, retain=True).wait_for_publish(5)
        sub, _, _ = connect()
        hit = threading.Event()
        sub.on_message = lambda client, u, m: m.retain and hit.set()
        subscribe(sub, topic, qos=1)
        show("Retained message", "OK" if hit.wait(5) else "NOT delivered")
        close(sub)
        c.publish(topic, b"", qos=1, retain=True).wait_for_publish(5)  # clear it
    except Exception as e:
        show("Retained message", f"FAILED ({e})")

    close(c)

if __name__ == "__main__":
    main()

Run it with your connection details:

Run the probe
pip install "paho-mqtt>=2.0"
MQTT_HOST=broker.connectmq.com MQTT_PORT=8883 \
MQTT_USER="$MQTT_USER" MQTT_PASS="$MQTT_PASS" python probe.py

Granted QoS values print as paho ReasonCode objects: Granted QoS 2 means the broker accepted the subscription at that level, while a code of 0x80 or higher (for example Not authorized or Shared Subscriptions not supported) is a refusal. If the shared subscription is accepted but you want to know whether messages are really load-balanced, use the dedicated shared subscription test.

Reading the report

Typical findings, and what to do about them:

  • MQTT 5 refused, 3.1.1 OK — older broker version or a listener restricted to 3.1.1. Your MQTT 5 client must fall back, or the broker needs an upgrade. See MQTT 5 vs 3.1.1.
  • MaximumQoS 1 — common on cloud IoT services and some brokers configured for throughput. A QoS 2 publish will disconnect your client with 0x9B; design for QoS 1 plus idempotent handlers.
  • Subscription granted at a lower QoS than requested — this is legal. The broker downgrades instead of refusing, and messages arrive at the granted level. Check the granted QoS in your client, not just the requested one.
  • RetainAvailable 0 or retained message not delivered — devices that rely on retained state (last value on subscribe) will look broken. Details in retain not supported.
  • TopicAliasMaximum 0 — the broker does not accept topic aliases from clients; libraries that auto-assign aliases must turn that off.
  • MaximumPacketSize set — compare it with your largest payload plus topic and properties using the packet size calculator.
  • Not authorized on a subscription — not a missing feature but an ACL. Run the probe with the user your devices use, and see testing MQTT ACL permissions.

The same check in JavaScript

With MQTT.js 5 the CONNACK properties are on the packet passed to the connect event:

connack.mjs
import mqtt from 'mqtt';

const client = mqtt.connect('mqtts://broker.connectmq.com:8883', {
  protocolVersion: 5,
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
});

client.on('connect', (connack) => {
  // Absent keys mean the spec default applies
  console.log(connack.properties ?? '(no properties: all defaults)');
  client.subscribe('$share/probe/testmqtt/probe/js', { qos: 2 }, (err, granted) => {
    console.log(err ?? granted); // granted[0].qos is the granted QoS or a reason code
    client.end();
  });
});

client.on('error', (err) => console.error('connect failed:', err.message));

The property names are camelCase here: maximumQoS, retainAvailable, maximumPacketSize, topicAliasMaximum, wildcardSubscriptionAvailable, sharedSubscriptionAvailable, subscriptionIdentifiersAvailable, receiveMaximum and serverKeepAlive.

No code? Use mqtt test

The HiveMQ MQTT CLI has this probe built in: mqtt test -h broker.connectmq.com prints the MQTT 3 and MQTT 5 capabilities, including maximum topic length and payload size measured by trial. The mqtt-cli guide explains the output. For a quick manual check, connect with the online MQTT client using MQTT 5 and try a retained publish and a $share/ subscription.

Make it part of your pipeline

Capabilities change silently: a broker upgrade, a new listener, a different plan on a managed service. Save the probe's output in your repository and compare it in CI:

Fail the build when capabilities change
python probe.py | sed -E 's#testmqtt/probe/[0-9a-f]+#testmqtt/probe/X#' > capabilities.txt
git diff --exit-code capabilities.txt

The sed removes the random topic suffix so only real changes show up in the diff. Pair this with the checks in how to test an MQTT broker and you will know about a missing feature before a device in the field does.

Watch the probe topics in the browser

A five-minute probe beats an afternoon of debugging "random" disconnects. Read the CONNACK, try every feature you rely on, and keep the report under version control so that differences between brokers and environments are never a surprise.

Frequently asked questions

How do I know if a broker supports MQTT 5?

Connect with protocol version 5. A broker that supports it answers with an MQTT 5 CONNACK, usually including properties. One that does not returns 'unacceptable protocol version' (0x01 in 3.1.1, 0x84 in MQTT 5) or closes the connection.

If a CONNACK property is missing, is the feature unsupported?

No, the opposite. In MQTT 5 a missing property means the default applies: retain, wildcards, shared subscriptions and subscription identifiers are available, maximum QoS is 2 and receive maximum is 65,535. Brokers only send properties that restrict or change these defaults.

Can a broker support a feature but deny it for my client?

Yes. ACLs, per-listener settings and plans on managed services can restrict QoS, retain, wildcards or topics for specific users. That is why the probe should run with the same credentials and port as your devices.