Repository: encode/httpx

Issue:
h11._util.RemoteProtocolError: can't handle event type ConnectionClosed when role=SERVER and state=SEND_RESPONSE
I intermittently got this error when load testing uvicorn endpoint.

This error comes from a proxy endpoint where I am also using `encode/http3` to perform HTTP client calls.

```
  File "/project/venv/lib/python3.7/site-packages/http3/client.py", line 365, in post
    timeout=timeout,
  File "/project/venv/lib/python3.7/site-packages/http3/client.py", line 497, in request
    timeout=timeout,
  File "/project/venv/lib/python3.7/site-packages/http3/client.py", line 112, in send
    allow_redirects=allow_redirects,
  File "/project/venv/lib/python3.7/site-packages/http3/client.py", line 145, in send_handling_redirects
    request, verify=verify, cert=cert, timeout=timeout
  File "/project/venv/lib/python3.7/site-packages/http3/dispatch/connection_pool.py", line 121, in send
    raise exc
  File "/project/venv/lib/python3.7/site-packages/http3/dispatch/connection_pool.py", line 116, in send
    request, verify=verify, cert=cert, timeout=timeout
  File "/project/venv/lib/python3.7/site-packages/http3/dispatch/connection.py", line 59, in send
    response = await self.h11_connection.send(request, timeout=timeout)
  File "/project/venv/lib/python3.7/site-packages/http3/dispatch/http11.py", line 65, in send
    event = await self._receive_event(timeout)
  File "/project/venv/lib/python3.7/site-packages/http3/dispatch/http11.py", line 109, in _receive_event
    event = self.h11_state.next_event()
  File "/project/venv/lib/python3.7/site-packages/h11/_connection.py", line 439, in next_event
    exc._reraise_as_remote_protocol_error()
  File "/project/venv/lib/python3.7/site-packages/h11/_util.py", line 72, in _reraise_as_remote_protocol_error
    raise self
  File "/project/venv/lib/python3.7/site-packages/h11/_connection.py", line 422, in next_event
    self._process_event(self.their_role, event)
  File "/project/venv/lib/python3.7/site-packages/h11/_connection.py", line 238, in _process_event
    self._cstate.process_event(role, type(event), server_switch_event)
  File "/project/venv/lib/python3.7/site-packages/h11/_state.py", line 238, in process_event
    self._fire_event_triggered_transitions(role, event_type)
  File "/project/venv/lib/python3.7/site-packages/h11/_state.py", line 253, in _fire_event_triggered_transitions
    .format(event_type.__name__, role, self.states[role]))
h11._util.RemoteProtocolError: can't handle event type ConnectionClosed when role=SERVER and state=SEND_RESPONSE
```

Discussion:
toppk: > Thanks for this! Overall and after reading through the digging in #96, this looks sensible to me.
> 
> Some notes:
> 
>     * I'm not able to reproduce the results I got in [#96 (comment)](https://github.com/encode/httpx/issues/96#issuecomment-544193625). Could it be that somehow the issue is not surfacing on 3.8 / Uvicorn 0.10+?

I saw your comment, and I didn't understand why this would cause an exception at all.  I assumed you were doing something else, like killing the server at the right moment, as mentioned in earlier comments by others.

> 
>     * A `premature_close`-type endpoint does yield the exact same `RemoteProtocolError` than the one listed in #96:
> 
> 
> ```python
> >>> import httpx
> >>>httpx.get("http://localhost:8000/premature_close")
> Traceback (most recent call last):
>   File "<stdin>", line 1, in <module>
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/api.py", line 125, in get
>     return request(
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/api.py", line 84, in request
>     return client.request(
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/client.py", line 845, in request
>     return self.send(
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/client.py", line 908, in send
>     response.read()
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/models.py", line 1097, in read
>     self._content = b"".join([part for part in self.stream()])
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/models.py", line 1097, in <listcomp>
>     self._content = b"".join([part for part in self.stream()])
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/models.py", line 1108, in stream
>     for chunk in self.raw():
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/models.py", line 1136, in raw
>     for part in self._raw_stream:
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/concurrency/base.py", line 159, in iterate
>     yield self.run(async_iterator.__anext__)
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/concurrency/asyncio.py", line 260, in run
>     return self.loop.run_until_complete(coroutine(*args, **kwargs))
>   File "/Users/florimond/.pyenv/versions/3.8.0/lib/python3.8/asyncio/base_events.py", line 608, in run_until_complete
>     return future.result()
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/dispatch/http11.py", line 152, in _receive_response_data
>     event = await self._receive_event(timeout)
>   File "/Users/florimond/Developer/python-projects/httpx/httpx/dispatch/http11.py", line 164, in _receive_event
>     event = self.h11_state.next_event()
>   File "/Users/florimond/Developer/python-projects/httpx/venv/lib/python3.8/site-packages/h11/_connection.py", line 420, in next_event
>     event = self._extract_next_receive_event()
>   File "/Users/florimond/Developer/python-projects/httpx/venv/lib/python3.8/site-packages/h11/_connection.py", line 369, in _extract_next_receive_event
>     event = self._reader.read_eof()
>   File "/Users/florimond/Developer/python-projects/httpx/venv/lib/python3.8/site-packages/h11/_readers.py", line 169, in read_eof
>     raise RemoteProtocolError(
> h11._util.RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read)
> ```
> 
> This error would be caught and handled by this PR though (it raises a `ConnectionClosed` error), so I think we're on a good track anyway. I haven't followed the whole story though so I'll let others make a more informed call. :)


toppk: address some issues with original pr.

* renamed the test case
* learned about url.copy_with(path="")
* removed the sleep from the "bad" app.

Other then not really understanding how @florimondmanca test case ever triggered, I'm confident this PR addresses an uncaught exception issue.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cea654..3734b1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
+## 0.7.8 (November 17, 2019)
+
+### Added
+
+- Add support for proxy tunnels for Python 3.6 + asyncio. (Pull #521)
+
+## 0.7.7 (November 15, 2019)
+
+### Fixed
+
+- Resolve an issue with cookies behavior on redirect requests. (Pull #529)
+
+### Added
+
+- Add request/response DEBUG logs. (Pull #502)
+- Use TRACE log level for low level info. (Pull #500)
+
 ## 0.7.6 (November 2, 2019)
 
 ### Removed
diff --git a/docs/advanced.md b/docs/advanced.md
index 3e73f5b..1365645 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -179,6 +179,21 @@ password example-password
 ...
 ```
 
+## Unix Domain Sockets
+
+You can configure an `httpx` client to connect through a unix domain socket via the `uds` parameter. This is useful when making requests to a server that is bound to a socket file rather than an IP address.
+
+Here's an example requesting the Docker Engine API:
+
+```python
+import httpx
+
+
+with httpx.Client(uds="/var/run/docker.sock") as client:
+    # This request will connect through the socket file.
+    resp = client.get("http://localhost/version")
+```
+
 ## HTTP Proxying
 
 HTTPX supports setting up proxies the same way that Requests does via the `proxies` parameter.
diff --git a/docs/async.md b/docs/async.md
index 45620bf..c631d54 100644
--- a/docs/async.md
+++ b/docs/async.md
@@ -105,3 +105,38 @@ trio.run(main)
 
 !!! important
     `trio` must be installed to import and use the `TrioBackend`.
+
+## FAQ
+
+### When should I use an `AsyncClient`?
+
+You should use an `AsyncClient` whenever you are inside an *async environment*.
+
+In particular, using `httpx.get()` or `httpx.Client()` in an async environment is **not** supported. There are several technical reasons to this, but the rationale is that you shouldn't be doing blocking-style network calls in the context of an event loop (for more discussion, see [#179](https://github.com/encode/httpx/issues/179)).
+
+For example, this won't work:
+
+```python
+import asyncio
+import httpx
+
+async def main():
+    r = httpx.get("https://example.org")
+
+asyncio.run(main())
+```
+
+Instead, you should use an `AsyncClient`:
+
+```python
+import asyncio
+import httpx
+
+async def main():
+    async with httpx.AsyncClient() as client:
+        r = await client.get("https://example.org")
+
+asyncio.run(main())
+```
+
+If you *need* to make synchronous requests, or otherwise run into issues related to sync usage, you should probably consider using a regular threaded client such as [Requests](https://github.com/psf/requests) instead.
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 250a02c..7b2b391 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -32,3 +32,7 @@ with httpx.Client(**kwargs) as client:
 ```
 
 More detailed documentation and usage of `Client` can be found in [Advanced Usage](advanced.md).
+
+## Mocking
+
+If you need to mock HTTPX the same way that test utilities like `responses` and `requests-mock` does for `requests`, see [RESPX](https://github.com/lundberg/respx).
diff --git a/httpx/__version__.py b/httpx/__version__.py
index 106c345..2f47ae7 100644
--- a/httpx/__version__.py
+++ b/httpx/__version__.py
@@ -1,3 +1,3 @@
 __title__ = "httpx"
 __description__ = "A next generation HTTP client, for Python 3."
-__version__ = "0.7.6"
+__version__ = "0.7.8"
diff --git a/httpx/client.py b/httpx/client.py
index 45589b9..c327c2d 100644
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -74,6 +74,7 @@ class BaseClient:
         app: typing.Callable = None,
         backend: ConcurrencyBackend = None,
         trust_env: bool = True,
+        uds: str = None,
     ):
         if backend is None:
             backend = AsyncioBackend()
@@ -99,6 +100,7 @@ class BaseClient:
                 pool_limits=pool_limits,
                 backend=backend,
                 trust_env=self.trust_env,
+                uds=uds,
             )
         elif isinstance(dispatch, Dispatcher):
             async_dispatch = ThreadedDispatcher(dispatch, backend)
@@ -721,6 +723,7 @@ class Client(BaseClient):
     async requests.
     * **trust_env** - *(optional)* Enables or disables usage of environment
     variables for configuration.
+    * **uds** - *(optional)* A path to a Unix domain socket to connect through.
     """
 
     def check_concurrency_backend(self, backend: ConcurrencyBackend) -> None:
diff --git a/httpx/concurrency/asyncio.py b/httpx/concurrency/asyncio.py
index 010d821..a0163ed 100644
--- a/httpx/concurrency/asyncio.py
+++ b/httpx/concurrency/asyncio.py
@@ -58,16 +58,49 @@ class SocketStream(BaseSocketStream):
         self, hostname: str, ssl_context: ssl.SSLContext, timeout: TimeoutConfig
     ) -> "SocketStream":
         loop = asyncio.get_event_loop()
-        if not hasattr(loop, "start_tls"):  # pragma: no cover
-            raise NotImplementedError(
-                "asyncio.AbstractEventLoop.start_tls() is only available in Python 3.7+"
-            )
 
         stream_reader = asyncio.StreamReader()
         protocol = asyncio.StreamReaderProtocol(stream_reader)
         transport = self.stream_writer.transport
 
-        loop_start_tls = loop.start_tls  # type: ignore
+        if hasattr(loop, "start_tls"):
+            loop_start_tls = loop.start_tls  # type: ignore
+        else:
+
+            async def loop_start_tls(
+                transport: asyncio.BaseTransport,
+                protocol: asyncio.BaseProtocol,
+                sslcontext: ssl.SSLContext = None,
+                *,
+                server_side: bool = False,
+                server_hostname: str = None,
+                ssl_handshake_timeout: float = None,
+            ) -> asyncio.Transport:
+                """Python 3.6 asyncio doesn't have a start_tls() method on the loop
+                so we use this function in place of the loop's start_tls() method.
+                Adapted from this comment:
+                https://github.com/urllib3/urllib3/issues/1323#issuecomment-362494839
+                """
+                import asyncio.sslproto
+
+                waiter = loop.create_future()
+                ssl_protocol = asyncio.sslproto.SSLProtocol(
+                    loop,
+                    protocol,
+                    sslcontext,
+                    waiter,
+                    server_side=False,
+                    server_hostname=server_hostname,
+                    call_connection_made=False,
+                )
+
+                transport.set_protocol(ssl_protocol)
+                loop.call_soon(ssl_protocol.connection_made, transport)
+                loop.call_soon(transport.resume_reading)  # type: ignore
+
+                await waiter
+                return ssl_protocol._app_transport
+
         transport = await asyncio.wait_for(
             loop_start_tls(
                 transport=transport,
@@ -242,6 +275,29 @@ class AsyncioBackend(ConcurrencyBackend):
             stream_reader=stream_reader, stream_writer=stream_writer, timeout=timeout
         )
 
+    async def open_uds_stream(
+        self,
+        path: str,
+        hostname: typing.Optional[str],
+        ssl_context: typing.Optional[ssl.SSLContext],
+        timeout: TimeoutConfig,
+    ) -> SocketStream:
+        server_hostname = hostname if ssl_context else None
+
+        try:
+            stream_reader, stream_writer = await asyncio.wait_for(  # type: ignore
+                asyncio.open_unix_connection(
+                    path, ssl=ssl_context, server_hostname=server_hostname
+                ),
+                timeout.connect_timeout,
+            )
+        except asyncio.TimeoutError:
+            raise ConnectTimeout()
+
+        return SocketStream(
+            stream_reader=stream_reader, stream_writer=stream_writer, timeout=timeout
+        )
+
     async def run_in_threadpool(
         self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
     ) -> typing.Any:
diff --git a/httpx/concurrency/base.py b/httpx/concurrency/base.py
index 9d5bffd..2109c21 100644
--- a/httpx/concurrency/base.py
+++ b/httpx/concurrency/base.py
@@ -124,6 +124,15 @@ class ConcurrencyBackend:
     ) -> BaseSocketStream:
         raise NotImplementedError()  # pragma: no cover
 
+    async def open_uds_stream(
+        self,
+        path: str,
+        hostname: typing.Optional[str],
+        ssl_context: typing.Optional[ssl.SSLContext],
+        timeout: TimeoutConfig,
+    ) -> BaseSocketStream:
+        raise NotImplementedError()  # pragma: no cover
+
     def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
         raise NotImplementedError()  # pragma: no cover
 
diff --git a/httpx/concurrency/trio.py b/httpx/concurrency/trio.py
index 5d3b50d..c84b1e4 100644
--- a/httpx/concurrency/trio.py
+++ b/httpx/concurrency/trio.py
@@ -191,6 +191,26 @@ class TrioBackend(ConcurrencyBackend):
 
         return SocketStream(stream=stream, timeout=timeout)
 
+    async def open_uds_stream(
+        self,
+        path: str,
+        hostname: typing.Optional[str],
+        ssl_context: typing.Optional[ssl.SSLContext],
+        timeout: TimeoutConfig,
+    ) -> SocketStream:
+        connect_timeout = _or_inf(timeout.connect_timeout)
+
+        with trio.move_on_after(connect_timeout) as cancel_scope:
+            stream: trio.SocketStream = await trio.open_unix_socket(path)
+            if ssl_context is not None:
+                stream = trio.SSLStream(stream, ssl_context, server_hostname=hostname)
+                await stream.do_handshake()
+
+        if cancel_scope.cancelled_caught:
+            raise ConnectTimeout()
+
+        return SocketStream(stream=stream, timeout=timeout)
+
     async def run_in_threadpool(
         self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
     ) -> typing.Any:
diff --git a/httpx/dispatch/connection.py b/httpx/dispatch/connection.py
index 91feb97..0612bcc 100644
--- a/httpx/dispatch/connection.py
+++ b/httpx/dispatch/connection.py
@@ -38,6 +38,7 @@ class HTTPConnection(AsyncDispatcher):
         http_versions: HTTPVersionTypes = None,
         backend: ConcurrencyBackend = None,
         release_func: typing.Optional[ReleaseCallback] = None,
+        uds: typing.Optional[str] = None,
     ):
         self.origin = Origin(origin) if isinstance(origin, str) else origin
         self.ssl = SSLConfig(cert=cert, verify=verify, trust_env=trust_env)
@@ -45,6 +46,7 @@ class HTTPConnection(AsyncDispatcher):
         self.http_versions = HTTPVersionConfig(http_versions)
         self.backend = AsyncioBackend() if backend is None else backend
         self.release_func = release_func
+        self.uds = uds
         self.h11_connection = None  # type: typing.Optional[HTTP11Connection]
         self.h2_connection = None  # type: typing.Optional[HTTP2Connection]
 
@@ -84,8 +86,21 @@ class HTTPConnection(AsyncDispatcher):
         else:
             on_release = functools.partial(self.release_func, self)
 
-        logger.trace(f"start_connect host={host!r} port={port!r} timeout={timeout!r}")
-        stream = await self.backend.open_tcp_stream(host, port, ssl_context, timeout)
+        if self.uds is None:
+            logger.trace(
+                f"start_connect tcp host={host!r} port={port!r} timeout={timeout!r}"
+            )
+            stream = await self.backend.open_tcp_stream(
+                host, port, ssl_context, timeout
+            )
+        else:
+            logger.trace(
+                f"start_connect uds path={self.uds!r} host={host!r} timeout={timeout!r}"
+            )
+            stream = await self.backend.open_uds_stream(
+                self.uds, host, ssl_context, timeout
+            )
+
         http_version = stream.get_http_version()
         logger.trace(f"connected http_version={http_version!r}")
 
diff --git a/httpx/dispatch/connection_pool.py b/httpx/dispatch/connection_pool.py
index 189fcff..5d7d886 100644
--- a/httpx/dispatch/connection_pool.py
+++ b/httpx/dispatch/connection_pool.py
@@ -89,6 +89,7 @@ class ConnectionPool(AsyncDispatcher):
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
         http_versions: HTTPVersionTypes = None,
         backend: ConcurrencyBackend = None,
+        uds: typing.Optional[str] = None,
     ):
         self.verify = verify
         self.cert = cert
@@ -97,6 +98,7 @@ class ConnectionPool(AsyncDispatcher):
         self.http_versions = http_versions
         self.is_closed = False
         self.trust_env = trust_env
+        self.uds = uds
 
         self.keepalive_connections = ConnectionStore()
         self.active_connections = ConnectionStore()
@@ -142,6 +144,7 @@ class ConnectionPool(AsyncDispatcher):
                 backend=self.backend,
                 release_func=self.release_connection,
                 trust_env=self.trust_env,
+                uds=self.uds,
             )
             logger.trace(f"new_connection connection={connection!r}")
         else:
diff --git a/httpx/dispatch/http11.py b/httpx/dispatch/http11.py
index b1781bf..188d56d 100644
--- a/httpx/dispatch/http11.py
+++ b/httpx/dispatch/http11.py
@@ -4,6 +4,7 @@ import h11
 
 from ..concurrency.base import BaseSocketStream, ConcurrencyBackend, TimeoutFlag
 from ..config import TimeoutConfig, TimeoutTypes
+from ..exceptions import ConnectionClosed, ProtocolError
 from ..models import AsyncRequest, AsyncResponse
 from ..utils import get_logger
 
@@ -161,7 +162,17 @@ class HTTP11Connection:
         Read a single `h11` event, reading more data from the network if needed.
         """
         while True:
-            event = self.h11_state.next_event()
+            try:
+                event = self.h11_state.next_event()
+            except h11.RemoteProtocolError as e:
+                logger.debug(
+                    "h11.RemoteProtocolError exception "
+                    + f"their_state={self.h11_state.their_state} "
+                    + f"error_status_hint={e.error_status_hint}"
+                )
+                if self.stream.is_connection_dropped():
+                    raise ConnectionClosed(e)
+                raise ProtocolError(e)
 
             if isinstance(event, h11.Data):
                 logger.trace(f"receive_event event=Data(<{len(event.data)} bytes>)")
diff --git a/httpx/exceptions.py b/httpx/exceptions.py
index 81df38b..419a21a 100644
--- a/httpx/exceptions.py
+++ b/httpx/exceptions.py
@@ -150,6 +150,12 @@ class InvalidURL(HTTPError):
     """
 
 
+class ConnectionClosed(HTTPError):
+    """
+    Expected more data from peer, but connection was closed.
+    """
+
+
 class CookieConflict(HTTPError):
     """
     Attempted to lookup a cookie by name, but multiple cookies existed.
diff --git a/httpx/middleware/redirect.py b/httpx/middleware/redirect.py
index 6f5c02e..e093bf4 100644
--- a/httpx/middleware/redirect.py
+++ b/httpx/middleware/redirect.py
@@ -51,7 +51,6 @@ class RedirectMiddleware(BaseMiddleware):
         headers = self.redirect_headers(request, url, method)  # TODO: merge headers?
         content = self.redirect_content(request, method)
         cookies = Cookies(self.cookies)
-        cookies.update(request.cookies)
         return AsyncRequest(
             method=method, url=url, headers=headers, data=content, cookies=cookies
         )
@@ -115,6 +114,11 @@ class RedirectMiddleware(BaseMiddleware):
             # are only relevant to the request body.
             headers.pop("Content-Length", None)
             headers.pop("Transfer-Encoding", None)
+
+        # We should use the client cookie store to determine any cookie header,
+        # rather than whatever was on the original outgoing request.
+        headers.pop("Cookie", None)
+
         return headers
 
     def redirect_content(self, request: AsyncRequest, method: str) -> bytes:
diff --git a/noxfile.py b/noxfile.py
index ef524eb..43494b5 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -24,14 +24,7 @@ def lint(session):
 @nox.session
 def check(session):
     session.install(
-        "--upgrade",
-        "black",
-        "flake8",
-        "flake8-bugbear",
-        "flake8-comprehensions",
-        "flake8-pie",
-        "isort",
-        "mypy",
+        "--upgrade", "black", "flake8", "flake8-bugbear", "flake8-pie", "isort", "mypy"
     )
 
     session.run("black", "--check", "--diff", "--target-version=py36", *source_files)


Test patch:
diff --git a/test-requirements.txt b/test-requirements.txt
index 7aa572c..96437e0 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -6,7 +6,6 @@ brotlipy==0.7.*
 cryptography
 flake8
 flake8-bugbear
-flake8-comprehensions
 flake8-pie
 isort
 mypy
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
index eaac5ef..42202aa 100644
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -146,3 +146,14 @@ async def test_100_continue(server, backend):
 
     assert response.status_code == 200
     assert response.content == data
+
+
+async def test_uds(uds_server, backend):
+    url = uds_server.url
+    uds = uds_server.config.uds
+    assert uds is not None
+    async with httpx.AsyncClient(backend=backend, uds=uds) as client:
+        response = await client.get(url)
+    assert response.status_code == 200
+    assert response.text == "Hello, world!"
+    assert response.encoding == "iso-8859-1"
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index f7be607..5dc196d 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -138,6 +138,17 @@ def test_base_url(server):
     assert response.url == base_url
 
 
+def test_uds(uds_server):
+    url = uds_server.url
+    uds = uds_server.config.uds
+    assert uds is not None
+    with httpx.Client(uds=uds) as http:
+        response = http.get(url)
+    assert response.status_code == 200
+    assert response.text == "Hello, world!"
+    assert response.encoding == "iso-8859-1"
+
+
 def test_merge_url():
     client = httpx.Client(base_url="https://www.paypal.com/")
     url = client.merge_url("http://www.paypal.com")
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
index d732ce4..b014b0f 100644
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -290,3 +290,71 @@ async def test_cross_subdomain_redirect(backend):
     url = "https://example.com/cross_subdomain"
     response = await client.get(url)
     assert response.url == URL("https://www.example.org/cross_subdomain")
+
+
+class MockCookieDispatch(AsyncDispatcher):
+    async def send(
+        self,
+        request: AsyncRequest,
+        verify: VerifyTypes = None,
+        cert: CertTypes = None,
+        timeout: TimeoutTypes = None,
+    ) -> AsyncResponse:
+        if request.url.path == "/":
+            if "cookie" in request.headers:
+                content = b"Logged in"
+            else:
+                content = b"Not logged in"
+            return AsyncResponse(codes.OK, content=content, request=request)
+
+        elif request.url.path == "/login":
+            status_code = codes.SEE_OTHER
+            headers = {
+                "location": "/",
+                "set-cookie": (
+                    "session=eyJ1c2VybmFtZSI6ICJ0b21; path=/; Max-Age=1209600; "
+                    "httponly; samesite=lax"
+                ),
+            }
+
+            return AsyncResponse(status_code, headers=headers, request=request)
+
+        elif request.url.path == "/logout":
+            status_code = codes.SEE_OTHER
+            headers = {
+                "location": "/",
+                "set-cookie": (
+                    "session=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; "
+                    "httponly; samesite=lax"
+                ),
+            }
+            return AsyncResponse(status_code, headers=headers, request=request)
+
+
+async def test_redirect_cookie_behavior(backend):
+    client = AsyncClient(dispatch=MockCookieDispatch(), backend=backend)
+
+    # The client is not logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
+
+    # Login redirects to the homepage, setting a session cookie.
+    response = await client.post("https://example.com/login")
+    assert response.url == "https://example.com/"
+    assert response.text == "Logged in"
+
+    # The client is logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Logged in"
+
+    # Logout redirects to the homepage, expiring the session cookie.
+    response = await client.post("https://example.com/logout")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
+
+    # The client is not logged in.
+    response = await client.get("https://example.com/")
+    assert response.url == "https://example.com/"
+    assert response.text == "Not logged in"
diff --git a/tests/conftest.py b/tests/conftest.py
index ef57cae..540b013 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -63,6 +63,8 @@ async def app(scope, receive, send):
     assert scope["type"] == "http"
     if scope["path"].startswith("/slow_response"):
         await slow_response(scope, receive, send)
+    elif scope["path"].startswith("/premature_close"):
+        await premature_close(scope, receive, send)
     elif scope["path"].startswith("/status"):
         await status_code(scope, receive, send)
     elif scope["path"].startswith("/echo_body"):
@@ -103,6 +105,16 @@ async def slow_response(scope, receive, send):
     await send({"type": "http.response.body", "body": b"Hello, world!"})
 
 
+async def premature_close(scope, receive, send):
+    await send(
+        {
+            "type": "http.response.start",
+            "status": 200,
+            "headers": [[b"content-type", b"text/plain"]],
+        }
+    )
+
+
 async def status_code(scope, receive, send):
     status_code = int(scope["path"].replace("/status/", ""))
     await send(
@@ -288,6 +300,15 @@ def server():
     yield from serve_in_thread(server)
 
 
+@pytest.fixture(scope=SERVER_SCOPE)
+def uds_server():
+    uds = "test_server.sock"
+    config = Config(app=app, lifespan="off", loop="asyncio", uds=uds)
+    server = TestServer(config=config)
+    yield from serve_in_thread(server)
+    os.remove(uds)
+
+
 @pytest.fixture(scope=SERVER_SCOPE)
 def https_server(cert_pem_file, cert_private_key_file):
     config = Config(
@@ -301,3 +322,19 @@ def https_server(cert_pem_file, cert_private_key_file):
     )
     server = TestServer(config=config)
     yield from serve_in_thread(server)
+
+
+@pytest.fixture(scope=SERVER_SCOPE)
+def https_uds_server(cert_pem_file, cert_private_key_file):
+    uds = "https_test_server.sock"
+    config = Config(
+        app=app,
+        lifespan="off",
+        ssl_certfile=cert_pem_file,
+        ssl_keyfile=cert_private_key_file,
+        uds=uds,
+        loop="asyncio",
+    )
+    server = TestServer(config=config)
+    yield from serve_in_thread(server)
+    os.remove(uds)
diff --git a/tests/dispatch/test_connections.py b/tests/dispatch/test_connections.py
index b4aea17..f39ac4f 100644
--- a/tests/dispatch/test_connections.py
+++ b/tests/dispatch/test_connections.py
@@ -1,4 +1,6 @@
-from httpx import HTTPConnection
+import pytest
+
+from httpx import HTTPConnection, exceptions
 
 
 async def test_get(server, backend):
@@ -15,6 +17,15 @@ async def test_post(server, backend):
         assert response.status_code == 200
 
 
+async def test_premature_close(server, backend):
+    with pytest.raises(exceptions.ConnectionClosed):
+        async with HTTPConnection(origin=server.url, backend=backend) as conn:
+            response = await conn.request(
+                "GET", server.url.copy_with(path="/premature_close")
+            )
+            await response.read()
+
+
 async def test_https_get_with_ssl_defaults(https_server, ca_cert_pem_file, backend):
     """
     An HTTPS request, with default SSL configuration set on the client.
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index 8bb933b..cf3844c 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -1,5 +1,3 @@
-import sys
-
 import pytest
 import trio
 
@@ -26,20 +24,26 @@ from httpx.concurrency.trio import TrioBackend
         ),
     ],
 )
-async def test_start_tls_on_socket_stream(https_server, backend, get_cipher):
+@pytest.mark.parametrize("use_uds", (False, True))
+async def test_start_tls_on_socket_stream(
+    https_server, https_uds_server, backend, get_cipher, use_uds
+):
     """
     See that the concurrency backend can make a connection without TLS then
     start TLS on an existing connection.
     """
-    if isinstance(backend, AsyncioBackend) and sys.version_info < (3, 7):
-        pytest.xfail(reason="Requires Python 3.7+ for AbstractEventLoop.start_tls()")
-
     ctx = SSLConfig().load_ssl_context_no_verify(HTTPVersionConfig())
     timeout = TimeoutConfig(5)
 
-    stream = await backend.open_tcp_stream(
-        https_server.url.host, https_server.url.port, None, timeout
-    )
+    if use_uds:
+        assert https_uds_server.config.uds is not None
+        stream = await backend.open_uds_stream(
+            https_uds_server.config.uds, https_uds_server.url.host, None, timeout
+        )
+    else:
+        stream = await backend.open_tcp_stream(
+            https_server.url.host, https_server.url.port, None, timeout
+        )
 
     try:
         assert stream.is_connection_dropped() is False