Repository: encode/httpx

Issue:
`response.aclose()` raises httpcore errors, not HTTPError
### Checklist

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

If a server resets the connection, then `response.aiter_bytes()` correctly raises an `httpx.HTTPError`.

-BUT- the error handling doesn't end there. If `response.aiter_raw()` doesn't succeed, it doesn't call `response.aclose()`. (I imagine it's the caller's responsibility?) So the caller will call `response.aclose()` ... and that will raise an `httpcore` exception. It can be caught ... but that's a big hassle and it's a hole in the abstraction layer.

```
response = await httpx_client.send(request, stream=True, timeout=timeout)  # raise HTTPErrors
try:
    async for blob in response.aiter_raw():
        pass
finally:
    await response.aclose()  # raise httpcore errors
```

```
async with httpx_client.stream("GET", url) as response:
    async for blob in response.aiter_raw():
        pass
        # but if aiter_raw() raises an exception, the aclose() will raise an httpcore error
```

### To reproduce

I'm having trouble with the server init, but this works enough of the time....:

```
import _socket
import asyncio
import io
import struct

import httpx
import pytest


@pytest.mark.asyncio
async def test_download_stream_connection_reset():
    async def respond_then_die(reader, writer):
        # Read request -- as we should
        await reader.readuntil(b"\r\n\r\n")
        # Send HTTP header and start sending data -- so the client is streaming
        writer.write(
            b"HTTP/1.1 200 OK\r\nContent-Length:1000\r\nContent-Type:text/plain\r\n\r\nstart"
        )
        await writer.drain()
        # Now force a TCP RST packet, which will cause response.aclose() to fail
        # http://deepix.github.io/2016/10/21/tcprst.html
        fd = writer._transport._sock.fileno()
        linger = struct.pack("=II", 1, 0)
        _socket.socket(fileno=fd).setsockopt(
            _socket.SOL_SOCKET, _socket.SO_LINGER, bytearray(linger)
        )
        writer.close()

    server = await asyncio.start_server(respond_then_die, "127.0.0.1")
    async with server:
        host, port = server.sockets[0].getsockname()
        async with httpx.AsyncClient() as client:
            async with client.stream("GET", f"http://{host}:{port}") as response:
                async for blob in response.aiter_raw():
                    pass
```

### Expected behavior

`httpx.ReadError: [Errno 104] Connection reset by peer`

### Actual behavior

`httpcore.CloseError: [Errno 104] Connection reset by peer`

### Debugging material

```
.tox/py38/lib/python3.8/site-packages/httpx/_client.py:1846: in __aexit__
    await self.response.aclose()
.tox/py38/lib/python3.8/site-packages/httpx/_models.py:1299: in aclose
    await self._on_close(self)
.tox/py38/lib/python3.8/site-packages/httpx/_client.py:1513: in on_close
    await stream.aclose()
.tox/py38/lib/python3.8/site-packages/httpcore/_async/connection_pool.py:69: in aclose
    await self.stream.aclose()
.tox/py38/lib/python3.8/site-packages/httpcore/_bytestreams.py:78: in aclose
    await self._aclose_func()
.tox/py38/lib/python3.8/site-packages/httpcore/_async/http11.py:192: in _response_closed
    await self.aclose()
.tox/py38/lib/python3.8/site-packages/httpcore/_async/http11.py:202: in aclose
    await self.socket.aclose()
.tox/py38/lib/python3.8/site-packages/httpcore/_backends/asyncio.py:198: in aclose
    await self.stream_writer.wait_closed()  # type: ignore
/usr/lib64/python3.8/contextlib.py:131: in __exit__
    self.gen.throw(type, value, traceback)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

map = {<class 'OSError'>: <class 'httpcore.CloseError'>}

    @contextlib.contextmanager
    def map_exceptions(map: Dict[Type[Exception], Type[Exception]]) -> Iterator[None]:
        try:
            yield
        except Exception as exc:  # noqa: PIE786
            for from_exc, to_exc in map.items():
                if isinstance(exc, from_exc):
>                   raise to_exc(exc) from None
E                   httpcore.CloseError: [Errno 104] Connection reset by peer

.tox/py38/lib/python3.8/site-packages/httpcore/_exceptions.py:12: CloseError
------------------------------------------------- Captured log call --------------------------------------------------
ERROR    asyncio:base_events.py:1707 Exception in callback _SelectorSocketTransport._call_connection_lost(None)
handle: <Handle _SelectorSocketTransport._call_connection_lost(None)>
Traceback (most recent call last):
  File "/usr/lib64/python3.8/asyncio/events.py", line 81, in _run
    self._context.run(self._callback, *self._args)
  File "/usr/lib64/python3.8/asyncio/selector_events.py", line 970, in _call_connection_lost
    super()._call_connection_lost(exc)
  File "/usr/lib64/python3.8/asyncio/selector_events.py", line 730, in _call_connection_lost
    self._sock.close()
  File "/usr/lib64/python3.8/socket.py", line 500, in close
    self._real_close()
  File "/usr/lib64/python3.8/socket.py", line 494, in _real_close
    _ss.close(self)
OSError: [Errno 9] Bad file descriptor
```

(That `map_exceptions()` is the one within `httpcore`, not the one that I would expect to adorn aclose() in `httpx`.)

### Environment

- OS: Linux
- Python version: Python 3.8.7
- HTTPX version: 0.16.1
- Async environment: asyncio
- HTTP proxy: no
- Custom certificates: no

### Additional context

I'm trying to perform HTTP fetches for non-tech-savvy users. I need to handle every exception to present a friendly message.

You might want to split this into _two_ errors, actually?:

1. `response.aclose()` exceptions aren't wrapped
2. `StreamContextManager.__aclose__()` raises whatever exception `response.aclose()` raised (should it instead raise the _original_ exception -- the one from `aiter_raw()`?)


Discussion:

Resolving patch:
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -1500,7 +1500,8 @@ async def _send_single_request(
         async def on_close(response: Response) -> None:
             response.elapsed = datetime.timedelta(seconds=await timer.async_elapsed())
             if hasattr(stream, "aclose"):
-                await stream.aclose()
+                with map_exceptions(HTTPCORE_EXC_MAP, request=request):
+                    await stream.aclose()
 
         response = Response(
             status_code,


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -303,6 +303,25 @@ async def test_mounted_transport():
         assert response.json() == {"app": "mounted"}
 
 
+@pytest.mark.usefixtures("async_environment")
+async def test_response_aclose_map_exceptions():
+    class BrokenStream:
+        async def __aiter__(self):
+            # so we're an AsyncIterator
+            pass  # pragma: nocover
+
+        async def aclose(self):
+            raise httpcore.CloseError(OSError(104, "Connection reset by peer"))
+
+    def handle(request: httpx.Request) -> httpx.Response:
+        return httpx.Response(200, stream=BrokenStream())
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
+        async with client.stream("GET", "http://example.com") as response:
+            with pytest.raises(httpx.CloseError):
+                await response.aclose()
+
+
 @pytest.mark.usefixtures("async_environment")
 async def test_async_mock_transport():
     async def hello_world(request):