From 1c0ee4300c770f1aa8dbfda61b5b7c627eb472a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= <4872288+tboerstad@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:09:25 +0100 Subject: [PATCH 1/6] Fix pass_through with HTTP proxy Using pass_through fails when behind a proxy (e.g. corporate network with http_proxy/https_proxy environment variables): $ https_proxy=http://proxy:8080 python >>> with respx.mock: ... respx.route().pass_through() ... httpx.get("https://example.com") httpx.InvalidURL: Invalid port: 8080example.com:443 Bypass CONNECT requests in HTTPCoreMocker to allow proxy tunnel establishment. --- respx/mocks.py | 19 +++++++++++++++++++ tests/test_api.py | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/respx/mocks.py b/respx/mocks.py index cce6365..50a6ddc 100644 --- a/respx/mocks.py +++ b/respx/mocks.py @@ -166,6 +166,10 @@ def _transport_for_url(self, *args, **kwargs): class AbstractRequestMocker(Mocker): + @classmethod + def _should_bypass(cls, **kwargs) -> bool: + return False + @classmethod def mock(cls, spec): if spec.__name__ not in cls.target_methods: @@ -176,6 +180,8 @@ def mock(cls, spec): def mock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) + if cls._should_bypass(**kwargs): + return spec(self, **kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = cls.prepare_sync_request(request, **kwargs) response = cls._send_sync_request( @@ -185,6 +191,8 @@ def mock(self, *args, **kwargs): async def amock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) + if cls._should_bypass(**kwargs): + return await spec(self, **kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = await cls.prepare_async_request(request, **kwargs) response = await cls._send_async_request( @@ -271,6 +279,17 @@ class HTTPCoreMocker(AbstractRequestMocker): ] target_methods = ["handle_request", "handle_async_request"] + @classmethod + def _should_bypass(cls, **kwargs) -> bool: + # Bypass CONNECT requests because we cannot mock proxy tunnels: + # 1. CONNECT URLs lack a scheme, crashing our URL parser. + # 2. Tunnels require a live socket, not a static mock response. + request = kwargs.get("request") + if request is not None: + if request.method in (b"CONNECT", "CONNECT"): + return True + return False + @classmethod def prepare_sync_request(cls, httpx_request, **kwargs): """ diff --git a/tests/test_api.py b/tests/test_api.py index 8aa9903..c6f9beb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -419,6 +419,33 @@ async def test_pass_through(client, using, route, expected): assert request.is_pass_through is expected +async def test_pass_through_with_proxy(): + # Sync + with respx.mock: + route = respx.get("https://foo.bar/").pass_through() + with mock.patch( + "socket.create_connection", side_effect=socket.error("test request blocked") + ) as connect: + with pytest.raises(httpx.NetworkError): + with httpx.Client(proxy="https://1.1.1.1:1") as client: + client.get("https://foo.bar/") + assert connect.called is True + assert route.called is True + + # Async + async with respx.mock: + route = respx.get("https://foo.bar/").pass_through() + with mock.patch( + "anyio.connect_tcp", + side_effect=ConnectionRefusedError("test request blocked"), + ) as open_connection: + with pytest.raises(httpx.NetworkError): + async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: + await client.get("https://foo.bar/") + assert open_connection.called is True + assert route.called is True + + @respx.mock async def test_parallel_requests(client): def content(request, page): From 0f4bc81dcc5701b91c1625cf12ab00ea19d3a806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= Date: Tue, 10 Mar 2026 13:18:21 +0100 Subject: [PATCH 2/6] Route CONNECT requests through the mock system instead of bypassing them CONNECT requests were silently forwarded to the real transport, so there was no way to mock proxy tunnel behavior. This parses them into proper httpx.Request objects so they go through normal route matching like everything else. --- respx/mocks.py | 45 +++++++++++++++++++-------------------------- tests/test_api.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/respx/mocks.py b/respx/mocks.py index 50a6ddc..b320076 100644 --- a/respx/mocks.py +++ b/respx/mocks.py @@ -166,10 +166,6 @@ def _transport_for_url(self, *args, **kwargs): class AbstractRequestMocker(Mocker): - @classmethod - def _should_bypass(cls, **kwargs) -> bool: - return False - @classmethod def mock(cls, spec): if spec.__name__ not in cls.target_methods: @@ -180,8 +176,6 @@ def mock(cls, spec): def mock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) - if cls._should_bypass(**kwargs): - return spec(self, **kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = cls.prepare_sync_request(request, **kwargs) response = cls._send_sync_request( @@ -191,8 +185,6 @@ def mock(self, *args, **kwargs): async def amock(self, *args, **kwargs): kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) - if cls._should_bypass(**kwargs): - return await spec(self, **kwargs) request = cls.to_httpx_request(**kwargs) request, kwargs = await cls.prepare_async_request(request, **kwargs) response = await cls._send_async_request( @@ -279,17 +271,6 @@ class HTTPCoreMocker(AbstractRequestMocker): ] target_methods = ["handle_request", "handle_async_request"] - @classmethod - def _should_bypass(cls, **kwargs) -> bool: - # Bypass CONNECT requests because we cannot mock proxy tunnels: - # 1. CONNECT URLs lack a scheme, crashing our URL parser. - # 2. Tunnels require a live socket, not a static mock response. - request = kwargs.get("request") - if request is not None: - if request.method in (b"CONNECT", "CONNECT"): - return True - return False - @classmethod def prepare_sync_request(cls, httpx_request, **kwargs): """ @@ -321,15 +302,27 @@ def to_httpx_request(cls, **kwargs): if isinstance(request.method, bytes) else request.method ) - raw_url = ( - request.url.scheme, - request.url.host, - request.url.port, - request.url.target, - ) + + if method == "CONNECT": + # CONNECT uses authority-form targets (host:port) for tunnel + # establishment. Build URL from the target so route matching + # works on the remote host. + target = request.url.target + if isinstance(target, bytes): + target = target.decode("ascii") + url = httpx.URL(f"https://{target}/") + else: + raw_url = ( + request.url.scheme, + request.url.host, + request.url.port, + request.url.target, + ) + url = parse_url(raw_url) + return httpx.Request( method, - parse_url(raw_url), + url, headers=request.headers, stream=request.stream, extensions=request.extensions, diff --git a/tests/test_api.py b/tests/test_api.py index c6f9beb..9306b89 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -422,6 +422,7 @@ async def test_pass_through(client, using, route, expected): async def test_pass_through_with_proxy(): # Sync with respx.mock: + connect_route = respx.route(method="CONNECT").pass_through() route = respx.get("https://foo.bar/").pass_through() with mock.patch( "socket.create_connection", side_effect=socket.error("test request blocked") @@ -430,10 +431,12 @@ async def test_pass_through_with_proxy(): with httpx.Client(proxy="https://1.1.1.1:1") as client: client.get("https://foo.bar/") assert connect.called is True + assert connect_route.called is True assert route.called is True # Async async with respx.mock: + connect_route = respx.route(method="CONNECT").pass_through() route = respx.get("https://foo.bar/").pass_through() with mock.patch( "anyio.connect_tcp", @@ -443,9 +446,35 @@ async def test_pass_through_with_proxy(): async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: await client.get("https://foo.bar/") assert open_connection.called is True + assert connect_route.called is True assert route.called is True +async def test_mocked_connect_proxy_response(): + # Sync - mock a 407 Proxy Authentication Required on CONNECT + # GET must pass through so the real proxy code sends CONNECT internally. + with respx.mock: + connect_route = respx.route(method="CONNECT").mock( + return_value=httpx.Response(407) + ) + respx.get("https://foo.bar/").pass_through() + with pytest.raises(httpx.ProxyError): + with httpx.Client(proxy="https://1.1.1.1:1") as client: + client.get("https://foo.bar/") + assert connect_route.called is True + + # Async - mock a 407 Proxy Authentication Required on CONNECT + async with respx.mock: + connect_route = respx.route(method="CONNECT").mock( + return_value=httpx.Response(407) + ) + respx.get("https://foo.bar/").pass_through() + with pytest.raises(httpx.ProxyError): + async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: + await client.get("https://foo.bar/") + assert connect_route.called is True + + @respx.mock async def test_parallel_requests(client): def content(request, page): From 3b8c9be8f6ea70425fe3c3574e0f98cd298f0200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= Date: Sun, 12 Apr 2026 21:57:03 +0200 Subject: [PATCH 3/6] follow up pr feedback --- respx/mocks.py | 26 ++++----------- tests/test_api.py | 56 -------------------------------- tests/test_mock.py | 81 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 78 deletions(-) diff --git a/respx/mocks.py b/respx/mocks.py index b320076..ae0dad7 100644 --- a/respx/mocks.py +++ b/respx/mocks.py @@ -302,27 +302,15 @@ def to_httpx_request(cls, **kwargs): if isinstance(request.method, bytes) else request.method ) - - if method == "CONNECT": - # CONNECT uses authority-form targets (host:port) for tunnel - # establishment. Build URL from the target so route matching - # works on the remote host. - target = request.url.target - if isinstance(target, bytes): - target = target.decode("ascii") - url = httpx.URL(f"https://{target}/") - else: - raw_url = ( - request.url.scheme, - request.url.host, - request.url.port, - request.url.target, - ) - url = parse_url(raw_url) - + raw_url = ( + request.url.scheme, + request.url.host, + request.url.port, + request.url.target if method != "CONNECT" else b"/", + ) return httpx.Request( method, - url, + parse_url(raw_url), headers=request.headers, stream=request.stream, extensions=request.extensions, diff --git a/tests/test_api.py b/tests/test_api.py index 9306b89..8aa9903 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -419,62 +419,6 @@ async def test_pass_through(client, using, route, expected): assert request.is_pass_through is expected -async def test_pass_through_with_proxy(): - # Sync - with respx.mock: - connect_route = respx.route(method="CONNECT").pass_through() - route = respx.get("https://foo.bar/").pass_through() - with mock.patch( - "socket.create_connection", side_effect=socket.error("test request blocked") - ) as connect: - with pytest.raises(httpx.NetworkError): - with httpx.Client(proxy="https://1.1.1.1:1") as client: - client.get("https://foo.bar/") - assert connect.called is True - assert connect_route.called is True - assert route.called is True - - # Async - async with respx.mock: - connect_route = respx.route(method="CONNECT").pass_through() - route = respx.get("https://foo.bar/").pass_through() - with mock.patch( - "anyio.connect_tcp", - side_effect=ConnectionRefusedError("test request blocked"), - ) as open_connection: - with pytest.raises(httpx.NetworkError): - async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: - await client.get("https://foo.bar/") - assert open_connection.called is True - assert connect_route.called is True - assert route.called is True - - -async def test_mocked_connect_proxy_response(): - # Sync - mock a 407 Proxy Authentication Required on CONNECT - # GET must pass through so the real proxy code sends CONNECT internally. - with respx.mock: - connect_route = respx.route(method="CONNECT").mock( - return_value=httpx.Response(407) - ) - respx.get("https://foo.bar/").pass_through() - with pytest.raises(httpx.ProxyError): - with httpx.Client(proxy="https://1.1.1.1:1") as client: - client.get("https://foo.bar/") - assert connect_route.called is True - - # Async - mock a 407 Proxy Authentication Required on CONNECT - async with respx.mock: - connect_route = respx.route(method="CONNECT").mock( - return_value=httpx.Response(407) - ) - respx.get("https://foo.bar/").pass_through() - with pytest.raises(httpx.ProxyError): - async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: - await client.get("https://foo.bar/") - assert connect_route.called is True - - @respx.mock async def test_parallel_requests(client): def content(request, page): diff --git a/tests/test_mock.py b/tests/test_mock.py index 12742f8..70ef475 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,4 +1,6 @@ from contextlib import ExitStack as does_not_raise +import socket +from unittest import mock import httpcore import httpx @@ -473,19 +475,92 @@ def test_add_remove_targets(): assert len(HTTPCoreMocker.targets) == pre_add_count -async def test_proxies(): +@pytest.mark.parametrize("proxy_url", ["http://1.1.1.1:1", "https://1.1.1.1:1"]) +async def test_proxies(proxy_url): with respx.mock: respx.get("https://foo.bar/") % dict(json={"foo": "bar"}) - with httpx.Client(proxy="https://1.1.1.1:1") as client: + with httpx.Client(proxy=proxy_url) as client: response = client.get("https://foo.bar/") assert response.json() == {"foo": "bar"} async with respx.mock: respx.get("https://foo.bar/") % dict(json={"foo": "bar"}) - async with httpx.AsyncClient(proxy="https://1.1.1.1:1") as client: + async with httpx.AsyncClient(proxy=proxy_url) as client: response = await client.get("https://foo.bar/") assert response.json() == {"foo": "bar"} + with respx.mock: + route = respx.route().pass_through() + with mock.patch( + "socket.create_connection", side_effect=socket.error("test request blocked") + ) as connect: + with pytest.raises(httpx.NetworkError): + with httpx.Client(proxy=proxy_url) as client: + client.get("https://foo.bar/") + assert connect.called is True + assert route.called is True + assert route.calls.last.request.method == "CONNECT" + + async with respx.mock: + route = respx.route().pass_through() + with mock.patch( + "anyio.connect_tcp", + side_effect=ConnectionRefusedError("test request blocked"), + ) as open_connection: + with pytest.raises(httpx.NetworkError): + async with httpx.AsyncClient(proxy=proxy_url) as client: + await client.get("https://foo.bar/") + assert open_connection.called is True + assert route.called is True + assert route.calls.last.request.method == "CONNECT" + + with respx.mock: + connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").pass_through() + route = respx.get("https://foo.bar/").pass_through() + with mock.patch( + "socket.create_connection", side_effect=socket.error("test request blocked") + ) as connect: + with pytest.raises(httpx.NetworkError): + with httpx.Client(proxy=proxy_url) as client: + client.get("https://foo.bar/") + assert connect.called is True + assert connect_route.called is True + assert route.called is True + + async with respx.mock: + connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").pass_through() + route = respx.get("https://foo.bar/").pass_through() + with mock.patch( + "anyio.connect_tcp", + side_effect=ConnectionRefusedError("test request blocked"), + ) as open_connection: + with pytest.raises(httpx.NetworkError): + async with httpx.AsyncClient(proxy=proxy_url) as client: + await client.get("https://foo.bar/") + assert open_connection.called is True + assert connect_route.called is True + assert route.called is True + + with respx.mock: + connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").mock( + return_value=httpx.Response(407) + ) + respx.get("https://foo.bar/").pass_through() + with pytest.raises(httpx.ProxyError): + with httpx.Client(proxy=proxy_url) as client: + client.get("https://foo.bar/") + assert connect_route.called is True + + async with respx.mock: + connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").mock( + return_value=httpx.Response(407) + ) + respx.get("https://foo.bar/").pass_through() + with pytest.raises(httpx.ProxyError): + async with httpx.AsyncClient(proxy=proxy_url) as client: + await client.get("https://foo.bar/") + assert connect_route.called is True + async def test_uds(): async with respx.mock: From 533dc906c9c0110d0bc8f3647bcd75fd6e9be3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= Date: Mon, 13 Apr 2026 13:39:16 +0200 Subject: [PATCH 4/6] fix linting issues --- tests/test_mock.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_mock.py b/tests/test_mock.py index 70ef475..26b9475 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,5 +1,5 @@ -from contextlib import ExitStack as does_not_raise import socket +from contextlib import ExitStack as does_not_raise from unittest import mock import httpcore @@ -494,8 +494,8 @@ async def test_proxies(proxy_url): with mock.patch( "socket.create_connection", side_effect=socket.error("test request blocked") ) as connect: - with pytest.raises(httpx.NetworkError): - with httpx.Client(proxy=proxy_url) as client: + with httpx.Client(proxy=proxy_url) as client: + with pytest.raises(httpx.NetworkError): client.get("https://foo.bar/") assert connect.called is True assert route.called is True @@ -507,8 +507,8 @@ async def test_proxies(proxy_url): "anyio.connect_tcp", side_effect=ConnectionRefusedError("test request blocked"), ) as open_connection: - with pytest.raises(httpx.NetworkError): - async with httpx.AsyncClient(proxy=proxy_url) as client: + async with httpx.AsyncClient(proxy=proxy_url) as client: + with pytest.raises(httpx.NetworkError): await client.get("https://foo.bar/") assert open_connection.called is True assert route.called is True @@ -520,8 +520,8 @@ async def test_proxies(proxy_url): with mock.patch( "socket.create_connection", side_effect=socket.error("test request blocked") ) as connect: - with pytest.raises(httpx.NetworkError): - with httpx.Client(proxy=proxy_url) as client: + with httpx.Client(proxy=proxy_url) as client: + with pytest.raises(httpx.NetworkError): client.get("https://foo.bar/") assert connect.called is True assert connect_route.called is True @@ -534,8 +534,8 @@ async def test_proxies(proxy_url): "anyio.connect_tcp", side_effect=ConnectionRefusedError("test request blocked"), ) as open_connection: - with pytest.raises(httpx.NetworkError): - async with httpx.AsyncClient(proxy=proxy_url) as client: + async with httpx.AsyncClient(proxy=proxy_url) as client: + with pytest.raises(httpx.NetworkError): await client.get("https://foo.bar/") assert open_connection.called is True assert connect_route.called is True @@ -546,8 +546,8 @@ async def test_proxies(proxy_url): return_value=httpx.Response(407) ) respx.get("https://foo.bar/").pass_through() - with pytest.raises(httpx.ProxyError): - with httpx.Client(proxy=proxy_url) as client: + with httpx.Client(proxy=proxy_url) as client: + with pytest.raises(httpx.ProxyError): client.get("https://foo.bar/") assert connect_route.called is True @@ -556,8 +556,8 @@ async def test_proxies(proxy_url): return_value=httpx.Response(407) ) respx.get("https://foo.bar/").pass_through() - with pytest.raises(httpx.ProxyError): - async with httpx.AsyncClient(proxy=proxy_url) as client: + async with httpx.AsyncClient(proxy=proxy_url) as client: + with pytest.raises(httpx.ProxyError): await client.get("https://foo.bar/") assert connect_route.called is True From 3564c1824b61a5c7db718a828cd48bb2d855462b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= Date: Mon, 13 Apr 2026 13:57:44 +0200 Subject: [PATCH 5/6] formatter --- tests/test_mock.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_mock.py b/tests/test_mock.py index 26b9475..117b4bd 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -515,7 +515,9 @@ async def test_proxies(proxy_url): assert route.calls.last.request.method == "CONNECT" with respx.mock: - connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").pass_through() + connect_route = respx.route( + method="CONNECT", url=f"{proxy_url}/" + ).pass_through() route = respx.get("https://foo.bar/").pass_through() with mock.patch( "socket.create_connection", side_effect=socket.error("test request blocked") @@ -528,7 +530,9 @@ async def test_proxies(proxy_url): assert route.called is True async with respx.mock: - connect_route = respx.route(method="CONNECT", url=f"{proxy_url}/").pass_through() + connect_route = respx.route( + method="CONNECT", url=f"{proxy_url}/" + ).pass_through() route = respx.get("https://foo.bar/").pass_through() with mock.patch( "anyio.connect_tcp", From 12590d1cfb64ac9033d2f6a773ff5b83055a005d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=B8rstad?= Date: Mon, 13 Apr 2026 13:59:52 +0200 Subject: [PATCH 6/6] add pragma --- tests/test_mock.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_mock.py b/tests/test_mock.py index 117b4bd..6f1118c 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -495,7 +495,7 @@ async def test_proxies(proxy_url): "socket.create_connection", side_effect=socket.error("test request blocked") ) as connect: with httpx.Client(proxy=proxy_url) as client: - with pytest.raises(httpx.NetworkError): + with pytest.raises(httpx.NetworkError): # pragma: no branch client.get("https://foo.bar/") assert connect.called is True assert route.called is True @@ -508,7 +508,7 @@ async def test_proxies(proxy_url): side_effect=ConnectionRefusedError("test request blocked"), ) as open_connection: async with httpx.AsyncClient(proxy=proxy_url) as client: - with pytest.raises(httpx.NetworkError): + with pytest.raises(httpx.NetworkError): # pragma: no branch await client.get("https://foo.bar/") assert open_connection.called is True assert route.called is True @@ -523,7 +523,7 @@ async def test_proxies(proxy_url): "socket.create_connection", side_effect=socket.error("test request blocked") ) as connect: with httpx.Client(proxy=proxy_url) as client: - with pytest.raises(httpx.NetworkError): + with pytest.raises(httpx.NetworkError): # pragma: no branch client.get("https://foo.bar/") assert connect.called is True assert connect_route.called is True @@ -539,7 +539,7 @@ async def test_proxies(proxy_url): side_effect=ConnectionRefusedError("test request blocked"), ) as open_connection: async with httpx.AsyncClient(proxy=proxy_url) as client: - with pytest.raises(httpx.NetworkError): + with pytest.raises(httpx.NetworkError): # pragma: no branch await client.get("https://foo.bar/") assert open_connection.called is True assert connect_route.called is True