Repository: encode/httpx (python)

Issue:
Authentication flows should preserve history.
If the authentication flow makes more that one request, then any prior requests should be visible in `response.history`.

Pull request:
Record history of requests made during authentication
Fixes #669 

Based on @gdhameeja's work in #686 🌟🙇

The updated tests contain:

- Asserts on `response.history` in the digest auth case.
- An isolated test case using an imaginary auth scheme, for in-depth verification of what gets pushed to the history.

Patch:
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 15defb1..0d68965 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -379,7 +379,7 @@ with additional API for accessing cookies by their domain or path.
 By default, HTTPX will follow redirects for anything except `HEAD` requests.
 
 The `history` property of the response can be used to inspect any followed redirects.
-It contains a list of all any redirect responses that were followed, in the order
+It contains a list of any redirect responses that were followed, in the order
 in which they were made.
 
 For example, GitHub redirects all HTTP requests to HTTPS.
diff --git a/httpx/__init__.py b/httpx/__init__.py
index caaa81a..01f4068 100644
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -17,8 +17,8 @@ from .exceptions import (
     ProtocolError,
     ProxyError,
     ReadTimeout,
-    RedirectBodyUnavailable,
     RedirectLoop,
+    RequestBodyUnavailable,
     ResponseClosed,
     ResponseNotRead,
     StreamConsumed,
@@ -63,8 +63,8 @@ __all__ = [
     "PoolTimeout",
     "ProtocolError",
     "ReadTimeout",
-    "RedirectBodyUnavailable",
     "RedirectLoop",
+    "RequestBodyUnavailable",
     "ResponseClosed",
     "ResponseNotRead",
     "StreamConsumed",
diff --git a/httpx/client.py b/httpx/client.py
index b2c2e5e..75f29ac 100644
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -27,8 +27,8 @@ from .dispatch.proxy_http import HTTPProxy
 from .exceptions import (
     HTTPError,
     InvalidURL,
-    RedirectBodyUnavailable,
     RedirectLoop,
+    RequestBodyUnavailable,
     TooManyRedirects,
 )
 from .models import (
@@ -451,7 +451,7 @@ class AsyncClient:
                 raise RedirectLoop()
 
             response = await self.send_handling_auth(
-                request, auth=auth, timeout=timeout,
+                request, history, auth=auth, timeout=timeout,
             )
             response.history = list(history)
 
@@ -561,12 +561,21 @@ class AsyncClient:
         """
         if method != request.method and method == "GET":
             return None
+
         if not request.stream.can_replay():
-            raise RedirectBodyUnavailable()
+            raise RequestBodyUnavailable(
+                "Got a redirect response, but the request body was streaming "
+                "and is no longer available."
+            )
+
         return request.stream
 
     async def send_handling_auth(
-        self, request: Request, auth: Auth, timeout: Timeout,
+        self,
+        request: Request,
+        history: typing.List[Response],
+        auth: Auth,
+        timeout: Timeout,
     ) -> Response:
         auth_flow = auth(request)
         request = next(auth_flow)
@@ -580,8 +589,10 @@ class AsyncClient:
                 await response.aclose()
                 raise exc from None
             else:
+                response.history = list(history)
+                await response.aread()
                 request = next_request
-                await response.aclose()
+                history.append(response)
 
     async def send_single_request(
         self, request: Request, timeout: Timeout,
diff --git a/httpx/exceptions.py b/httpx/exceptions.py
index 2d8d27d..e199270 100644
--- a/httpx/exceptions.py
+++ b/httpx/exceptions.py
@@ -86,13 +86,6 @@ class TooManyRedirects(RedirectError):
     """
 
 
-class RedirectBodyUnavailable(RedirectError):
-    """
-    Got a redirect response, but the request body was streaming, and is
-    no longer available.
-    """
-
-
 class RedirectLoop(RedirectError):
     """
     Infinite redirect loop.
@@ -117,6 +110,13 @@ class StreamError(HTTPError):
     """
 
 
+class RequestBodyUnavailable(StreamError):
+    """
+    Had to send the request again, but the request body was streaming, and is
+    no longer available.
+    """
+
+
 class StreamConsumed(StreamError):
     """
     Attempted to read or stream response content, but the content has already


Test patch:
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
index d4dd76a..ea6ff8a 100644
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -6,6 +6,7 @@ import typing
 import pytest
 
 from httpx import URL, AsyncClient, DigestAuth, ProtocolError, Request, Response
+from httpx.auth import Auth, AuthFlow
 from httpx.config import CertTypes, TimeoutTypes, VerifyTypes
 from httpx.dispatch.base import Dispatcher
 
@@ -218,6 +219,7 @@ async def test_digest_auth_returns_no_auth_if_no_digest_header_in_response() ->
 
     assert response.status_code == 200
     assert response.json() == {"auth": None}
+    assert len(response.history) == 0
 
 
 @pytest.mark.asyncio
@@ -233,6 +235,7 @@ async def test_digest_auth_200_response_including_digest_auth_header() -> None:
 
     assert response.status_code == 200
     assert response.json() == {"auth": None}
+    assert len(response.history) == 0
 
 
 @pytest.mark.asyncio
@@ -245,6 +248,7 @@ async def test_digest_auth_401_response_without_digest_auth_header() -> None:
 
     assert response.status_code == 401
     assert response.json() == {"auth": None}
+    assert len(response.history) == 0
 
 
 @pytest.mark.parametrize(
@@ -271,6 +275,8 @@ async def test_digest_auth(
     response = await client.get(url, auth=auth)
 
     assert response.status_code == 200
+    assert len(response.history) == 1
+
     authorization = typing.cast(dict, response.json())["auth"]
     scheme, _, fields = authorization.partition(" ")
     assert scheme == "Digest"
@@ -299,6 +305,8 @@ async def test_digest_auth_no_specified_qop() -> None:
     response = await client.get(url, auth=auth)
 
     assert response.status_code == 200
+    assert len(response.history) == 1
+
     authorization = typing.cast(dict, response.json())["auth"]
     scheme, _, fields = authorization.partition(" ")
     assert scheme == "Digest"
@@ -325,7 +333,10 @@ async def test_digest_auth_qop_including_spaces_and_auth_returns_auth(qop: str)
     auth = DigestAuth(username="tomchristie", password="password123")
 
     client = AsyncClient(dispatch=MockDigestAuthDispatch(qop=qop))
-    await client.get(url, auth=auth)
+    response = await client.get(url, auth=auth)
+
+    assert response.status_code == 200
+    assert len(response.history) == 1
 
 
 @pytest.mark.asyncio
@@ -357,6 +368,7 @@ async def test_digest_auth_incorrect_credentials() -> None:
     response = await client.get(url, auth=auth)
 
     assert response.status_code == 401
+    assert len(response.history) == 1
 
 
 @pytest.mark.parametrize(
@@ -381,3 +393,52 @@ async def test_digest_auth_raises_protocol_error_on_malformed_header(
 
     with pytest.raises(ProtocolError):
         await client.get(url, auth=auth)
+
+
+@pytest.mark.asyncio
+async def test_auth_history() -> None:
+    """
+    Test that intermediate requests sent as part of an authentication flow
+    are recorded in the response history.
+    """
+
+    class RepeatAuth(Auth):
+        """
+        A mock authentication scheme that requires clients to send
+        the request a fixed number of times, and then send a last request containing
+        an aggregation of nonces that the server sent in 'WWW-Authenticate' headers
+        of intermediate responses.
+        """
+
+        def __init__(self, repeat: int):
+            self.repeat = repeat
+
+        def __call__(self, request: Request) -> AuthFlow:
+            nonces = []
+
+            for index in range(self.repeat):
+                request.headers["Authorization"] = f"Repeat {index}"
+                response = yield request
+                nonces.append(response.headers["www-authenticate"])
+
+            key = ".".join(nonces)
+            request.headers["Authorization"] = f"Repeat {key}"
+            yield request
+
+    url = "https://example.org/"
+    auth = RepeatAuth(repeat=2)
+    client = AsyncClient(dispatch=MockDispatch(auth_header="abc"))
+
+    response = await client.get(url, auth=auth)
+    assert response.status_code == 200
+    assert response.json() == {"auth": "Repeat abc.abc"}
+
+    assert len(response.history) == 2
+    resp1, resp2 = response.history
+    assert resp1.json() == {"auth": "Repeat 0"}
+    assert resp2.json() == {"auth": "Repeat 1"}
+
+    assert len(resp2.history) == 1
+    assert resp2.history == [resp1]
+
+    assert len(resp1.history) == 0
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
index 663d2de..4c02745 100644
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -7,9 +7,9 @@ from httpx import (
     URL,
     AsyncClient,
     NotRedirectResponse,
-    RedirectBodyUnavailable,
     RedirectLoop,
     Request,
+    RequestBodyUnavailable,
     Response,
     TooManyRedirects,
     codes,
@@ -293,7 +293,7 @@ async def test_cannot_redirect_streaming_body():
     async def streaming_body():
         yield b"Example request body"
 
-    with pytest.raises(RedirectBodyUnavailable):
+    with pytest.raises(RequestBodyUnavailable):
         await client.post(url, data=streaming_body())