Skip to content

Commit c98187f

Browse files
committed
fix(mqtt): drop dead paho fallback, fit MQTT 3.1 client_id limit
- Drop the `try mqtt.Client(client_id=...) except TypeError: mqtt.Client(client_id)` fallback in `_build_client`. paho-mqtt is pinned >=2.0; the kwarg call always succeeds. If the fallback ever did fire on paho 2.x, the positional arg would be fed to `callback_api_version` (expects an enum) and crash with a worse error than the kwarg call itself. Removed the matching test that locked in the broken behavior. - Shrink the per-process client_id suffix from 8 hex chars (4 bytes) to 6 hex chars (3 bytes). Default composed `latchpoint-alarm-XXXXXX` now lands at exactly 23 bytes — the MQTT 3.1 spec maximum. paho 2.0+ defaults to 3.1.1 which raises the cap, but staying inside 3.1 protects against stricter brokers. 6 hex still gives 16M distinct values, far above what's needed for 1-2 process collision avoidance. - Add `test_default_composed_client_id_fits_mqtt_3_1_length_limit` regression guard so a future suffix/prefix bump can't silently push us over. - Update user-facing strings: settings registry title `"Client ID"` → `"Client ID prefix"` with description explaining the suffix and the 16-char prefix budget; rc=2 connect-error messages now reference "client_id prefix" so users know which knob they're turning.
1 parent db83ff1 commit c98187f

3 files changed

Lines changed: 41 additions & 51 deletions

File tree

backend/alarm/settings_registry.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,13 @@ class SettingDefinition:
360360
},
361361
"client_id": {
362362
"type": "string",
363-
"title": "Client ID",
363+
"title": "Client ID prefix",
364364
"default": "latchpoint-alarm",
365-
"description": "MQTT client identifier (must be unique per broker)",
365+
"description": (
366+
"Prefix for the MQTT client identifier; a per-process random suffix is "
367+
"appended automatically so multiple Latchpoint processes never collide on "
368+
"the broker. Keep ≤16 chars to stay within MQTT 3.1's 23-byte limit."
369+
),
366370
},
367371
"keepalive_seconds": {
368372
"type": "integer",

backend/transports_mqtt/manager.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ def _format_connect_error(*, rc_int: int, settings: "MqttConnectionSettings | No
4040
if rc_int == 2:
4141
client_id = (settings.get("client_id") or "").strip()
4242
if client_id:
43-
return f"{message} Try a different client_id."
44-
return f"{message} Set a unique client_id."
43+
return f"{message} Try a different client_id prefix."
44+
return f"{message} Set a client_id prefix."
4545
if rc_int == 3:
4646
return f"{message} Verify host/port and broker availability."
4747

@@ -167,7 +167,13 @@ def __init__(self) -> None:
167167
# as the same identity and the broker would knock them off each other
168168
# in a tight loop. Stable within a process, so reconnects/settings
169169
# changes don't churn the broker's view of the live client.
170-
self._client_id_suffix = secrets.token_hex(4)
170+
#
171+
# Sized at 3 bytes (6 hex chars) so the default composed client_id
172+
# `latchpoint-alarm-XXXXXX` lands at exactly 23 characters — the
173+
# MQTT 3.1 spec maximum. paho-mqtt 2.0+ defaults to MQTT 3.1.1
174+
# which raises this to 65535, but staying inside the 3.1 limit
175+
# keeps us safe against any stricter brokers a user might point at.
176+
self._client_id_suffix = secrets.token_hex(3)
171177

172178
@staticmethod
173179
def _topic_matches(*, topic_filter: str, topic: str) -> bool:
@@ -244,8 +250,9 @@ def test_connection(self, *, settings: MqttConnectionSettings, timeout_seconds:
244250

245251
# A fresh suffix per test guarantees the throwaway client never knocks
246252
# the live persistent client off the broker, even when called from the
247-
# same process.
248-
client = self._build_client(mqtt=mqtt, settings=settings, client_id_suffix=secrets.token_hex(4))
253+
# same process. Sized to match `_client_id_suffix` so the composed ID
254+
# stays within MQTT 3.1's 23-byte limit.
255+
client = self._build_client(mqtt=mqtt, settings=settings, client_id_suffix=secrets.token_hex(3))
249256

250257
def on_connect(_client, _userdata, _flags, rc, _properties=None):
251258
"""Capture connect return code and release the waiter."""
@@ -388,14 +395,15 @@ def _build_client(self, *, mqtt, settings: MqttConnectionSettings, client_id_suf
388395
on the same MQTT identity. Callers (e.g. `test_connection`) may pass
389396
an override suffix to ensure their throwaway client never collides
390397
with the live one.
398+
399+
paho-mqtt >=2.0 is pinned (see pyproject.toml); the `client_id=`
400+
kwarg call is supported across that range, so no compat fallback
401+
is needed.
391402
"""
392403
base_client_id = str(settings.get("client_id") or "latchpoint-alarm")
393404
suffix = client_id_suffix or self._client_id_suffix
394405
client_id = f"{base_client_id}-{suffix}"
395-
try:
396-
client = mqtt.Client(client_id=client_id)
397-
except TypeError:
398-
client = mqtt.Client(client_id)
406+
client = mqtt.Client(client_id=client_id)
399407

400408
username = settings.get("username") or ""
401409
password = settings.get("password") or ""

backend/transports_mqtt/tests/test_manager.py

Lines changed: 18 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ def test_empty_configured_client_id_falls_back_to_latchpoint_alarm_prefix(self):
139139
f"expected default prefix preserved, got {client.client_id!r}",
140140
)
141141

142+
def test_default_composed_client_id_fits_mqtt_3_1_length_limit(self):
143+
"""
144+
MQTT 3.1 caps `client_id` at 23 UTF-8 bytes. paho-mqtt 2.0+ defaults
145+
to 3.1.1 (which raises the cap to 65535), but keeping the default
146+
composed ID inside the 3.1 limit guards against any stricter broker
147+
a user might point Latchpoint at. If this test fails, either the
148+
suffix grew or the default prefix did — both would silently break
149+
connection on a 3.1-only broker.
150+
"""
151+
mgr = MqttConnectionManager()
152+
client = mgr._build_client(mqtt=_FakePahoModule, settings={})
153+
composed = client.client_id
154+
self.assertLessEqual(
155+
len(composed),
156+
23,
157+
f"default composed client_id exceeds MQTT 3.1's 23-byte limit: {composed!r} ({len(composed)} bytes)",
158+
)
159+
142160
def test_explicit_suffix_override_does_not_collide_with_live_suffix(self):
143161
"""`test_connection` opts into a fresh suffix so it never knocks the live client off."""
144162
mgr = MqttConnectionManager()
@@ -151,46 +169,6 @@ def test_explicit_suffix_override_does_not_collide_with_live_suffix(self):
151169
self.assertNotEqual(live.client_id, test.client_id)
152170
self.assertTrue(test.client_id.endswith("-abcd1234"))
153171

154-
def test_typeerror_on_kwarg_falls_back_to_positional_with_suffix_intact(self):
155-
"""
156-
Compat fallback: if a paho version ever rejects `client_id=` as a
157-
kwarg, the manager retries positionally. The composed
158-
`<prefix>-<suffix>` MUST still reach the broker — otherwise the
159-
per-process collision fix is silently lost.
160-
"""
161-
captured: dict[str, object] = {}
162-
163-
class _KwargRejectingPahoModule:
164-
class Client:
165-
def __init__(self, *args, **kwargs):
166-
if "client_id" in kwargs:
167-
raise TypeError("client_id keyword unsupported in this paho version")
168-
captured["positional_args"] = args
169-
self.client_id = args[0] if args else None
170-
171-
def username_pw_set(self, *_a, **_k):
172-
pass
173-
174-
def tls_set(self, *_a, **_k):
175-
pass
176-
177-
def tls_insecure_set(self, *_a, **_k):
178-
pass
179-
180-
def reconnect_delay_set(self, *_a, **_k):
181-
pass
182-
183-
mgr = MqttConnectionManager()
184-
client = mgr._build_client(
185-
mqtt=_KwargRejectingPahoModule,
186-
settings={"client_id": "latchpoint-alarm"},
187-
)
188-
self.assertTrue(
189-
client.client_id.startswith("latchpoint-alarm-"),
190-
f"fallback dropped the prefix: {client.client_id!r}",
191-
)
192-
self.assertEqual(client.client_id, captured["positional_args"][0])
193-
194172
def test_test_connection_passes_a_fresh_non_live_suffix_to_build_client(self):
195173
"""
196174
Wiring guard: `test_connection()` MUST forward a non-None override

0 commit comments

Comments
 (0)