From 2c101d6791730d5341a21f4f804da9f3157b4add Mon Sep 17 00:00:00 2001 From: Pranjal Patel Date: Tue, 14 Jul 2026 17:20:14 -0700 Subject: [PATCH 1/3] [servicebus] Strip port and path from fully_qualified_namespace (#44034) strip_protocol_from_uri now reduces the namespace to its bare host, removing any port (e.g. :443) and trailing path in addition to the scheme. This accepts the https://.servicebus.windows.net:443/ form that Azure returns when provisioning a namespace, matching the .NET and JavaScript SDKs. Previously the retained port/path corrupted both the AMQP connection host and the CBS token audience, raising ServiceBusAuthenticationError. Also enables the previously-uncollected connection-string parser test class (renamed to Test* so pytest collects it), fixes 3 stale emulator assertions, and de-duplicates a shadowed test method. --- sdk/servicebus/azure-servicebus/CHANGELOG.md | 1 + .../azure/servicebus/_common/utils.py | 20 ++++++++++-- .../tests/test_connection_string_parser.py | 31 +++++++++++++++---- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/CHANGELOG.md b/sdk/servicebus/azure-servicebus/CHANGELOG.md index f16eabf8f771..bea8e3ff1430 100644 --- a/sdk/servicebus/azure-servicebus/CHANGELOG.md +++ b/sdk/servicebus/azure-servicebus/CHANGELOG.md @@ -12,6 +12,7 @@ - Read `com.microsoft:max-message-batch-size` vendor property from the AMQP sender link to correctly limit batch size on Premium large-message entities, where `max-message-size` can be up to 100 MB but the batch limit is 1 MB. - Fixed a bug where sending a batched or multi-message payload with `uamqp_transport=True` raised `TypeError: 'BatchMessage' object is not subscriptable` (and a masked `AttributeError` on the list path) when the first message carried a `message_id`, `session_id`, or `partition_key`. The batch envelope properties are now set through the transport-appropriate code path. (regression from [#42598](https://github.com/Azure/azure-sdk-for-python/pull/42598)) - Fixed a bug where the async receiver factory methods on `azure.servicebus.aio.ServiceBusClient` (`get_queue_receiver`, `get_subscription_receiver`) and the async `ServiceBusReceiver` annotated the `auto_lock_renewer` keyword with the synchronous `AutoLockRenewer`, causing static type checkers to reject the documented `azure.servicebus.aio.AutoLockRenewer` usage. The annotation now references the async `AutoLockRenewer`, matching the docstrings and runtime behavior. ([#47948](https://github.com/Azure/azure-sdk-for-python/issues/47948)) +- Fixed a bug where passing a `fully_qualified_namespace` that included a port and/or trailing path (for example the `https://.servicebus.windows.net:443/` form that Azure returns when provisioning a namespace) raised `ServiceBusAuthenticationError`. The namespace is now normalized to its bare host, matching the .NET and JavaScript SDKs. ([#44034](https://github.com/Azure/azure-sdk-for-python/issues/44034)) ## 7.14.3 (2025-11-11) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py index ebc6b4452852..685c142b5a73 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py @@ -246,14 +246,28 @@ def transform_outbound_messages( def strip_protocol_from_uri(uri: str) -> str: - """Removes the protocol (e.g. http:// or sb://) from a URI, such as the FQDN. + """Reduces a URI to the bare host by removing the protocol (e.g. http:// or + sb://), any port (e.g. :443) and any path (e.g. a trailing /), such as for a FQDN. + + Azure returns the namespace endpoint as ``https://.servicebus.windows.net:443/``; + the resulting host is used both as the AMQP connection host and as the token + audience, so port and path must be removed. + :param str uri: The URI to modify. - :return: The URI without the protocol. + :return: The bare host portion of the URI. :rtype: str """ left_slash_pos = uri.find("//") if left_slash_pos != -1: - return uri[left_slash_pos + 2 :] + uri = uri[left_slash_pos + 2 :] + # Remove any path (trailing slash and everything after it). + slash_pos = uri.find("/") + if slash_pos != -1: + uri = uri[:slash_pos] + # Remove any port. + colon_pos = uri.find(":") + if colon_pos != -1: + uri = uri[:colon_pos] return uri diff --git a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py index 956a67eaccfc..92a065bf1c60 100644 --- a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py +++ b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py @@ -10,11 +10,12 @@ ServiceBusConnectionStringProperties, parse_connection_string, ) +from azure.servicebus._common.utils import strip_protocol_from_uri from devtools_testutils import AzureMgmtRecordedTestCase -class ServiceBusConnectionStringParserTests(AzureMgmtRecordedTestCase): +class TestServiceBusConnectionStringParser(AzureMgmtRecordedTestCase): def test_sb_conn_str_parse_cs(self, **kwargs): conn_str = "Endpoint=sb://resourcename.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=THISISATESTKEYXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" parse_result = parse_connection_string(conn_str) @@ -103,7 +104,7 @@ def test_sb_parse_malformed_conn_str_lowercase_sa_key_name(self, **kwargs): parse_result = parse_connection_string(conn_str) assert str(e.value) == "Connection string must have both SharedAccessKeyName and SharedAccessKey." - def test_sb_parse_malformed_conn_str_lowercase_sa_key_name(self, **kwargs): + def test_sb_parse_malformed_conn_str_lowercase_sa_key(self, **kwargs): conn_str = "Endpoint=sb://resourcename.servicebus.windows.net/;SharedAccessKeyName=test;sharedaccesskey=THISISATESTKEYXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" with pytest.raises(ValueError) as e: parse_result = parse_connection_string(conn_str) @@ -112,15 +113,33 @@ def test_sb_parse_malformed_conn_str_lowercase_sa_key_name(self, **kwargs): def test_sb_parse_emulator_string(self, **kwargs): conn_str = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" parse_result = parse_connection_string(conn_str) - assert parse_result.endpoint == "sb://localhost" + assert parse_result.endpoint == "sb://localhost/" assert parse_result.fully_qualified_namespace == "localhost" conn_str = "Endpoint=sb://servicebus-emulator;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" parse_result = parse_connection_string(conn_str) - assert parse_result.endpoint == "sb://servicebus-emulator" + assert parse_result.endpoint == "sb://servicebus-emulator/" assert parse_result.fully_qualified_namespace == "servicebus-emulator" conn_str = "Endpoint=sb://192.168.y.z;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" parse_result = parse_connection_string(conn_str) - assert parse_result.endpoint == "sb://192.168.y.z" - assert parse_result.fully_qualified_namespace == "192.168.y.z" \ No newline at end of file + assert parse_result.endpoint == "sb://192.168.y.z/" + assert parse_result.fully_qualified_namespace == "192.168.y.z" + + def test_strip_protocol_from_uri_normalizes_to_bare_host(self, **kwargs): + # strip_protocol_from_uri must reduce any accepted namespace form to the + # bare host - dropping scheme, port, and trailing path - so the value is + # usable as both the AMQP connection host and the SAS/AAD token audience. + # Regression test for issue #44034: the ARM/portal-emitted endpoint + # https://.servicebus.windows.net:443/ previously kept the ":443/", + # producing ServiceBusAuthenticationError (amqp:client-error). + host = "myservicebus.servicebus.windows.net" + assert strip_protocol_from_uri(host) == host # bare host (known-good) + assert strip_protocol_from_uri("sb://" + host) == host # protocol only + assert strip_protocol_from_uri("https://" + host) == host + assert strip_protocol_from_uri("http://" + host) == host + assert strip_protocol_from_uri("sb://" + host + "/") == host # trailing slash + assert strip_protocol_from_uri(host + ":443") == host # port only (no scheme) + assert strip_protocol_from_uri(host + ":443/") == host + assert strip_protocol_from_uri("https://" + host + ":443/") == host # ARM/portal-emitted form + assert strip_protocol_from_uri("sb://" + host + ":5671/") == host From c9854e03dbfec3bd2a1b4729ccdd4406ca63e7d1 Mon Sep 17 00:00:00 2001 From: Pranjal Patel Date: Tue, 14 Jul 2026 17:52:33 -0700 Subject: [PATCH 2/3] [servicebus] Preserve bracketed IPv6 literals when stripping port (#44034) Address PR review: strip_protocol_from_uri truncated bracketed IPv6 hosts (e.g. [fe80::1]) at the first colon inside the address. Port stripping now only applies after the closing ']' for bracketed literals, matching the AMQP transport's to_host_port handling. Added IPv6 regression cases. --- .../azure/servicebus/_common/utils.py | 15 +++++++++++---- .../tests/test_connection_string_parser.py | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py index 685c142b5a73..81147e3539b8 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py @@ -264,10 +264,17 @@ def strip_protocol_from_uri(uri: str) -> str: slash_pos = uri.find("/") if slash_pos != -1: uri = uri[:slash_pos] - # Remove any port. - colon_pos = uri.find(":") - if colon_pos != -1: - uri = uri[:colon_pos] + # Remove any port, while preserving a bracketed IPv6 literal such as + # "[fe80::1]" (whose address contains colons). Only a port following the + # closing "]" should be stripped. + if uri.startswith("["): + bracket_pos = uri.find("]") + if bracket_pos != -1: + uri = uri[: bracket_pos + 1] + else: + colon_pos = uri.find(":") + if colon_pos != -1: + uri = uri[:colon_pos] return uri diff --git a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py index 92a065bf1c60..35912d0bbb76 100644 --- a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py +++ b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py @@ -143,3 +143,9 @@ def test_strip_protocol_from_uri_normalizes_to_bare_host(self, **kwargs): assert strip_protocol_from_uri(host + ":443/") == host assert strip_protocol_from_uri("https://" + host + ":443/") == host # ARM/portal-emitted form assert strip_protocol_from_uri("sb://" + host + ":5671/") == host + + # Bracketed IPv6 literals must be preserved (their address contains + # colons); only a port following the closing "]" is stripped. + assert strip_protocol_from_uri("[fe80::1]") == "[fe80::1]" + assert strip_protocol_from_uri("[fe80::1]:5671") == "[fe80::1]" + assert strip_protocol_from_uri("sb://[fe80::1]:5671/") == "[fe80::1]" From 6c3e5473c1a56bc802de4071eb9e179bc2ca5f72 Mon Sep 17 00:00:00 2001 From: Pranjal Patel Date: Wed, 15 Jul 2026 15:51:39 -0700 Subject: [PATCH 3/3] [servicebus] Preserve emulator port when normalizing fully_qualified_namespace (#44034) Address PR review: the #44034 port strip dropped the development emulator's non-default port (e.g. localhost:5673 -> localhost), causing pyamqp to fall back to 5672. strip_protocol_from_uri now takes a strip_port keyword; the four client/base-handler constructors pass strip_port=use_tls so the emulator (use_tls=False) keeps its port while real namespaces are still reduced to a bare host. Added sync and async regression tests for a non-default emulator port. --- .../azure/servicebus/_base_handler.py | 6 ++-- .../azure/servicebus/_common/utils.py | 21 ++++++------- .../azure/servicebus/_servicebus_client.py | 6 ++-- .../servicebus/aio/_base_handler_async.py | 6 ++-- .../aio/_servicebus_client_async.py | 6 ++-- .../tests/test_connection_string_parser.py | 30 ++++++++++++++----- 6 files changed, 49 insertions(+), 26 deletions(-) diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py index 642597b7702b..31ad3df26f72 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_base_handler.py @@ -252,8 +252,10 @@ def __init__( ) -> None: self._amqp_transport = kwargs.pop("amqp_transport", PyamqpTransport) - # If the user provided http:// or sb://, let's be polite and strip that. - self.fully_qualified_namespace: str = strip_protocol_from_uri(fully_qualified_namespace.strip()) + # Keep the port for the non-TLS emulator; strip scheme/port/path otherwise. + self.fully_qualified_namespace: str = strip_protocol_from_uri( + fully_qualified_namespace.strip(), strip_port=kwargs.get("use_tls", True) + ) self._entity_name = entity_name subscription_name = kwargs.get("subscription_name") diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py index 81147e3539b8..92f6f9e981fc 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py @@ -245,28 +245,29 @@ def transform_outbound_messages( return _convert_to_single_service_bus_message(messages, message_type, to_outgoing_amqp_message) -def strip_protocol_from_uri(uri: str) -> str: - """Reduces a URI to the bare host by removing the protocol (e.g. http:// or - sb://), any port (e.g. :443) and any path (e.g. a trailing /), such as for a FQDN. +def strip_protocol_from_uri(uri: str, *, strip_port: bool = True) -> str: + """Reduce a URI to its bare host by removing the scheme (e.g. sb://) and path. - Azure returns the namespace endpoint as ``https://.servicebus.windows.net:443/``; - the resulting host is used both as the AMQP connection host and as the token - audience, so port and path must be removed. + The port is also removed by default (e.g. the ``:443/`` in the ARM-emitted + ``https://.servicebus.windows.net:443/``). Pass ``strip_port=False`` for + the development emulator, whose non-default port must be kept. :param str uri: The URI to modify. + :keyword bool strip_port: Whether to also remove a trailing port. Defaults to ``True``. :return: The bare host portion of the URI. :rtype: str """ + # Strip the scheme (everything up to and including "//"). left_slash_pos = uri.find("//") if left_slash_pos != -1: uri = uri[left_slash_pos + 2 :] - # Remove any path (trailing slash and everything after it). + # Strip the path (the first "/" and everything after it). slash_pos = uri.find("/") if slash_pos != -1: uri = uri[:slash_pos] - # Remove any port, while preserving a bracketed IPv6 literal such as - # "[fe80::1]" (whose address contains colons). Only a port following the - # closing "]" should be stripped. + if not strip_port: + return uri + # Strip the port, but preserve a bracketed IPv6 literal (e.g. "[fe80::1]"). if uri.startswith("["): bracket_pos = uri.find("]") if bracket_pos != -1: diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_client.py b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_client.py index 86a0be498e3c..fb7a4b202689 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_client.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_servicebus_client.py @@ -138,8 +138,10 @@ def __init__( self._amqp_transport = amqp_transport - # If the user provided http:// or sb://, let's be polite and strip that. - self.fully_qualified_namespace: str = strip_protocol_from_uri(fully_qualified_namespace.strip()) + # Keep the port for the non-TLS emulator; strip scheme/port/path otherwise. + self.fully_qualified_namespace: str = strip_protocol_from_uri( + fully_qualified_namespace.strip(), strip_port=kwargs.get("use_tls", True) + ) self._credential = credential # TODO: can we remove this here? it's recreated in Sender/Receiver diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py index c97787ccb801..bde1a82d6771 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_base_handler_async.py @@ -131,8 +131,10 @@ def __init__( ) -> None: self._amqp_transport = kwargs.pop("amqp_transport", PyamqpTransportAsync) - # If the user provided http:// or sb://, let's be polite and strip that. - self.fully_qualified_namespace: str = strip_protocol_from_uri(fully_qualified_namespace.strip()) + # Keep the port for the non-TLS emulator; strip scheme/port/path otherwise. + self.fully_qualified_namespace: str = strip_protocol_from_uri( + fully_qualified_namespace.strip(), strip_port=kwargs.get("use_tls", True) + ) self._entity_name = entity_name subscription_name = kwargs.get("subscription_name") diff --git a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_client_async.py b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_client_async.py index b0387f27e4c3..1af5e1d28084 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_client_async.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/aio/_servicebus_client_async.py @@ -130,8 +130,10 @@ def __init__( raise ValueError("To use the uAMQP transport, please install `uamqp>=1.6.3,<2.0.0`.") from None self._amqp_transport = amqp_transport - # If the user provided http:// or sb://, let's be polite and strip that. - self.fully_qualified_namespace: str = strip_protocol_from_uri(fully_qualified_namespace.strip()) + # Keep the port for the non-TLS emulator; strip scheme/port/path otherwise. + self.fully_qualified_namespace: str = strip_protocol_from_uri( + fully_qualified_namespace.strip(), strip_port=kwargs.get("use_tls", True) + ) self._credential = credential self._config = Configuration( retry_total=retry_total, diff --git a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py index 35912d0bbb76..c56e07cf382e 100644 --- a/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py +++ b/sdk/servicebus/azure-servicebus/tests/test_connection_string_parser.py @@ -7,14 +7,22 @@ import os import pytest from azure.servicebus import ( + ServiceBusClient, ServiceBusConnectionStringProperties, parse_connection_string, ) +from azure.servicebus.aio import ServiceBusClient as ServiceBusClientAsync from azure.servicebus._common.utils import strip_protocol_from_uri from devtools_testutils import AzureMgmtRecordedTestCase +_EMULATOR_CONN_STR = ( + "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;" + "SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" +) + + class TestServiceBusConnectionStringParser(AzureMgmtRecordedTestCase): def test_sb_conn_str_parse_cs(self, **kwargs): conn_str = "Endpoint=sb://resourcename.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=THISISATESTKEYXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" @@ -127,12 +135,8 @@ def test_sb_parse_emulator_string(self, **kwargs): assert parse_result.fully_qualified_namespace == "192.168.y.z" def test_strip_protocol_from_uri_normalizes_to_bare_host(self, **kwargs): - # strip_protocol_from_uri must reduce any accepted namespace form to the - # bare host - dropping scheme, port, and trailing path - so the value is - # usable as both the AMQP connection host and the SAS/AAD token audience. - # Regression test for issue #44034: the ARM/portal-emitted endpoint - # https://.servicebus.windows.net:443/ previously kept the ":443/", - # producing ServiceBusAuthenticationError (amqp:client-error). + # regression test for issue #44034: scheme, port and trailing path are + # stripped so the ARM-emitted https://...:443/ form yields a bare host host = "myservicebus.servicebus.windows.net" assert strip_protocol_from_uri(host) == host # bare host (known-good) assert strip_protocol_from_uri("sb://" + host) == host # protocol only @@ -144,8 +148,18 @@ def test_strip_protocol_from_uri_normalizes_to_bare_host(self, **kwargs): assert strip_protocol_from_uri("https://" + host + ":443/") == host # ARM/portal-emitted form assert strip_protocol_from_uri("sb://" + host + ":5671/") == host - # Bracketed IPv6 literals must be preserved (their address contains - # colons); only a port following the closing "]" is stripped. + # bracketed IPv6 literals are preserved; only a port after "]" is stripped assert strip_protocol_from_uri("[fe80::1]") == "[fe80::1]" assert strip_protocol_from_uri("[fe80::1]:5671") == "[fe80::1]" assert strip_protocol_from_uri("sb://[fe80::1]:5671/") == "[fe80::1]" + + def test_emulator_nondefault_port_preserved_sync(self, **kwargs): + client = ServiceBusClient.from_connection_string(_EMULATOR_CONN_STR) + try: + assert client.fully_qualified_namespace == "localhost:5673" + finally: + client.close() + + def test_emulator_nondefault_port_preserved_async(self, **kwargs): + client = ServiceBusClientAsync.from_connection_string(_EMULATOR_CONN_STR) + assert client.fully_qualified_namespace == "localhost:5673"