Repository: encode/httpx
Pull request: response.elapsed now reflects entire request/response time.
Couple of further tweaks on top of #687, with thanks to @gdhameeja ✨

* `Response(content=...)` is a closed response and should have `response.elapsed == datetime.timedelta(0)`.
* `._elapsed` should only be set when the response is closed. Any further calls to `.close` should have no effect.

Closes #655

Problem statement:
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.

Hints from issue 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?)

Commit message: Response instantiated with content should have elapsed==0

Patch to review:
diff --git a/docs/api.md b/docs/api.md
index 7762c05947..9061824a93 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -56,8 +56,7 @@
 * `.cookies` - **Cookies**
 * `.history` - **List[Response]**
 * `.elapsed` - **[timedelta](https://docs.python.org/3/library/datetime.html)**
-  * The amount of time elapsed between sending the first byte and parsing the headers (not including time spent reading
-  the response).  Use
+  * The amount of time elapsed between sending the request and calling `close()` on the corresponding response received for that request.
   [total_seconds()](https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds) to correctly get
   the total elapsed seconds.
 * `def .raise_for_status()` - **None**
diff --git a/httpx/client.py b/httpx/client.py
index be9c881a36..936c646e24 100644
--- 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__)
 
@@ -616,11 +616,9 @@ async def send_single_request(
         dispatcher = self.dispatcher_for_url(request.url)
 
         try:
-            with ElapsedTimer() as timer:
-                response = await dispatcher.send(
-                    request, verify=verify, cert=cert, timeout=timeout
-                )
-            response.elapsed = timer.elapsed
+            response = await dispatcher.send(
+                request, verify=verify, cert=cert, timeout=timeout
+            )
             response.request = request
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
diff --git a/httpx/models.py b/httpx/models.py
index 0a44d3258e..52be6817e8 100644
--- 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:
@@ -667,14 +669,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)
@@ -683,11 +683,25 @@ def __init__(
             self.is_closed = True
             self.is_stream_consumed = True
             self._raw_content = content or b""
+            self._elapsed = datetime.timedelta(0)
         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)
@@ -938,6 +952,7 @@ async def close(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()
 
diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py
index 3133e03dd4..c0f616d61a 100644
--- a/tests/models/test_responses.py
+++ b/tests/models/test_responses.py
@@ -20,8 +20,10 @@ 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!"


Code review comments:
diff_hunk: @@ -683,11 +683,25 @@ def __init__(
             self.is_closed = True
             self.is_stream_consumed = True
             self._raw_content = content or b""
+            self._elapsed = datetime.timedelta(0)
path: httpx/models.py
text: @user1:
Shouldn't this be `request.timer.elapsed`? Otherwise we don't account for the time spent receiving the response headers in the raw-bytes case.

@author:
Ah that'd be better yup. As it *happens* we'll always use `stream=...` for actual network responses, it's really only the "mock response instance" case that we use `content=...` for, but yes it makes sense to account for the time properly in that case too.