Skip to content

Commit 12a85bc

Browse files
author
Pranjal Patel
committed
test(servicebus): cover the response half and the uamqp value type
Address review feedback. Nothing tested the response half: every assertion covered what the client sends, while the changelog promises a retryable OperationTimeoutError that rests on the service returning com.microsoft:timeout in errorCondition. Push a fake non-200 response with that condition through mgmt_handlers.default and assert the type, for both transports, with no live namespace. uamqp is the one place the value type changes, since pyamqp uses a plain dict where uamqp uses an AMQPuInt that has not previously gone into application_properties. One test stubs the transport type to prove the property is built from the transport's own AMQP_UINT_VALUE, and one encodes a real message when uamqp is installed. Also drop the redundant outer min in get_server_timeout_ms, which never binds because the buffer is subtracted after the cap, and move the changelog entry to Other Changes since it changes existing operations rather than adding a feature. api.metadata.yml carries the parserVersion bump only. That field is gated by the consistency check and the package still had 0.3.30 while the check generates 0.3.31. apiMdSha256 and pythonVersion are unchanged. Refs: AB#38822503
1 parent e2e1d9d commit 12a85bc

4 files changed

Lines changed: 93 additions & 9 deletions

File tree

‎sdk/servicebus/azure-servicebus/CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
- Added `ServiceBusReceivedMessage.from_bytes()` classmethod to construct a `ServiceBusReceivedMessage` from raw AMQP payload bytes without requiring the deprecated `uamqp` library. ([#43979](https://github.com/Azure/azure-sdk-for-python/issues/43979))
88
- Added `ServiceBusClient.list_queue_sessions()` and `ServiceBusClient.list_subscription_sessions()` (sync and async) to list session IDs for entities with active messages, with optional filtering by session-state update timestamp. The methods return an `ItemPaged[str]` (`AsyncItemPaged[str]` on the async client) so callers can iterate every session transparently or page with `by_page()`. Implements the `com.microsoft:get-message-sessions` management operation. ([#46575](https://github.com/Azure/azure-sdk-for-python/pull/46575))
9-
- Management operations (peek, deferred receive, message settlement over the management link, lock renewal, session state, session listing, schedule/cancel) now send `com.microsoft:server-timeout`: the caller's remaining time less one second, or 60 seconds when none was given. Previously no bound was sent, so a stalled service held the call until the AMQP link failed; it now raises a retryable `OperationTimeoutError`, so a persistently stalled service surfaces after roughly four minutes at default retry settings. Matches the .NET, Java and Go SDKs.
109

1110
### Bugs Fixed
1211

@@ -23,6 +22,7 @@
2322
### Other Changes
2423

2524
- When using the async `AmqpOverWebsocket` transport on Python 3.10 or later, `aiohttp>=3.14.0` is now recommended. Earlier `aiohttp` versions have a WebSocket heartbeat bug ([aio-libs/aiohttp#12030](https://github.com/aio-libs/aiohttp/pull/12030)) that can cause the connection to be dropped during long message processing, surfacing as a `SocketError` ("Cannot write to closing transport"). Python 3.9 users must upgrade Python to install an `aiohttp` release containing this fix. ([#44028](https://github.com/Azure/azure-sdk-for-python/issues/44028))
25+
- Management operations (peek, deferred receive, message settlement over the management link, lock renewal, session state, session listing, schedule/cancel) now send `com.microsoft:server-timeout`: the caller's remaining time less one second, or 60 seconds when none was given. Previously no bound was sent, so a stalled service held the call until the AMQP link failed; it now raises a retryable `OperationTimeoutError`, so a persistently stalled service surfaces after roughly four minutes at default retry settings. Matches the .NET, Java and Go SDKs.
2626

2727
## 7.14.3 (2025-11-11)
2828

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
apiMdSha256: 68c237729c78165bea132f330a2e89519b2e6ba19373d40c48c55f09e8a994de
2-
parserVersion: 0.3.30
2+
parserVersion: 0.3.31
33
pythonVersion: 3.12.10

‎sdk/servicebus/azure-servicebus/azure/servicebus/_common/utils.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ def get_server_timeout_ms(timeout: Optional[float]) -> int:
106106
if timeout is None:
107107
return DEFAULT_SERVER_TIMEOUT_MS
108108
capped = min(timeout, MAX_SERVER_TIMEOUT_MS / 1000)
109-
remaining_ms = int(capped * 1000) - SERVER_TIMEOUT_BUFFER_MS
110-
return min(max(remaining_ms, 0), MAX_SERVER_TIMEOUT_MS)
109+
return max(int(capped * 1000) - SERVER_TIMEOUT_BUFFER_MS, 0)
111110

112111

113112
def build_uri(address, entity):

‎sdk/servicebus/azure-servicebus/tests/unittests/test_server_timeout.py‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@
1717

1818
import pytest
1919

20-
from azure.servicebus._common.constants import MAX_SERVER_TIMEOUT_MS, REQUEST_RESPONSE_TIMEOUT
20+
from azure.servicebus._common import mgmt_handlers
21+
from azure.servicebus._common.constants import (
22+
ERROR_CODE_TIMEOUT,
23+
MAX_SERVER_TIMEOUT_MS,
24+
MGMT_RESPONSE_MESSAGE_ERROR_CONDITION,
25+
REQUEST_RESPONSE_TIMEOUT,
26+
)
2127
from azure.servicebus._common.utils import get_server_timeout_ms
2228
from azure.servicebus._transport._pyamqp_transport import PyamqpTransport
29+
from azure.servicebus.exceptions import OperationTimeoutError
2330

2431

2532
class TestServerTimeoutMillis:
@@ -87,8 +94,7 @@ def fake_create_mgmt_msg(message, application_properties, config, reply_to, **kw
8794
return handler, captured
8895

8996
def test_default_sent_when_caller_gave_no_timeout(self):
90-
# The gap this closes: without a caller timeout the service previously received no bound at
91-
# all.
97+
# The gap this closes: previously no bound was sent at all.
9298
handler, captured = self._make_handler()
9399
handler._mgmt_request_response(b"op", {}, lambda *a: None, timeout=None)
94100
assert captured[REQUEST_RESPONSE_TIMEOUT] == {"TYPE": "UINT", "VALUE": 60000}
@@ -110,8 +116,7 @@ def test_associated_link_name_preserved(self):
110116
assert REQUEST_RESPONSE_TIMEOUT in captured
111117

112118
def test_sent_on_calls_without_an_associated_link(self):
113-
# Operations such as list_sessions pass `keep_alive_associated_link=False` and start from an
114-
# empty property map; they must still be bounded.
119+
# list_sessions passes keep_alive_associated_link=False, starting from an empty map.
115120
handler, captured = self._make_handler()
116121
handler._mgmt_request_response(
117122
b"op",
@@ -123,6 +128,86 @@ def test_sent_on_calls_without_an_associated_link(self):
123128
assert captured == {REQUEST_RESPONSE_TIMEOUT: {"TYPE": "UINT", "VALUE": 60000}}
124129

125130

131+
class TestServiceTimeoutResponse:
132+
"""The response half: the service's answer must surface as a retryable
133+
`OperationTimeoutError`, which rests on `com.microsoft:timeout` in `errorCondition`."""
134+
135+
@staticmethod
136+
def _response(condition):
137+
message = MagicMock()
138+
message.application_properties = {MGMT_RESPONSE_MESSAGE_ERROR_CONDITION: condition}
139+
return message
140+
141+
def test_timeout_condition_raises_retryable_operation_timeout_error(self):
142+
with pytest.raises(OperationTimeoutError) as exc_info:
143+
mgmt_handlers.default(408, self._response(ERROR_CODE_TIMEOUT), "The operation timed out.", PyamqpTransport)
144+
145+
assert exc_info.value._retryable is True
146+
147+
def test_success_returns_the_value_untouched(self):
148+
message = self._response(None)
149+
message.value = {"ok": True}
150+
assert mgmt_handlers.default(200, message, None, PyamqpTransport) == {"ok": True}
151+
152+
def test_uamqp_maps_the_same_condition(self):
153+
uamqp_transport = pytest.importorskip(
154+
"azure.servicebus._transport._uamqp_transport", reason="uamqp not installed"
155+
)
156+
with pytest.raises(OperationTimeoutError):
157+
mgmt_handlers.default(
158+
408,
159+
self._response(ERROR_CODE_TIMEOUT),
160+
"The operation timed out.",
161+
uamqp_transport.UamqpTransport,
162+
)
163+
164+
165+
class TestUamqpValueType:
166+
"""uamqp is the one place the value type changes: pyamqp uses a plain dict, uamqp an `AMQPuInt`."""
167+
168+
def test_transport_supplied_type_is_what_reaches_the_message(self):
169+
170+
from azure.servicebus._base_handler import BaseHandler
171+
172+
sentinel = object()
173+
captured = {}
174+
175+
def fake_create_mgmt_msg(message, application_properties, config, reply_to, **kwargs):
176+
captured.update(application_properties)
177+
return MagicMock()
178+
179+
handler = BaseHandler.__new__(BaseHandler)
180+
handler._amqp_transport = MagicMock()
181+
handler._amqp_transport.create_mgmt_msg = fake_create_mgmt_msg
182+
handler._amqp_transport.AMQP_UINT_VALUE = lambda ms: sentinel
183+
handler._amqp_transport.get_handler_link_name = lambda h: "link-1"
184+
handler._amqp_transport.mgmt_client_request = lambda *args, **kwargs: "response"
185+
handler._amqp_transport.TIMEOUT_ERROR = TimeoutError
186+
handler._open = lambda: None
187+
handler._handler = MagicMock()
188+
handler._config = MagicMock(encoding="UTF-8")
189+
handler._mgmt_target = "queue/$management"
190+
191+
handler._mgmt_request_response(b"op", {}, lambda *a: None, timeout=None)
192+
assert captured[REQUEST_RESPONSE_TIMEOUT] is sentinel
193+
194+
def test_real_uamqp_type_encodes_in_application_properties(self):
195+
uamqp = pytest.importorskip("uamqp", reason="uamqp not installed")
196+
from azure.servicebus._transport._uamqp_transport import UamqpTransport
197+
198+
value = UamqpTransport.AMQP_UINT_VALUE(get_server_timeout_ms(None))
199+
assert isinstance(value, uamqp.types.AMQPuInt)
200+
201+
message = UamqpTransport.create_mgmt_msg(
202+
message={"operation": "peek"},
203+
application_properties={REQUEST_RESPONSE_TIMEOUT: value},
204+
config=MagicMock(encoding="UTF-8"),
205+
reply_to="queue/$management",
206+
)
207+
208+
assert message.encode_message()
209+
210+
126211
class TestAsyncParity:
127212
"""The async management path is a separate implementation and can drift from the sync one."""
128213

0 commit comments

Comments
 (0)