Repository: encode/httpx

Issue:
Make `.elapsed` reflect the entire response time.
I think we should probably switch the behavior of `.elapsed` so that rather than reflecting the "time for the response to start", it is instead only available once `.close()` has been called, and is calculated from the start of the request to the point that the response is closed.

That way it'll actually properly reflect the time taken for the entire request/response cycle.


Discussion:
Two different options here during streaming responses...

* `.elapsed` is not available until the response is closed.
* `.elapsed` is available, but will reflect "elapsed so far", until the response is finally closed.

I *guess* we probably just want to go with the former, as a more constrained option.
We'll also want to note in the requests compat that our `.elapsed` property is different to requests. (Which I *assume* uses time-to-start-response?)

Resolving patch:
diff --git a/httpx/client.py b/httpx/client.py
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -47,7 +47,7 @@
     URLTypes,
 )
 from .status_codes import codes
-from .utils import ElapsedTimer, NetRCInfo, get_environment_proxies, get_logger
+from .utils import NetRCInfo, get_environment_proxies, get_logger
 
 logger = get_logger(__name__)
 
@@ -593,10 +593,7 @@ async def send_single_request(
         dispatcher = self.dispatcher_for_url(request.url)
 
         try:
-            with ElapsedTimer() as timer:
-                response = await dispatcher.send(request, timeout=timeout)
-            response.elapsed = timer.elapsed
-            response.request = request
+            response = await dispatcher.send(request, timeout=timeout)
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
             # there'a already a request attached in the case of
diff --git a/httpx/models.py b/httpx/models.py
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -34,6 +34,7 @@
 )
 from .status_codes import StatusCode
 from .utils import (
+    ElapsedTimer,
     flatten_queryparams,
     guess_json_utf,
     is_known_encoding,
@@ -607,6 +608,7 @@ def __init__(
         else:
             self.stream = encode(data, files, json)
 
+        self.timer = ElapsedTimer()
         self.prepare()
 
     def prepare(self) -> None:
@@ -661,14 +663,12 @@ def __init__(
         stream: ContentStream = None,
         content: bytes = None,
         history: typing.List["Response"] = None,
-        elapsed: datetime.timedelta = None,
     ):
         self.status_code = status_code
         self.http_version = http_version
         self.headers = Headers(headers)
 
         self.request = request
-        self.elapsed = datetime.timedelta(0) if elapsed is None else elapsed
         self.call_next: typing.Optional[typing.Callable] = None
 
         self.history = [] if history is None else list(history)
@@ -677,11 +677,25 @@ def __init__(
             self.is_closed = True
             self.is_stream_consumed = True
             self._raw_content = content or b""
+            self._elapsed = request.timer.elapsed
         else:
             self.is_closed = False
             self.is_stream_consumed = False
             self._raw_stream = stream
 
+    @property
+    def elapsed(self) -> datetime.timedelta:
+        """
+        Returns the time taken for the complete request/response
+        cycle to complete.
+        """
+        if not hasattr(self, "_elapsed"):
+            raise RuntimeError(
+                "'.elapsed' may only be accessed after the response "
+                "has been read or closed."
+            )
+        return self._elapsed
+
     @property
     def reason_phrase(self) -> str:
         return StatusCode.get_reason_phrase(self.status_code)
@@ -932,6 +946,7 @@ async def aclose(self) -> None:
         """
         if not self.is_closed:
             self.is_closed = True
+            self._elapsed = self.request.timer.elapsed
             if hasattr(self, "_raw_stream"):
                 await self._raw_stream.aclose()
 


Test patch:
diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py
--- a/tests/models/test_responses.py
+++ b/tests/models/test_responses.py
@@ -20,13 +20,15 @@ async def async_streaming_body():
     yield b"world!"
 
 
-def test_response():
+@pytest.mark.asyncio
+async def test_response():
     response = httpx.Response(200, content=b"Hello, world!", request=REQUEST)
+
     assert response.status_code == 200
     assert response.reason_phrase == "OK"
     assert response.text == "Hello, world!"
     assert response.request is REQUEST
-    assert response.elapsed == datetime.timedelta(0)
+    assert response.elapsed >= datetime.timedelta(0)
     assert not response.is_error