diff --git a/sdk/servicebus/azure-servicebus/CHANGELOG.md b/sdk/servicebus/azure-servicebus/CHANGELOG.md index 1fdc4a3f450a..7a37a5ee9c76 100644 --- a/sdk/servicebus/azure-servicebus/CHANGELOG.md +++ b/sdk/servicebus/azure-servicebus/CHANGELOG.md @@ -14,6 +14,7 @@ - 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 closing a `PEEK_LOCK` receiver did not release messages that had been prefetched into the client buffer or were still in flight, so they remained locked at the broker until lock expiry — delaying their redelivery and inflating their delivery count. On close, a non-session `PEEK_LOCK` receiver now drains the link (stopping the broker and flushing in-flight transfers) and releases the buffered messages (`released` disposition), so the broker can redeliver them immediately without incrementing the delivery count. ([#42917](https://github.com/Azure/azure-sdk-for-python/issues/42917)) - Fixed a bug where the async pure-Python AMQP transport failed to connect with `[Errno 22] Invalid argument` (`amqp:socket-error`) inside containerized/virtualized environments such as Docker Desktop on macOS. The transport no longer reads back and re-applies platform-negotiated TCP options (e.g. `TCP_MAXSEG`) that some platforms reject via `setsockopt`. ([#45394](https://github.com/Azure/azure-sdk-for-python/issues/45394)) +- 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)) ### Other Changes 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 ebc6b4452852..92f6f9e981fc 100644 --- a/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py +++ b/sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py @@ -245,15 +245,37 @@ 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: - """Removes the protocol (e.g. http:// or sb://) from a URI, such as the 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. + + 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. - :return: The URI without the protocol. + :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: - return uri[left_slash_pos + 2 :] + uri = uri[left_slash_pos + 2 :] + # Strip the path (the first "/" and everything after it). + slash_pos = uri.find("/") + if slash_pos != -1: + uri = uri[:slash_pos] + 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: + 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/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 956a67eaccfc..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,23 @@ 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 -class ServiceBusConnectionStringParserTests(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=" parse_result = parse_connection_string(conn_str) @@ -103,7 +112,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 +121,45 @@ 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): + # 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 + 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 + + # 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"