99from PowerPlatform .Dataverse .aio .core ._async_http import _AsyncHttpClient
1010
1111
12- def _make_session (status : int = 200 ) -> MagicMock :
13- """Return a mock aiohttp.ClientSession whose request() returns a buffered response."""
14- session = MagicMock (spec = aiohttp .ClientSession )
15- resp = AsyncMock ()
12+ def _make_resp (status : int = 200 ) -> MagicMock :
13+ """Return a mock aiohttp.ClientResponse."""
14+ resp = MagicMock ()
1615 resp .status = status
1716 resp .headers = {}
1817 resp .read = AsyncMock (return_value = b"" )
1918 resp .text = AsyncMock (return_value = "" )
20- session .request = AsyncMock (return_value = resp )
19+ return resp
20+
21+
22+ def _make_cm (resp = None , exc = None ) -> MagicMock :
23+ """Return an async context manager mock.
24+
25+ If exc is given, __aenter__ raises it. Otherwise it returns resp.
26+ """
27+ cm = MagicMock ()
28+ if exc is not None :
29+ cm .__aenter__ = AsyncMock (side_effect = exc )
30+ else :
31+ cm .__aenter__ = AsyncMock (return_value = resp )
32+ cm .__aexit__ = AsyncMock (return_value = False )
33+ return cm
34+
35+
36+ def _make_session (status : int = 200 ) -> MagicMock :
37+ """Return a mock aiohttp.ClientSession whose request() is an async context manager."""
38+ session = MagicMock (spec = aiohttp .ClientSession )
39+ session .request = MagicMock (return_value = _make_cm (_make_resp (status )))
2140 return session
2241
2342
@@ -99,13 +118,11 @@ class TestAsyncHttpClientRetry:
99118 async def test_retries_on_client_error_and_succeeds (self ):
100119 """Retries after a ClientError and returns response on second attempt."""
101120 session = MagicMock (spec = aiohttp .ClientSession )
102- good_resp = AsyncMock ()
103- good_resp .status = 200
104- good_resp .headers = {}
105- good_resp .read = AsyncMock (return_value = b"" )
106- good_resp .text = AsyncMock (return_value = "" )
107-
108- session .request = AsyncMock (side_effect = [aiohttp .ClientConnectionError ("timeout" ), good_resp ])
121+ good_resp = _make_resp (200 )
122+ session .request = MagicMock (side_effect = [
123+ _make_cm (exc = aiohttp .ClientConnectionError ("timeout" )),
124+ _make_cm (good_resp ),
125+ ])
109126 client = _AsyncHttpClient (retries = 2 , backoff = 0 , session = session )
110127 with patch ("asyncio.sleep" , new_callable = AsyncMock ):
111128 result = await client ._request ("get" , "https://example.com/data" )
@@ -116,7 +133,9 @@ async def test_retries_on_client_error_and_succeeds(self):
116133 async def test_raises_after_all_retries_exhausted (self ):
117134 """Raises ClientError after all retry attempts fail."""
118135 session = MagicMock (spec = aiohttp .ClientSession )
119- session .request = AsyncMock (side_effect = aiohttp .ClientConnectionError ("timeout" ))
136+ session .request = MagicMock (
137+ return_value = _make_cm (exc = aiohttp .ClientConnectionError ("timeout" ))
138+ )
120139 client = _AsyncHttpClient (retries = 3 , backoff = 0 , session = session )
121140 with patch ("asyncio.sleep" , new_callable = AsyncMock ):
122141 with pytest .raises (aiohttp .ClientError ):
@@ -125,19 +144,12 @@ async def test_raises_after_all_retries_exhausted(self):
125144 async def test_backoff_delay_between_retries (self ):
126145 """Sleeps with exponential backoff between retry attempts."""
127146 session = MagicMock (spec = aiohttp .ClientSession )
128- good_resp = AsyncMock ()
129- good_resp .status = 200
130- good_resp .headers = {}
131- good_resp .read = AsyncMock (return_value = b"" )
132- good_resp .text = AsyncMock (return_value = "" )
133-
134- session .request = AsyncMock (
135- side_effect = [
136- aiohttp .ClientConnectionError (),
137- aiohttp .ClientConnectionError (),
138- good_resp ,
139- ]
140- )
147+ good_resp = _make_resp (200 )
148+ session .request = MagicMock (side_effect = [
149+ _make_cm (exc = aiohttp .ClientConnectionError ()),
150+ _make_cm (exc = aiohttp .ClientConnectionError ()),
151+ _make_cm (good_resp ),
152+ ])
141153 client = _AsyncHttpClient (retries = 3 , backoff = 1.0 , session = session )
142154 with patch ("asyncio.sleep" , new_callable = AsyncMock ) as mock_sleep :
143155 await client ._request ("get" , "https://example.com/data" )
@@ -151,6 +163,22 @@ async def test_no_retry_on_success(self):
151163 await client ._request ("get" , "https://example.com/data" )
152164 assert session .request .call_count == 1
153165
166+ async def test_retries_on_timeout_error (self ):
167+ """Retries on asyncio.TimeoutError (not a subclass of aiohttp.ClientError)."""
168+ import asyncio
169+ session = MagicMock (spec = aiohttp .ClientSession )
170+ good_resp = _make_resp (200 )
171+ session .request = MagicMock (side_effect = [
172+ _make_cm (exc = asyncio .TimeoutError ()),
173+ _make_cm (good_resp ),
174+ ])
175+ client = _AsyncHttpClient (retries = 2 , backoff = 0 , session = session )
176+ with patch ("asyncio.sleep" , new_callable = AsyncMock ):
177+ result = await client ._request ("get" , "https://example.com/data" )
178+
179+ assert session .request .call_count == 2
180+ assert result is good_resp
181+
154182
155183class TestAsyncHttpClientClose :
156184 """Tests for _AsyncHttpClient.close()."""
@@ -194,15 +222,58 @@ async def test_response_logged_when_logger_set(self):
194222 async def test_error_logged_on_retry (self ):
195223 """Transport errors are logged before each retry."""
196224 session = MagicMock (spec = aiohttp .ClientSession )
197- good_resp = AsyncMock ()
198- good_resp .status = 200
199- good_resp .headers = {}
200- good_resp .read = AsyncMock (return_value = b"" )
201- good_resp .text = AsyncMock (return_value = "" )
202- session .request = AsyncMock (side_effect = [aiohttp .ClientConnectionError (), good_resp ])
225+ good_resp = _make_resp (200 )
226+ session .request = MagicMock (side_effect = [
227+ _make_cm (exc = aiohttp .ClientConnectionError ()),
228+ _make_cm (good_resp ),
229+ ])
203230 mock_logger = MagicMock ()
204231 mock_logger .body_logging_enabled = False
205232 client = _AsyncHttpClient (retries = 2 , backoff = 0 , session = session , logger = mock_logger )
206233 with patch ("asyncio.sleep" , new_callable = AsyncMock ):
207234 await client ._request ("get" , "https://example.com/data" )
208235 mock_logger .log_error .assert_called_once ()
236+
237+ async def test_request_body_logged_from_json_kwarg (self ):
238+ """json= kwarg body is extracted and passed to log_request."""
239+ session = _make_session ()
240+ mock_logger = MagicMock ()
241+ mock_logger .body_logging_enabled = False
242+ client = _AsyncHttpClient (retries = 1 , session = session , logger = mock_logger )
243+ await client ._request ("post" , "https://example.com/data" , json = {"key" : "value" })
244+ _ , log_kwargs = mock_logger .log_request .call_args
245+ assert log_kwargs ["body" ] == {"key" : "value" }
246+
247+ async def test_request_body_logged_from_data_kwarg (self ):
248+ """data= kwarg body is extracted when json= is absent."""
249+ session = _make_session ()
250+ mock_logger = MagicMock ()
251+ mock_logger .body_logging_enabled = False
252+ client = _AsyncHttpClient (retries = 1 , session = session , logger = mock_logger )
253+ await client ._request ("post" , "https://example.com/data" , data = b"raw bytes" )
254+ _ , log_kwargs = mock_logger .log_request .call_args
255+ assert log_kwargs ["body" ] == b"raw bytes"
256+
257+ async def test_response_body_decoded_when_body_logging_enabled (self ):
258+ """When body_logging_enabled=True, resp.text() is awaited and passed to log_response."""
259+ session = _make_session ()
260+ session .request .return_value .__aenter__ .return_value .text = AsyncMock (return_value = '{"ok": true}' )
261+ mock_logger = MagicMock ()
262+ mock_logger .body_logging_enabled = True
263+ client = _AsyncHttpClient (retries = 1 , session = session , logger = mock_logger )
264+ await client ._request ("get" , "https://example.com/data" )
265+ _ , log_kwargs = mock_logger .log_response .call_args
266+ assert log_kwargs ["body" ] == '{"ok": true}'
267+
268+ async def test_response_body_decode_error_is_swallowed (self ):
269+ """If resp.text() raises, body is None and log_response is still called."""
270+ session = _make_session ()
271+ session .request .return_value .__aenter__ .return_value .text = AsyncMock (
272+ side_effect = UnicodeDecodeError ("utf-8" , b"\xff " , 0 , 1 , "invalid start byte" )
273+ )
274+ mock_logger = MagicMock ()
275+ mock_logger .body_logging_enabled = True
276+ client = _AsyncHttpClient (retries = 1 , session = session , logger = mock_logger )
277+ await client ._request ("get" , "https://example.com/data" )
278+ _ , log_kwargs = mock_logger .log_response .call_args
279+ assert log_kwargs ["body" ] is None
0 commit comments