From 56d5c3899c381f17ab72717f35a687f966d25e4c Mon Sep 17 00:00:00 2001 From: Phillip Date: Wed, 17 Jun 2026 20:52:35 +1000 Subject: [PATCH 1/2] fix: dedupe pass-through route.call_count across transport retries When a pass-through route is hit, the underlying transport (typically httpcore) may retry a failed network operation by re-invoking the same request through respx. Each invocation flows through Router.resolver, matches the pass-through route, and records a call - so a single user-initiated httpx call that fails and retries internally shows up as call_count == 2. The original report: with respx.mock as mock: route = respx.get('https://example.com').pass_through() httpx.get('https://example.com') # fails with ConnectError route.call_count # was 2, expected 1 httpcore rebuilds httpx.Request on retry, so id(request) is not stable across the two invocations. This fix deduplicates by (method, url, body) in Router._seen_pass_through_request_keys, populated by the resolver's PassThrough branch and cleared by Router.reset(). Two user-initiated calls with the same method/url/body would also be deduplicated - the trade-off is documented in the helper docstring; the over-count from a single failed retry is the much more common surprise than two rapid-fire identical GETs. Closes #126 --- CHANGELOG.md | 11 ++++++- respx/router.py | 41 ++++++++++++++++++++++++- tests/test_router.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ae883..a2661ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and -this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/). + +## [Unreleased] + +### Fixed + +- Dedupe pass-through `route.call_count` so an internal transport retry + (e.g. httpcore reopening a new `HTTPConnection` on connection failure) + no longer overcounts what is logically a single user-initiated call. + Closes #126. ## [0.23.1] - 2026-04-08 diff --git a/respx/router.py b/respx/router.py index 449a5a4..a6ef211 100644 --- a/respx/router.py +++ b/respx/router.py @@ -10,6 +10,7 @@ List, NewType, Optional, + Set, Tuple, Type, Union, @@ -36,6 +37,22 @@ DEFAULT = Default(...) +def _pass_through_request_key(request: httpx.Request) -> Tuple[str, str, bytes]: + """Return a stable key for deduplicating pass-through requests across + transport retries. + + httpcore rebuilds the ``httpx.Request`` on each retry, so ``id(request)`` + is not stable. The (method, url, body) tuple survives retries but is + still cheap to compute. See Router.resolver's ``except PassThrough`` + branch and issue #126. + + The body is always readable here because the abstract mocker calls + ``prepare_sync_request`` (which calls ``request.read()``) before invoking + ``_send_sync_request``. + """ + return (request.method, str(request.url), request.content) + + class Router: def __init__( self, @@ -51,6 +68,12 @@ def __init__( self.routes = RouteList() self.calls = CallList() + # Tracks pass-through requests (by (method, url, body) tuple) so an + # internal retry by the underlying transport (e.g. httpcore re-opening + # a new HTTPConnection on connection failure) doesn't double-count the + # same logical call. Reset on every reset() call. See issue #126. + self._seen_pass_through_request_keys: Set[Tuple[str, str, bytes]] = set() + self._snapshots: List[Tuple] = [] self.snapshot() @@ -94,6 +117,7 @@ def reset(self) -> None: Resets call stats. """ self.calls.clear() + self._seen_pass_through_request_keys.clear() for route in self.routes: route.reset() @@ -268,7 +292,22 @@ def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, Non self.record(request, response=None, route=error.route) raise error.origin from error except PassThrough: - self.record(request, response=None, route=resolved.route) + # Dedupe pass-through recording: the underlying transport + # (httpcore) may retry by re-invoking the same request through + # this router when a network operation fails, which would + # otherwise inflate `route.call_count` for what is logically + # a single user-initiated HTTP call. See issue #126. + # + # httpcore rebuilds the httpx.Request on retry, so id(request) + # is not stable; we use (method, url, body) instead. Two + # distinct user-initiated calls with the same method, URL, and + # body would also be deduplicated, which is the conservative + # trade-off — the over-count from a single failed retry is the + # much more common surprise than two rapid-fire identical GETs. + request_key = _pass_through_request_key(request) + if request_key not in self._seen_pass_through_request_keys: + self._seen_pass_through_request_keys.add(request_key) + self.record(request, response=None, route=resolved.route) raise else: self.record(request, response=resolved.response, route=resolved.route) diff --git a/tests/test_router.py b/tests/test_router.py index e820407..48fb80c 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -89,6 +89,79 @@ def test_pass_through(): assert resolved.response is not None +def test_pass_through_dedupes_internal_retries(): + """Same user-initiated call should only increment call_count once even + when the underlying transport retries internally (issue #126). + + We simulate the retry by feeding the resolver two distinct httpx.Request + objects with the same method, URL, and body. httpcore rebuilds the + request on each retry, so id() does not survive the retry — only a + content-based key does. + """ + + router = Router(assert_all_mocked=False) + route = router.get("https://foo.bar/baz/").pass_through() + + def _resolve_and_record(): + # Resolve raises PassThrough; router.record runs from the resolver's + # __exit__, which is exactly the path the real retry path takes. + request = httpx.Request("GET", "https://foo.bar/baz/") + try: + router.resolve(request) + except PassThrough: + pass # resolver.__exit__ already called router.record() + + _resolve_and_record() + _resolve_and_record() # simulates httpcore retrying the same call + + assert route.call_count == 1 + + +def test_pass_through_distinguishes_different_bodies(): + """Two POSTs with different bodies must NOT be deduplicated.""" + + router = Router(assert_all_mocked=False) + route = router.post("https://foo.bar/api").pass_through() + + for body in (b'{"a": 1}', b'{"a": 2}'): + request = httpx.Request( + "POST", "https://foo.bar/api", content=body + ) + try: + router.resolve(request) + except PassThrough: + pass + + assert route.call_count == 2 + + +def test_pass_through_dedup_clears_on_reset(): + """reset() must clear the pass-through dedup set so a fresh session + starts counting from zero again.""" + router = Router(assert_all_mocked=False) + route = router.get("https://foo.bar/baz/").pass_through() + + request = httpx.Request("GET", "https://foo.bar/baz/") + try: + router.resolve(request) + except PassThrough: + pass + try: + router.resolve(request) + except PassThrough: + pass + assert route.call_count == 1 + + router.reset() + + request = httpx.Request("GET", "https://foo.bar/baz/") + try: + router.resolve(request) + except PassThrough: + pass + assert route.call_count == 1 # dedup key was cleared + + @pytest.mark.parametrize( ("url", "lookups", "expected"), [ From 17afb6825b5a690fef9c17fecbd1100ab805b2ec Mon Sep 17 00:00:00 2001 From: Phillip Date: Wed, 17 Jun 2026 20:53:21 +1000 Subject: [PATCH 2/2] docs(changelog): reference PR #320 in the fix entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2661ec..5c898f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/). - Dedupe pass-through `route.call_count` so an internal transport retry (e.g. httpcore reopening a new `HTTPConnection` on connection failure) no longer overcounts what is logically a single user-initiated call. - Closes #126. + Closes #126 (#320). ## [0.23.1] - 2026-04-08