From 9439c3fe312ec0143d785b1297410d96bbbaf574 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Tue, 25 Feb 2025 18:53:43 +0000 Subject: [PATCH 01/10] fix for issue 2983 --- httpx/_client.py | 12 ++++++++++ httpx/_transports/default.py | 43 ++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/httpx/_client.py b/httpx/_client.py index 2249231f8c..f57e9c4f6f 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -1366,6 +1366,9 @@ def __init__( timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, follow_redirects: bool = False, limits: Limits = DEFAULT_LIMITS, + handle_disconnects: bool = True, + reduce_disconnects: bool = True, + reduce_timeout_factor: int = 2, max_redirects: int = DEFAULT_MAX_REDIRECTS, event_hooks: None | (typing.Mapping[str, list[EventHook]]) = None, base_url: URL | str = "", @@ -1407,6 +1410,9 @@ def __init__( http2=http2, limits=limits, transport=transport, + handle_disconnects=handle_disconnects, + reduce_disconnects=reduce_disconnects, + reduce_timeout_factor=reduce_timeout_factor, ) self._mounts: dict[URLPattern, AsyncBaseTransport | None] = { @@ -1438,6 +1444,9 @@ def _init_transport( http2: bool = False, limits: Limits = DEFAULT_LIMITS, transport: AsyncBaseTransport | None = None, + handle_disconnects: bool = True, + reduce_disconnects: bool = True, + reduce_timeout_factor: int = 2, ) -> AsyncBaseTransport: if transport is not None: return transport @@ -1449,6 +1458,9 @@ def _init_transport( http1=http1, http2=http2, limits=limits, + handle_disconnects=handle_disconnects, + reduce_disconnects=reduce_disconnects, + reduce_timeout_factor=reduce_timeout_factor ) def _init_proxy_transport( diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index d5aa05ff23..d6327654b6 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -290,9 +290,16 @@ def __init__( local_address: str | None = None, retries: int = 0, socket_options: typing.Iterable[SOCKET_OPTION] | None = None, + handle_disconnects: bool = True, + reduce_disconnects: bool = True, + reduce_timeout_factor: int = 2, ) -> None: import httpcore + self.handle_disconnects = handle_disconnects + self.reduce_disconnects = reduce_disconnects + self.reduce_timeout_factor = reduce_timeout_factor + proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) @@ -390,8 +397,18 @@ async def handle_async_request( content=request.stream, extensions=request.extensions, ) - with map_httpcore_exceptions(): - resp = await self._pool.handle_async_request(req) + + try: + with map_httpcore_exceptions(): + resp = await self._pool.handle_async_request(req) + except RemoteProtocolError as e: + if format("%s" % e) != "Server disconnected" or not self.handle_disconnects: + raise + print("handling error %s, attempting reconnection" % e) + await self.areconnect() + with map_httpcore_exceptions(): + resp = await self._pool.handle_async_request(req) + print("Reconnection Attempt Successful") assert isinstance(resp.stream, typing.AsyncIterable) @@ -402,5 +419,27 @@ async def handle_async_request( extensions=resp.extensions, ) + async def areconnect(self) -> None: + import httpcore + + await self._pool.aclose() + + if self.reduce_disconnects: + print("Attempt to reduce future disconnects by reducing timeout by a facotr of %d" % self.reduce_timeout_factor) + self._pool._keepalive_expiry //= self.reduce_timeout_factor + + self._pool = httpcore.AsyncConnectionPool( + ssl_context=self._pool._ssl_context, # Reuse existing SSL context + max_connections=self._pool._max_connections, + max_keepalive_connections=self._pool._max_keepalive_connections, + keepalive_expiry=self._pool._keepalive_expiry, + http1=self._pool._http1, + http2=self._pool._http2, + uds=self._pool._uds, + local_address=self._pool._local_address, + retries=self._pool._retries, + socket_options=self._pool._socket_options, + ) + async def aclose(self) -> None: await self._pool.aclose() From ad86f2df7fbac56f7fa1571367ed86eb2516f125 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Tue, 25 Feb 2025 21:31:49 +0000 Subject: [PATCH 02/10] remove full reinit --- httpx/_client.py | 2 +- httpx/_transports/default.py | 38 +++++++++++++----------------------- test | 1 + 3 files changed, 16 insertions(+), 25 deletions(-) create mode 100644 test diff --git a/httpx/_client.py b/httpx/_client.py index f57e9c4f6f..038b1a5f20 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -1460,7 +1460,7 @@ def _init_transport( limits=limits, handle_disconnects=handle_disconnects, reduce_disconnects=reduce_disconnects, - reduce_timeout_factor=reduce_timeout_factor + reduce_timeout_factor=reduce_timeout_factor, ) def _init_proxy_transport( diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index d6327654b6..9a55675b55 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -397,14 +397,13 @@ async def handle_async_request( content=request.stream, extensions=request.extensions, ) - + try: with map_httpcore_exceptions(): resp = await self._pool.handle_async_request(req) - except RemoteProtocolError as e: - if format("%s" % e) != "Server disconnected" or not self.handle_disconnects: - raise - print("handling error %s, attempting reconnection" % e) + except RemoteProtocolError: + if not self.handle_disconnects: + raise await self.areconnect() with map_httpcore_exceptions(): resp = await self._pool.handle_async_request(req) @@ -420,26 +419,17 @@ async def handle_async_request( ) async def areconnect(self) -> None: - import httpcore - await self._pool.aclose() - - if self.reduce_disconnects: - print("Attempt to reduce future disconnects by reducing timeout by a facotr of %d" % self.reduce_timeout_factor) - self._pool._keepalive_expiry //= self.reduce_timeout_factor - - self._pool = httpcore.AsyncConnectionPool( - ssl_context=self._pool._ssl_context, # Reuse existing SSL context - max_connections=self._pool._max_connections, - max_keepalive_connections=self._pool._max_keepalive_connections, - keepalive_expiry=self._pool._keepalive_expiry, - http1=self._pool._http1, - http2=self._pool._http2, - uds=self._pool._uds, - local_address=self._pool._local_address, - retries=self._pool._retries, - socket_options=self._pool._socket_options, - ) + + if not self.reduce_disconnects or self._pool._keepalive_expiry is None: + return + print( + "Attempt to reduce future disconnects \ +by reducing timeout by a facotr of %d" + % self.reduce_timeout_factor + ) + self._pool._keepalive_expiry //= self.reduce_timeout_factor + async def aclose(self) -> None: await self._pool.aclose() diff --git a/test b/test new file mode 100644 index 0000000000..a7c01bc6a4 --- /dev/null +++ b/test @@ -0,0 +1 @@ +# TLS secrets log file, generated by OpenSSL / Python From 3e233aa795dd485f22a9ab9df38965f44e728cb9 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Tue, 25 Feb 2025 21:49:20 +0000 Subject: [PATCH 03/10] add tests for created functions --- tests/client/test_async_client.py | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 8d7eaa3c58..aed86006ec 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -373,3 +373,115 @@ async def test_server_extensions(server): response = await client.get(url) assert response.status_code == 200 assert response.extensions["http_version"] == b"HTTP/1.1" + +async def test_areconnect_reduce_disconnects_and_expiry_present(capfd): + """ + Test when reduce_disconnects is True and keepalive_expiry is not None. + Verifies that keepalive_expiry is reduced and print statement is executed. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool._keepalive_expiry = 60 # Set a non-None keepalive_expiry + transport = AsyncHTTPTransport(reduce_disconnects=True) + transport._pool = mock_pool + transport.reduce_timeout_factor = 2 + + await transport.areconnect() + + mock_pool.aclose.assert_called_once() + assert mock_pool._keepalive_expiry == 30 # 60 // 2 + captured = capfd.readouterr() + assert "Attempt to reduce future disconnects" in captured.out + assert "by reducing timeout by a facotr of 2" in captured.out + + +async def test_areconnect_reduce_disconnects_false(): + """ + Test when reduce_disconnects is False. + Verifies that keepalive_expiry is not reduced. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool._keepalive_expiry = 60 + transport = AsyncHTTPTransport(reduce_disconnects=False) + transport._pool = mock_pool + original_expiry = mock_pool._keepalive_expiry + + await transport.areconnect() + + mock_pool.aclose.assert_called_once() + assert mock_pool._keepalive_expiry == original_expiry # Expiry should remain unchanged + + +async def test_areconnect_no_keepalive_expiry(): + """ + Test when _keepalive_expiry is None. + Verifies that keepalive_expiry is not reduced. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool._keepalive_expiry = None # Set keepalive_expiry to None + transport = AsyncHTTPTransport(reduce_disconnects=True) # reduce_disconnects is True, but should not matter + transport._pool = mock_pool + original_expiry = mock_pool._keepalive_expiry + + await transport.areconnect() + + mock_pool.aclose.assert_called_once() + assert mock_pool._keepalive_expiry is original_expiry # Expiry should remain None + + +# -------------------- Tests for handle_async_request method (lines 422-431) -------------------- + +async def test_handle_async_request_remote_protocol_error_handle_disconnects_true(capfd): + """ + Test RemoteProtocolError with handle_disconnects=True. + Verifies reconnection attempt and successful second request. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool.handle_async_request.side_effect = [ + httpcore.RemoteProtocolError("Simulated RemoteProtocolError"), + AsyncMock(spec=httpcore.Response), # Mock successful response on retry + ] + transport = AsyncHTTPTransport(handle_disconnects=True) + transport._pool = mock_pool + + request = httpx.Request("GET", "http://example.com") + await transport.handle_async_request(request) + + assert mock_pool.handle_async_request.call_count == 2 # Called twice (initial + retry) + mock_pool.aclose.assert_called_once() # areconnect() should call aclose + captured = capfd.readouterr() + assert "Reconnection Attempt Successful" in captured.out + + +async def test_handle_async_request_remote_protocol_error_handle_disconnects_false(): + """ + Test RemoteProtocolError with handle_disconnects=False. + Verifies exception is re-raised. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool.handle_async_request.side_effect = httpcore.RemoteProtocolError("Simulated RemoteProtocolError") + transport = AsyncHTTPTransport(handle_disconnects=False) + transport._pool = mock_pool + + request = httpx.Request("GET", "http://example.com") + with pytest.raises(httpx.RemoteProtocolError, match="Simulated RemoteProtocolError"): + await transport.handle_async_request(request) + + mock_pool.handle_async_request.assert_called_once() # Called only once, no retry + mock_pool.aclose.assert_not_called() # areconnect() and thus aclose() should not be called + + +async def test_handle_async_request_no_error(): + """ + Test handle_async_request when no RemoteProtocolError occurs. + Verifies normal execution path. + """ + mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) + mock_pool.handle_async_request.return_value = AsyncMock(spec=httpcore.Response) # Mock successful response + transport = AsyncHTTPTransport(handle_disconnects=True) # handle_disconnects doesn't matter here + transport._pool = mock_pool + + request = httpx.Request("GET", "http://example.com") + await transport.handle_async_request(request) + + mock_pool.handle_async_request.assert_called_once() # Called once for the initial request + mock_pool.aclose.assert_not_called() # areconnect() should not be called \ No newline at end of file From 972056ad2636c33260ba057042247ad14cec9e6e Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Tue, 25 Feb 2025 22:04:51 +0000 Subject: [PATCH 04/10] simplify tests --- tests/client/test_async_client.py | 115 ++---------------------------- 1 file changed, 5 insertions(+), 110 deletions(-) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index aed86006ec..eadc26dcd2 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -374,114 +374,9 @@ async def test_server_extensions(server): assert response.status_code == 200 assert response.extensions["http_version"] == b"HTTP/1.1" -async def test_areconnect_reduce_disconnects_and_expiry_present(capfd): - """ - Test when reduce_disconnects is True and keepalive_expiry is not None. - Verifies that keepalive_expiry is reduced and print statement is executed. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool._keepalive_expiry = 60 # Set a non-None keepalive_expiry - transport = AsyncHTTPTransport(reduce_disconnects=True) - transport._pool = mock_pool - transport.reduce_timeout_factor = 2 - - await transport.areconnect() - - mock_pool.aclose.assert_called_once() - assert mock_pool._keepalive_expiry == 30 # 60 // 2 - captured = capfd.readouterr() - assert "Attempt to reduce future disconnects" in captured.out - assert "by reducing timeout by a facotr of 2" in captured.out - - -async def test_areconnect_reduce_disconnects_false(): - """ - Test when reduce_disconnects is False. - Verifies that keepalive_expiry is not reduced. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool._keepalive_expiry = 60 - transport = AsyncHTTPTransport(reduce_disconnects=False) - transport._pool = mock_pool - original_expiry = mock_pool._keepalive_expiry - - await transport.areconnect() - - mock_pool.aclose.assert_called_once() - assert mock_pool._keepalive_expiry == original_expiry # Expiry should remain unchanged - - -async def test_areconnect_no_keepalive_expiry(): - """ - Test when _keepalive_expiry is None. - Verifies that keepalive_expiry is not reduced. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool._keepalive_expiry = None # Set keepalive_expiry to None - transport = AsyncHTTPTransport(reduce_disconnects=True) # reduce_disconnects is True, but should not matter - transport._pool = mock_pool - original_expiry = mock_pool._keepalive_expiry - - await transport.areconnect() - - mock_pool.aclose.assert_called_once() - assert mock_pool._keepalive_expiry is original_expiry # Expiry should remain None - - -# -------------------- Tests for handle_async_request method (lines 422-431) -------------------- - -async def test_handle_async_request_remote_protocol_error_handle_disconnects_true(capfd): - """ - Test RemoteProtocolError with handle_disconnects=True. - Verifies reconnection attempt and successful second request. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool.handle_async_request.side_effect = [ - httpcore.RemoteProtocolError("Simulated RemoteProtocolError"), - AsyncMock(spec=httpcore.Response), # Mock successful response on retry - ] - transport = AsyncHTTPTransport(handle_disconnects=True) - transport._pool = mock_pool - - request = httpx.Request("GET", "http://example.com") - await transport.handle_async_request(request) - - assert mock_pool.handle_async_request.call_count == 2 # Called twice (initial + retry) - mock_pool.aclose.assert_called_once() # areconnect() should call aclose - captured = capfd.readouterr() - assert "Reconnection Attempt Successful" in captured.out - - -async def test_handle_async_request_remote_protocol_error_handle_disconnects_false(): - """ - Test RemoteProtocolError with handle_disconnects=False. - Verifies exception is re-raised. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool.handle_async_request.side_effect = httpcore.RemoteProtocolError("Simulated RemoteProtocolError") - transport = AsyncHTTPTransport(handle_disconnects=False) - transport._pool = mock_pool - - request = httpx.Request("GET", "http://example.com") - with pytest.raises(httpx.RemoteProtocolError, match="Simulated RemoteProtocolError"): - await transport.handle_async_request(request) - - mock_pool.handle_async_request.assert_called_once() # Called only once, no retry - mock_pool.aclose.assert_not_called() # areconnect() and thus aclose() should not be called - - -async def test_handle_async_request_no_error(): - """ - Test handle_async_request when no RemoteProtocolError occurs. - Verifies normal execution path. - """ - mock_pool = AsyncMock(spec=httpcore.AsyncConnectionPool) - mock_pool.handle_async_request.return_value = AsyncMock(spec=httpcore.Response) # Mock successful response - transport = AsyncHTTPTransport(handle_disconnects=True) # handle_disconnects doesn't matter here - transport._pool = mock_pool - +@pytest.mark.anyio +async def test_received_RemoteProtocolError(): + transport = httpx.AsyncHTTPTransport(handle_disconnects=True) request = httpx.Request("GET", "http://example.com") - await transport.handle_async_request(request) - - mock_pool.handle_async_request.assert_called_once() # Called once for the initial request - mock_pool.aclose.assert_not_called() # areconnect() should not be called \ No newline at end of file + with pytest.raises(httpx.RemoteProtocolError): + await transport.handle_async_request(request) \ No newline at end of file From 8bf97293cf9eb26126de4c4bef4d0dc86fc333ff Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 03:39:40 +0000 Subject: [PATCH 05/10] passing tests --- tests/client/test_async_client.py | 111 ++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index eadc26dcd2..15ecf36e06 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -1,3 +1,4 @@ +""" from __future__ import annotations import typing @@ -6,6 +7,109 @@ import pytest import httpx +""" + +import typing +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest + +import httpx +from httpx._config import Limits +from httpx._transports.default import AsyncHTTPTransport + + +@pytest.mark.anyio +async def test_areconnect_reduce_disconnects_false(server): + """Test areconnect when reduce_disconnects is False.""" + transport = AsyncHTTPTransport( + http2=True, + reduce_disconnects=False, + limits=httpx.Limits( + keepalive_expiry=100, + ), + ) + transport._pool = AsyncMock() # Mock the pool + transport._pool.aclose = AsyncMock() + transport._pool._keepalive_expiry = 60.0 + + await transport.areconnect() + assert transport._pool._keepalive_expiry == 60.0 # Should remain unchanged + transport._pool.aclose.assert_called_once() + + +@pytest.mark.anyio +async def test_areconnect_keepalive_expiry_none(server): + """Test areconnect when keepalive_expiry is None.""" + limits = Limits(keepalive_expiry=None) + transport = AsyncHTTPTransport(http2=True, limits=limits) + transport._pool = AsyncMock() # Mock the pool + transport._pool.aclose = AsyncMock() + transport._pool._keepalive_expiry = None + + await transport.areconnect() + assert transport._pool._keepalive_expiry is None # Should remain None + transport._pool.aclose.assert_called_once() + + +@pytest.mark.anyio +async def test_aexit_exception_mapping(): + """Test that httpcore exceptions during __aexit__ are mapped.""" + import httpcore + + transport = AsyncHTTPTransport() + transport._pool = AsyncMock() + # Configure the mock to raise a specific httpcore exception. + transport._pool.__aexit__ = AsyncMock( + side_effect=httpcore.ConnectError("Mocked ConnectError") + ) + + with pytest.raises(httpx.ConnectError) as exc_info: + async with transport: + pass # The exception will occur during the 'async with' exit. + + assert "Mocked ConnectError" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_remote_protocol_error_reconnect_handling_disabled(server): + """ + If we set the handle_disconnects parameter to false, it will not + attempt to recover from httpcore.RemoteProtocolError exceptions + """ + import httpcore + + transport = AsyncHTTPTransport(handle_disconnects=False) + transport._pool = AsyncMock() + transport._pool.handle_async_request = AsyncMock( + side_effect=httpcore.RemoteProtocolError("Mocked protocol error") + ) + + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(httpx.RemoteProtocolError): + await client.get(server.url) + + +@pytest.mark.anyio +async def test_remote_protocol_error_successfull_reconnect(server): + """ + If httpcore.RemoteProtocolError is rised but reconnections are + set it will try to reconnect once and return normally if it's successful + """ + import httpcore + + transport = AsyncHTTPTransport() + transport._pool = AsyncMock() + transport._pool.handle_async_request = AsyncMock( + side_effect=[ + httpcore.RemoteProtocolError("Mocked protocol error"), + httpcore.Response(200), + ] + ) + async with httpx.AsyncClient(transport=transport) as client: + response = await client.get(server.url) + assert response.status_code == 200 @pytest.mark.anyio @@ -373,10 +477,3 @@ async def test_server_extensions(server): response = await client.get(url) assert response.status_code == 200 assert response.extensions["http_version"] == b"HTTP/1.1" - -@pytest.mark.anyio -async def test_received_RemoteProtocolError(): - transport = httpx.AsyncHTTPTransport(handle_disconnects=True) - request = httpx.Request("GET", "http://example.com") - with pytest.raises(httpx.RemoteProtocolError): - await transport.handle_async_request(request) \ No newline at end of file From 52eb29d093613990297e8f7d744c390abd1ef4f0 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 04:09:14 +0000 Subject: [PATCH 06/10] update docs --- docs/async.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/async.md b/docs/async.md index 089d783191..6e3dad9e72 100644 --- a/docs/async.md +++ b/docs/async.md @@ -189,6 +189,26 @@ async def main(): anyio.run(main, backend='trio') ``` +## Handling Server Disconnects + +In rare cases where the `keep_alive` value of the destination is shorter than the client a `RemoteProtocolException` may be thrown. With `handle_disconnects` set to True a reconnection will be attempeted. If the reconnect is successful and `reduce_disconnects` is set to True it will attempt to reduce future disconencts by reducing the `keep_alive` value of the client. The factor at whcih the keep_alive is reduced can be set by setting reduce_timeout_factor + +```python +import httpx +import trio + +async def main(): + async with httpx.AsyncClient( + handle_disconnects=True, + reduce_disconnects=True, + reduce_timeout_factor=2 + ) as client: + response = await client.get('https://www.example.com/') + print(response) + +trio.run(main) +``` + ## Calling into Python Web Apps For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport). \ No newline at end of file From cd931dda3943c181228d92637ce2f5fdf2ce2012 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 04:20:48 +0000 Subject: [PATCH 07/10] add in support for testing second failure --- tests/client/test_async_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 15ecf36e06..fccebe4c5f 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -111,6 +111,25 @@ async def test_remote_protocol_error_successfull_reconnect(server): response = await client.get(server.url) assert response.status_code == 200 +@pytest.mark.anyio +async def test_remote_protocol_error_failure_reconnect(server): + """ + If httpcore.RemoteProtocolError is rised but reconnections are + set it will try to reconnect once and return normally if it's successful + """ + import httpcore + + transport = AsyncHTTPTransport() + transport._pool = AsyncMock() + transport._pool.handle_async_request = AsyncMock( + side_effect=[ + httpcore.RemoteProtocolError("Mocked protocol error"), + httpcore.RemoteProtocolError("Mocked protocol error"), + ] + ) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(httpx.RemoteProtocolError): + await client.get(server.url) @pytest.mark.anyio async def test_get(server): From 12919e10e18e3e733d2576db03567221ef559413 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 04:22:41 +0000 Subject: [PATCH 08/10] formatting fix --- httpx/_transports/default.py | 1 - tests/client/test_async_client.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index 9a55675b55..009481db44 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -430,6 +430,5 @@ async def areconnect(self) -> None: ) self._pool._keepalive_expiry //= self.reduce_timeout_factor - async def aclose(self) -> None: await self._pool.aclose() diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index fccebe4c5f..5bb2ef99e1 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -111,6 +111,7 @@ async def test_remote_protocol_error_successfull_reconnect(server): response = await client.get(server.url) assert response.status_code == 200 + @pytest.mark.anyio async def test_remote_protocol_error_failure_reconnect(server): """ @@ -131,6 +132,7 @@ async def test_remote_protocol_error_failure_reconnect(server): with pytest.raises(httpx.RemoteProtocolError): await client.get(server.url) + @pytest.mark.anyio async def test_get(server): url = server.url From 7070db1bd098d4c63c96d36869cb6784b8e8be02 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 13:29:57 +0000 Subject: [PATCH 09/10] correct failing tests --- tests/client/test_async_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 5bb2ef99e1..5ed0440f73 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -308,7 +308,7 @@ async def test_100_continue(server): async def test_context_managed_transport(): class Transport(httpx.AsyncBaseTransport): def __init__(self) -> None: - self.events: list[str] = [] + self.events: typing.List[str] = [] async def aclose(self): # The base implementation of httpx.AsyncBaseTransport just @@ -341,7 +341,7 @@ async def test_context_managed_transport_and_mount(): class Transport(httpx.AsyncBaseTransport): def __init__(self, name: str) -> None: self.name: str = name - self.events: list[str] = [] + self.events: typing.List[str] = [] async def aclose(self): # The base implementation of httpx.AsyncBaseTransport just From 3efb42a406440df863ebda8a4ba0b28d735df235 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 26 Feb 2025 13:34:22 +0000 Subject: [PATCH 10/10] test desciption update --- tests/client/test_async_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 5ed0440f73..3317893f37 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -116,7 +116,7 @@ async def test_remote_protocol_error_successfull_reconnect(server): async def test_remote_protocol_error_failure_reconnect(server): """ If httpcore.RemoteProtocolError is rised but reconnections are - set it will try to reconnect once and return normally if it's successful + set it will try to reconnect once and return raised exception on second failure """ import httpcore