From 661d5e9a443d189e29e2374cee0af3bf2931db64 Mon Sep 17 00:00:00 2001 From: Chase Bolt Date: Mon, 27 Jul 2026 11:09:23 -0700 Subject: [PATCH] fix: disable AWS IoT SDK metrics to avoid awscrt _certificate_source crash awsiot's mqtt_connection_builder defaults enable_metrics_collection to True, so it builds an AWS IoT SDK metrics string and passes it to awscrt.mqtt.Connection with disable_metrics=False. awscrt then reads private ClientTlsContext internals to encode the feature list: # awscrt/aws_iot_metrics.py val = _certificate_source_metrics_value(tls_ctx._certificate_source) _certificate_source was only added to ClientTlsContext in awscrt 0.35.0. On installs where awscrt's modules are not all from the same version -- which is what several Home Assistant 2026.7 / Python 3.14 users are hitting -- that attribute is missing and get_rest_devices dies with: AttributeError: 'ClientTlsContext' object has no attribute '_certificate_source' The connection is never even attempted, so setup fails and the retry loop hammers the Hatch login endpoint until it returns HTTP 429. These metrics only report AWS SDK name/version/platform to AWS in the CONNECT packet username; nothing in this library consumes them. Opting out skips the offending code path entirely and leaves the connection otherwise unchanged. Fixes dahlb/ha_hatch#323 --- src/hatch_rest_api/util_bootstrap.py | 11 +++ tests/test_util_bootstrap.py | 121 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/test_util_bootstrap.py diff --git a/src/hatch_rest_api/util_bootstrap.py b/src/hatch_rest_api/util_bootstrap.py index 93eddda..9453dee 100644 --- a/src/hatch_rest_api/util_bootstrap.py +++ b/src/hatch_rest_api/util_bootstrap.py @@ -94,6 +94,17 @@ async def get_rest_devices( client_id=f"hatch_rest_api/{safe_email}/{str(uuid4())}", on_connection_interrupted=on_connection_interrupted, on_connection_resumed=on_connection_resumed, + # Opt out of the AWS IoT SDK metrics that awsiot otherwise appends + # to the CONNECT packet username. They report AWS SDK/platform + # details to AWS and are of no use to us, but building them makes + # awscrt introspect private ClientTlsContext internals + # (tls_ctx._certificate_source). On installs where awscrt's modules + # are not all from the same version, that attribute is missing and + # the connection blows up before it is ever attempted: + # AttributeError: 'ClientTlsContext' object + # has no attribute '_certificate_source' + # Disabling metrics skips that code path entirely. + enable_metrics_collection=False, ), ) try: diff --git a/tests/test_util_bootstrap.py b/tests/test_util_bootstrap.py new file mode 100644 index 0000000..c081adf --- /dev/null +++ b/tests/test_util_bootstrap.py @@ -0,0 +1,121 @@ +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from hatch_rest_api import util_bootstrap + + +class FakeHatch: + """Stands in for the Hatch REST client, returning canned payloads.""" + + def __init__(self, *args, **kwargs): + self.api_session = MagicMock() + + async def login(self, **kwargs): + return "auth-token" + + async def iot_devices(self, **kwargs): + return [ + { + "product": "restMini", + "name": "Nursery", + "thingName": "thing-1", + "macAddress": "AA:BB:CC:DD:EE:FF", + } + ] + + async def token(self, **kwargs): + return { + "region": "us-east-1", + "identityId": "identity-1", + "token": "aws-token", + "endpoint": "https://example-ats.iot.us-east-1.amazonaws.com", + } + + +class FakeAwsHttp: + def __init__(self, *args, **kwargs): + pass + + async def aws_credentials(self, **kwargs): + return { + "Credentials": { + "AccessKeyId": "key", + "SecretKey": "secret", + "SessionToken": "session", + "Expiration": 1780000000, + } + } + + +class FakeShadowClient: + """IotShadowClient stub. + + subscribe_* returns the (future, topic) pair the real client returns; + publish_* returns a future whose .result() is a MagicMock. + """ + + def __getattr__(self, name): + if name.startswith("subscribe_"): + return lambda *args, **kwargs: (MagicMock(), MagicMock()) + return lambda *args, **kwargs: MagicMock() + + +class GetRestDevicesMetricsTest(unittest.TestCase): + """Regression guard for dahlb/ha_hatch#323. + + awsiot defaults ``enable_metrics_collection`` to True, which makes awscrt + build an AWS IoT SDK metrics string by reading private ClientTlsContext + internals. On installs whose awscrt modules are not all the same version + that read raises:: + + AttributeError: 'ClientTlsContext' object has no attribute + '_certificate_source' + + which fails setup before the MQTT connection is even attempted. We must + keep passing enable_metrics_collection=False so that path is never entered. + """ + + def _run_bootstrap(self): + builder = MagicMock(return_value=MagicMock()) + + with ( + patch.object(util_bootstrap, "Hatch", FakeHatch), + patch.object(util_bootstrap, "Contentful", MagicMock()), + patch.object(util_bootstrap, "AwsHttp", FakeAwsHttp), + patch.object(util_bootstrap, "AwsCredentialsProvider", MagicMock()), + patch.object(util_bootstrap, "io", MagicMock()), + patch.object( + util_bootstrap, "IotShadowClient", lambda *a, **kw: FakeShadowClient() + ), + patch.object( + util_bootstrap, "websockets_with_default_aws_signing", builder + ), + ): + asyncio.run( + util_bootstrap.get_rest_devices( + email="user@example.com", password="hunter2" + ) + ) + + return builder + + def test_metrics_collection_disabled(self): + builder = self._run_bootstrap() + + builder.assert_called_once() + self.assertIs(builder.call_args.kwargs["enable_metrics_collection"], False) + + def test_devices_still_created(self): + builder = self._run_bootstrap() + + # Sanity check that disabling metrics did not disturb the rest of the + # bootstrap: the connection is still built and devices still returned. + self.assertEqual( + builder.call_args.kwargs["endpoint"], + "example-ats.iot.us-east-1.amazonaws.com", + ) + + +if __name__ == "__main__": + unittest.main()