Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (#320).

## [0.23.1] - 2026-04-08

Expand Down
41 changes: 40 additions & 1 deletion respx/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
List,
NewType,
Optional,
Set,
Tuple,
Type,
Union,
Expand All @@ -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,
Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down