Skip to content

Fix MockTransport not setting elapsed property - #3757

Open
veeceey wants to merge 2 commits into
encode:masterfrom
veeceey:fix/issue-3712-mock-transport-elapsed
Open

veeceey wants to merge 2 commits into
encode:masterfrom
veeceey:fix/issue-3712-mock-transport-elapsed

Conversation

@veeceey

@veeceey veeceey commented Feb 8, 2026

Copy link
Copy Markdown

Summary

Fixes MockTransport to automatically set response.elapsed to timedelta(0) for both sync and async requests, eliminating boilerplate and matching real transport behavior.

Problem

Previously, accessing response.elapsed on MockTransport responses would raise RuntimeError:

RuntimeError: '.elapsed' may only be accessed after the response has been read or closed.

Users had to add boilerplate in every handler:

def handler(request):
    resp = httpx.Response(200, json={"text": "Hello, world!"})
    resp.elapsed = timedelta(0)  # Boilerplate needed
    return resp

Solution

MockTransport now automatically sets response.elapsed = timedelta(0) after calling the handler, in both handle_request() and handle_async_request().

Test Plan

Before fix:

def handler(request):
    return httpx.Response(200, json={"text": "Hello, world!"})

transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)
response = client.get("https://example.com")
print(response.elapsed)  # RuntimeError

After fix:

def handler(request):
    return httpx.Response(200, json={"text": "Hello, world!"})

transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)
response = client.get("https://example.com")
print(response.elapsed)  # 0:00:00 ✓

Tests:

  • Added test_mock_transport_elapsed() for sync clients
  • Added test_async_mock_transport_elapsed() for async clients
  • Both tests verify elapsed == timedelta(0) and type correctness

Related

Fixes #3712

@veeceey

veeceey commented Feb 8, 2026

Copy link
Copy Markdown
Author

All CI passing on Python 3.9-3.13, ready for merge

@veeceey

veeceey commented Feb 19, 2026

Copy link
Copy Markdown
Author

Friendly ping - any chance someone could take a look at this when they get a chance? Happy to make any changes if needed.

veeceey added 2 commits March 10, 2026 06:08
MockTransport now automatically sets response.elapsed to timedelta(0)
for both sync and async requests, matching the behavior of real
transports and eliminating the need for boilerplate code in handlers.

Previously, accessing response.elapsed on MockTransport responses
would raise RuntimeError. Now it returns timedelta(0) by default,
which is the expected behavior for mocked requests with no actual
network I/O.

Fixes encode#3712
Add blank lines after docstrings in MockTransport elapsed tests
to comply with Ruff formatting rules.
@veeceey
veeceey force-pushed the fix/issue-3712-mock-transport-elapsed branch from c7216ac to 324e16a Compare March 10, 2026 13:08
Comment thread httpx/_transports/mock.py
response = self.handler(request)
if not isinstance(response, Response): # pragma: no cover
raise TypeError("Cannot use an async handler in a sync Client")
response.elapsed = datetime.timedelta()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve elapsed values explicitly supplied by the mock handler

This unconditionally replaces timing metadata that the handler already supplied, rather than just defaulting a missing value:

def handler(request):
    response = httpx.Response(200, text="ok")
    response.elapsed = timedelta(seconds=7)
    return response

with httpx.Client(transport=httpx.MockTransport(handler)) as client:
    assert client.get("http://local.invalid/").elapsed == timedelta(seconds=7)

The assertion passes on the base but fails here because elapsed becomes zero. This breaks existing mocks that deliberately simulate response timings, including the explicit assignment shown in #3712. The async assignment has the same regression. Please preserve a supplied elapsed value when adding the default.

Automated review; reproduced through both Client and AsyncClient on Python 3.13.15, base b5addb6 versus head 324e16a.

@ege-arhan ege-arhan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with the fix as it stands.

1. The async regression test never runs. tests/client/test_client.py:485 declares async def test_async_mock_transport_elapsed() without @pytest.mark.anyio. This suite has no anyio_mode/asyncio_mode in pyproject.toml and no anyio_backend fixture in tests/conftest.py, so the coroutine is collected and reported as "async def functions are not natively supported" instead of being executed. Every other coroutine test in the repo carries the marker; this one is the only unmarked async def in the file.

2. The root cause is not MockTransport. Response.elapsed raises because BoundSyncStream.close() (httpx/_client.py:139-159) is the only place that populates _elapsed, and it is never called when the transport hands back a response whose body is already loaded — Response.__init__ calls self.read() for bytes content, and read() short-circuits on the cached _content (httpx/_models.py:876), so response.close()/iter_raw() never runs:

class Slow(BaseTransport):
    def handle_request(self, request):
        time.sleep(0.05)
        return httpx.Response(200, content=b"hello")

r = httpx.Client(transport=Slow()).get("http://x.test/")
print(r.is_closed, r.is_stream_consumed)   # True True
r.elapsed                                  # RuntimeError: '.elapsed' may only be accessed after ...

Any third-party or custom transport returning a preloaded response hits this, with or without the patch.

Two smaller points on the patch itself:

  • httpx/_transports/mock.py:28,45 unconditionally overwrite the value, so a handler that measures its own latency loses it: resp.elapsed = timedelta(seconds=2.5) in the handler comes back as 0:00:00.
  • Issue #3712 asks for a design decision (mock-only duration vs. Response setting elapsed when the body is already materialized), and a maintainer asked to hold PRs until that is settled.

Suggestion: set the elapsed span in Client._send_single_request() when the returned response is already fully read (or in Response.read() for the cached-content path), and leave MockTransport to translate a handler-supplied value rather than overwrite it. Then add the missing @pytest.mark.anyio.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

MockTransport doesn't set the elapsed property

3 participants