From 9ea9734346d996d2a6cac60f5ccf388f51cf7fa6 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sat, 5 Sep 2026 16:56:48 +0100 Subject: [PATCH 1/2] Consult nested mocks innermost-first on the httpx backend respx keeps every started router in a single process-wide list and tries them in registration order. Each MockVWS router ends with a catch-all, so an outer mock always answered and a nested inner mock was never consulted -- a request to the inner mock's own URL was refused. Move each newly started router to the front of respx's list so nesting resolves innermost-first, as it already does on the requests and httpx2 backends, whose patches form a LIFO stack. Fixes #3544 Co-Authored-By: Claude Opus 5 (1M context) --- newsfragments/3544.change | 1 + src/mock_vws/_respx_mock_server/decorators.py | 25 +++++++++++++++++++ tests/mock_vws/test_respx_mock_usage.py | 25 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 newsfragments/3544.change diff --git a/newsfragments/3544.change b/newsfragments/3544.change new file mode 100644 index 000000000..05e23f498 --- /dev/null +++ b/newsfragments/3544.change @@ -0,0 +1 @@ +Fix nested ``MockVWS`` instances on the ``httpx`` backend: an inner mock is now the only one which answers while it is running, matching the ``requests`` and ``httpx2`` backends. Previously the outer mock kept answering and requests to the inner mock's URL were refused. diff --git a/src/mock_vws/_respx_mock_server/decorators.py b/src/mock_vws/_respx_mock_server/decorators.py index c9a59e6c8..298d71ae4 100644 --- a/src/mock_vws/_respx_mock_server/decorators.py +++ b/src/mock_vws/_respx_mock_server/decorators.py @@ -7,6 +7,7 @@ import httpx import respx +from respx.mocks import Mocker from mock_vws._mock_common import RequestData, Route @@ -122,6 +123,29 @@ def callback(request: httpx.Request) -> httpx.Response: return callback +def _make_router_first_consulted(*, router: respx.MockRouter) -> None: + """Make a started router the first one ``respx`` consults. + + ``respx`` keeps every started router in a single process-wide list and + tries them in the order they were started, so an outer mock would answer + requests which an inner, more recently started, mock was created for. + The catch-all route means the outer mock always answers, so the inner one + would never be consulted at all. + + Moving the newest router to the front of that list makes nested mocks + resolve innermost-first, matching the ``requests`` and ``httpx2`` + backends, whose patches form a LIFO stack. + + Args: + router: A started router to consult before any other. + """ + # ``respx.Router.start`` looks its mocker up by name, and this router is + # created with the default name, so the lookup cannot fail. + mocker = Mocker.registry[router.using or ""] + mocker.routers.remove(router) + mocker.routers.insert(0, router) + + def start_respx_router( *, mock_vws_api: _APIHandler, @@ -183,4 +207,5 @@ def start_respx_router( router.route().mock(side_effect=_block_unmatched) router.start() + _make_router_first_consulted(router=router) return router diff --git a/tests/mock_vws/test_respx_mock_usage.py b/tests/mock_vws/test_respx_mock_usage.py index 9d5bc9d2f..eb87fb53d 100644 --- a/tests/mock_vws/test_respx_mock_usage.py +++ b/tests/mock_vws/test_respx_mock_usage.py @@ -124,6 +124,31 @@ def test_add_get_and_delete_target( with pytest.raises(expected_exception=UnknownTargetError): client.get_target_record(target_id=target_id) + @staticmethod + def test_nested_mocks() -> None: + """A mock inside another mock leaves the outer one working. + + The innermost mock is the only one which answers while it is + running, which is what the ``requests`` and ``httpx2`` backends do + too, and the outer mock answers again once the inner one has + stopped. + """ + outer_url = "https://vws.vuforia.com/summary" + inner_url = "https://vuforia.vws.example.com/summary" + + with MockVWS(): + with MockVWS(base_vws_url="https://vuforia.vws.example.com"): + inner_response = httpx.get(url=inner_url, timeout=30) + with pytest.raises(expected_exception=httpx.ConnectError): + httpx.get(url=outer_url, timeout=30) + outer_response = httpx.get(url=outer_url, timeout=30) + + with pytest.raises(expected_exception=httpx.ConnectError): + httpx.get(url=inner_url, timeout=30) + + assert inner_response.status_code == HTTPStatus.UNAUTHORIZED + assert outer_response.status_code == HTTPStatus.UNAUTHORIZED + class TestCloudRecoService: """Synchronous cloud query usage through the mock via ``httpx``.""" From 624c9e8a4969834dca016c333f6c61e58ea3d629 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 6 Sep 2026 00:28:11 +0100 Subject: [PATCH 2/2] Inline the router reordering instead of an impure helper Co-Authored-By: Claude Opus 5 (1M context) --- src/mock_vws/_respx_mock_server/decorators.py | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/mock_vws/_respx_mock_server/decorators.py b/src/mock_vws/_respx_mock_server/decorators.py index 298d71ae4..b1a09a163 100644 --- a/src/mock_vws/_respx_mock_server/decorators.py +++ b/src/mock_vws/_respx_mock_server/decorators.py @@ -123,29 +123,6 @@ def callback(request: httpx.Request) -> httpx.Response: return callback -def _make_router_first_consulted(*, router: respx.MockRouter) -> None: - """Make a started router the first one ``respx`` consults. - - ``respx`` keeps every started router in a single process-wide list and - tries them in the order they were started, so an outer mock would answer - requests which an inner, more recently started, mock was created for. - The catch-all route means the outer mock always answers, so the inner one - would never be consulted at all. - - Moving the newest router to the front of that list makes nested mocks - resolve innermost-first, matching the ``requests`` and ``httpx2`` - backends, whose patches form a LIFO stack. - - Args: - router: A started router to consult before any other. - """ - # ``respx.Router.start`` looks its mocker up by name, and this router is - # created with the default name, so the lookup cannot fail. - mocker = Mocker.registry[router.using or ""] - mocker.routers.remove(router) - mocker.routers.insert(0, router) - - def start_respx_router( *, mock_vws_api: _APIHandler, @@ -207,5 +184,20 @@ def start_respx_router( router.route().mock(side_effect=_block_unmatched) router.start() - _make_router_first_consulted(router=router) + + # ``respx`` keeps every started router in a single process-wide list and + # tries them in the order they were started, so an outer mock would + # answer requests which an inner, more recently started, mock was created + # for. The catch-all route above means the outer mock always answers, so + # the inner one would never be consulted at all. + # + # Moving the newest router to the front of that list makes nested mocks + # resolve innermost-first, matching the ``requests`` and ``httpx2`` + # backends, whose patches form a LIFO stack. ``respx.Router.start`` + # looks its mocker up by name, and this router is created with the + # default name, so the lookup cannot fail. + mocker = Mocker.registry[router.using or ""] + mocker.routers.remove(router) + mocker.routers.insert(0, router) + return router