Skip to content

MockTransport doesn't set the elapsed property #3712

Description

@ionelmc

This is not a nice solution:

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

If you have dispatch code in that handler that can easily get boilerplatey.

I think httpx should do either of these:

  • MockTransport take the duration as an optional argument
  • httpx provide a decorator, so you can wrap your handler in it
  • automatically set a duration of 0 for manually created Response objects - why error out if the data is already there?

Activity

  1. added a commit that references this issue on Dec 9, 2025
    0baeaf2
  2. karpetrosyan commented on Dec 10, 2025

    @karpetrosyan
    Contributor

    This issue already has two PRs with proposed fixes, but for now I suggest a very simple way to fix it without extra boilerplate code. We can close the PRs until we make a good design decision around this.

    You can write a very simple wrapper around that mock transport and use it instead of the default one. Then you can handle the elapsed time for mocked responses however you want, like this.

    from __future__ import annotations
    
    from datetime import timedelta
    
    import httpx
    
    
    class CustomMock(httpx.BaseTransport):
        def __init__(self, next_transport: httpx.BaseTransport | None = None) -> None:
            self._next_transport = httpx.MockTransport() if next_transport is None else next_transport
    
        def handle_request(self, request: httpx.Request) -> httpx.Response:
            response = self._next_transport.handle_request(request)
            response.elapsed = timedelta()
            return response
    
    # use custom mock instead of the default mock
  3. added a commit that references this issue on Jan 7, 2026
    d4d9883
  4. added a commit that references this issue on Feb 8, 2026
    313a4f4
  5. ege-arhan commented on Oct 23, 2026

    @ege-arhan

    The same gap is not limited to MockTransport — it hits any transport that hands back a Response whose body is already loaded:

    import httpx
    
    class Preloaded(httpx.BaseTransport):
        def handle_request(self, request):
            return httpx.Response(200, content=b"ok", request=request)
    
    with httpx.Client(transport=Preloaded()) as client:
        r = client.get("http://example.org/")
        print(r.is_closed, r.is_stream_consumed)   # True True
        print(r.elapsed)
    
    # RuntimeError: '.elapsed' may only be accessed after the response has been read or closed.

    Same with MockTransport + client.stream(...) when the handler returns a response built with content=, so #3757 does not cover the general case either.

    Root cause on master (0.28.1, httpx/_client.py:139-159): BoundSyncStream.close() is the only place that sets _elapsed, and it never runs for a preloaded body. Response.__init__ sets _content, so Response.read() (httpx/_models.py:876) short-circuits and no byte iterator, hence no close(), is ever created:

    def read(self) -> bytes:
        if not hasattr(self, "_content"):
            self._content = b"".join(self.iter_bytes())
        return self._content

    So a response that is fully buffered from the start is the one case where is_closed/is_stream_consumed become True without _elapsed existing — the two flags the error message points at are both already True, which makes the message misleading.

    Two other spots feed the same complaint:

    • httpx/_transports/mock.py:28,45 returns the handler's response unmodified, so a handler that does set resp.elapsed = timedelta(seconds=2.5) has no effect once the client wraps it — the value you set is silently replaced by the transport round-trip time (measured 0:00:00).
    • nothing sets _elapsed when the transport raises or when send() returns before reading, which is the 0 case people work around manually.

    Given the design discussion in #3712, the minimal consistent rule would be: set _elapsed at send() return time for responses that are already buffered, and keep BoundSyncStream.close() for streamed ones. That makes .elapsed available for every closed response without asking handlers to compute timings.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions