Repository: encode/httpx

Issue:
httpcore.ReadTimeout not mapped to httpx.ReadTimeout
I'm running the following to perform requests and catch `httpx.RequestError` exceptions:
```
async def async_get_url(client, url, retry=True):
    async with semaphore.get():
        try:
            response = await client.get(url)
            response.raise_for_status()
            result = handle_reponse(reponse)
        except httpx.RequestError as exc:
            if retry:
                print(f"Retrying {url} ...")
                result = await async_get_url(
                    client, url, retry=False
                )
            else:
                result = ...
        except httpx.HTTPStatusError as exc:
            result = ...
        return result
```

At some point it failed with a `httpcore._exceptions.ReadTimeout` exception:
```
Traceback (most recent call last):
  File "/home/user/main.py", line 351, in <module>
    asyncio.run(run())
  File "/usr/lib64/python3.8/asyncio/runners.py", line 43, in run
    return loop.run_until_complete(main)
  File "/usr/lib64/python3.8/asyncio/base_events.py", line 616, in run_until_complete
    return future.result()
  File "/home/user/main.py", line 317, in run
    await f
  File "/usr/lib64/python3.8/asyncio/tasks.py", line 608, in _wait_for_one
    return f.result()  # May raise f.exception().
  File "/home/user/main.py", line 173, in get_updates
    item_api_response = await f
  File "/usr/lib64/python3.8/asyncio/tasks.py", line 608, in _wait_for_one
    return f.result()  # May raise f.exception().
  File "/home/user/main.py", line 53, in async_get_url
    response = await client.get(url)
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_client.py", line 1292, in get
    return await self.request(
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_client.py", line 1143, in request
    response = await self.send(
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_client.py", line 1167, in send
    await response.aread()
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_models.py", line 958, in aread
    self._content = b"".join([part async for part in self.aiter_bytes()])
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_models.py", line 958, in <listcomp>
    self._content = b"".join([part async for part in self.aiter_bytes()])
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_models.py", line 969, in aiter_bytes
    async for chunk in self.aiter_raw():
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpx/_models.py", line 1002, in aiter_raw
    async for part in self._raw_stream:
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_async/connection_pool.py", line 50, in __aiter__
    async for chunk in self.stream:
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_bytestreams.py", line 72, in __aiter__
    async for chunk in self._aiterator:
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_async/http11.py", line 143, in _receive_response_data
    event = await self._receive_event(timeout)
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_async/http11.py", line 160, in _receive_event
    data = await self.socket.read(self.READ_NUM_BYTES, timeout)
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_backends/asyncio.py", line 139, in read
    return data
  File "/usr/lib64/python3.8/contextlib.py", line 131, in __exit__
    self.gen.throw(type, value, traceback)
  File "/home/user/.pyenv/versions/venv/lib64/python3.8/site-packages/httpcore/_exceptions.py", line 12, in map_exceptions
    raise to_exc(exc) from None
httpcore._exceptions.ReadTimeout
```
According to: https://github.com/encode/httpx/blob/8c7729e42c9ebf860e75820d5325741b85d64970/httpx/_exceptions.py#L360-L363 
The `httpcore.ReadTimeout` exception should have been mapped to `httpx.ReadTimeout` and therefore catched appropriately with `except httpx.RequestError as exc`, but that doesn't happen.

### Environment

- OS: Linux
- Python version: 3.8.5
- HTTPX version: 0.14.0
- HTTPCore version: 0.10.0
- Async environment: asyncio
- HTTP proxy: no
- Custom certificates: no


Discussion:
Thanks for raising this!

Currently we only mapped exceptions happend in `Client._send_single_request`, your exception occured in `Client.send` while reading the response, thus not being mapped.
I think we should add `map_exceptions` to `Response.iter_raw()` and `Response.aiter_raw()` as well?
Good catch, yup. 👍

Resolving patch:
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -24,6 +24,7 @@
     TextDecoder,
 )
 from ._exceptions import (
+    HTTPCORE_EXC_MAP,
     CookieConflict,
     HTTPStatusError,
     InvalidURL,
@@ -32,6 +33,7 @@
     ResponseClosed,
     ResponseNotRead,
     StreamConsumed,
+    map_exceptions,
 )
 from ._status_codes import codes
 from ._types import (
@@ -931,8 +933,9 @@ def iter_raw(self) -> typing.Iterator[bytes]:
             raise ResponseClosed()
 
         self.is_stream_consumed = True
-        for part in self._raw_stream:
-            yield part
+        with map_exceptions(HTTPCORE_EXC_MAP, request=self.request):
+            for part in self._raw_stream:
+                yield part
         self.close()
 
     def next(self) -> "Response":
@@ -1007,8 +1010,9 @@ async def aiter_raw(self) -> typing.AsyncIterator[bytes]:
             raise ResponseClosed()
 
         self.is_stream_consumed = True
-        async for part in self._raw_stream:
-            yield part
+        with map_exceptions(HTTPCORE_EXC_MAP, request=self.request):
+            async for part in self._raw_stream:
+                yield part
         await self.aclose()
 
     async def anext(self) -> "Response":


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -76,6 +76,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("/slow_stream_response"):
+        await slow_stream_response(scope, receive, send)
     elif scope["path"].startswith("/status"):
         await status_code(scope, receive, send)
     elif scope["path"].startswith("/echo_body"):
@@ -111,6 +113,19 @@ async def slow_response(scope, receive, send):
     await send({"type": "http.response.body", "body": b"Hello, world!"})
 
 
+async def slow_stream_response(scope, receive, send):
+    await send(
+        {
+            "type": "http.response.start",
+            "status": 200,
+            "headers": [[b"content-type", b"text/plain"]],
+        }
+    )
+
+    await sleep(1)
+    await send({"type": "http.response.body", "body": b"", "more_body": False})
+
+
 async def status_code(scope, receive, send):
     status_code = int(scope["path"].replace("/status/", ""))
     await send(
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -24,7 +24,7 @@ def test_httpcore_all_exceptions_mapped() -> None:
         pytest.fail(f"Unmapped httpcore exceptions: {not_mapped}")
 
 
-def test_httpcore_exception_mapping() -> None:
+def test_httpcore_exception_mapping(server) -> None:
     """
     HTTPCore exception mapping works as expected.
     """
@@ -33,6 +33,13 @@ def test_httpcore_exception_mapping() -> None:
     with pytest.raises(httpx.ConnectError):
         httpx.get("http://doesnotexist")
 
+    # Make sure streaming methods also map exceptions.
+    url = server.url.copy_with(path="/slow_stream_response")
+    timeout = httpx.Timeout(None, read=0.1)
+    with httpx.stream("GET", url, timeout=timeout) as stream:
+        with pytest.raises(httpx.ReadTimeout):
+            stream.read()
+
     # Make sure it also works with custom transports.
     class MockTransport(httpcore.SyncHTTPTransport):
         def request(self, *args: Any, **kwargs: Any) -> Any: