Repository: encode/httpx

Issue:
0.13: some `httpcore` exceptions are missing from top level package
### 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
I believe that `HTTPError` is expected to be the base exception for all exceptions that httpx may raise? Since 0.13 this is no longer true and comments in [`_exceptions.py`](https://github.com/encode/httpx/blob/master/httpx/_exceptions.py#L11) indicate this is a bug. In real world use I have at least one bit of code that has failed due to this. I could see an argument for trying to catch a more specific error, but I think this case `HTTPError` was really nice to use.

Even if users should be using more specific exceptions having a single base exception is an incredibly useful feature for any library and it'd be great if we could ensure this behavior is set for the upcoming 1.0 release.

### To reproduce
The following code assumes localhost does not have any webserver running on port 80. Running on httpx 0.12 it correctly catches the `HTTPError`, running on 0.13 it does not catch the exception (httpcore.ConnectError, which doesn't appear to be properly exported in [`\_\_init\_\_.py](https://github.com/encode/httpx/blob/master/httpx/__init__.py), though it is aliased in `_exceptions.py`).
```python
import httpx
try:
    httpx.get('http://localhost')
except httpx.HTTPError:
    print('There was an httpx error')
```

### Expected behavior
If `HTTPError` is the base exception for httpx I'd expect catching `HTTPError` to actually catch all errors httpx may raise.

### Actual behavior
Instead of catching `HTTPError` it appears that httpcore errors bubble up. This breaks exception handling that expects `HTTPError` to be the base exception.

### Environment
- OS: macOS 10.15.5
- Python version: 3.8.3
- HTTPX version: 0.13
- Async environment: asyncio
- HTTP proxy: No
- Custom certificates: No



Discussion:
@iwoloschin I'm tempted to keep the base class issue to #949. But you raised an interesting point about `ConnectError` not being exposed in `__init__.py`, which I believe is a bug in itself:

> `httpcore.ConnectError,` which doesn't appear to be properly exported in [`__init__.py`](https://github.com/encode/httpx/blob/master/httpx/__init__.py), though it is aliased in `_exceptions.py`.

So, marking this as bug up for grabs. I'll retitle this issue to reduce its scope and keep any discussions on base classes scoped to #949.
I made this as a separate issue because I couldn't really find any big, obvious notes that exception handling had changed for 0.13. I didn't even really understand the scope of the change after commenting in #949, after I did some digging and understood what had happened I felt it was worthwhile specifically breaking this out since it might be as simple as a bug against 0.13[.3], with the higher level discussion for #949? I'm fine either way.

I realize that as this is basically all pre-release software there is always a risk of breaking changes, but this project has actually been pretty good about noting breaking changes. Honestly, httpx is such a joy to use that it would be worth dealing with breaking changes anyways, I just was hoping for more notice than "pushed a new release and...oh...it all broke" 😄 .
Yeah, to be honest we hadn't realized the exception handling API would change so much due to the deferring of the networking layer to `httpcore`, but here we are. :-) Now we just need some folks to fix things.
If only we were paid for fixing all of the "small" changes we make 😄 .

Is most of the discussion happening in the gitter room? I can see if I can drop in there.

Resolving patch:
diff --git a/httpx/__init__.py b/httpx/__init__.py
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -25,6 +25,7 @@
     ResponseNotRead,
     StreamConsumed,
     StreamError,
+    TimeoutException,
     TooManyRedirects,
     WriteError,
     WriteTimeout,
@@ -81,6 +82,7 @@
     "StreamConsumed",
     "StreamError",
     "ProxyError",
+    "TimeoutException",
     "TooManyRedirects",
     "WriteError",
     "WriteTimeout",
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -18,7 +18,14 @@
     UnsetType,
 )
 from ._content_streams import ContentStream
-from ._exceptions import HTTPError, InvalidURL, RequestBodyUnavailable, TooManyRedirects
+from ._exceptions import (
+    HTTPCORE_EXC_MAP,
+    HTTPError,
+    InvalidURL,
+    RequestBodyUnavailable,
+    TooManyRedirects,
+    map_exceptions,
+)
 from ._models import URL, Cookies, Headers, Origin, QueryParams, Request, Response
 from ._status_codes import codes
 from ._transports.asgi import ASGITransport
@@ -705,19 +712,20 @@ def send_single_request(self, request: Request, timeout: Timeout) -> Response:
         transport = self.transport_for_url(request.url)
 
         try:
-            (
-                http_version,
-                status_code,
-                reason_phrase,
-                headers,
-                stream,
-            ) = transport.request(
-                request.method.encode(),
-                request.url.raw,
-                headers=request.headers.raw,
-                stream=request.stream,
-                timeout=timeout.as_dict(),
-            )
+            with map_exceptions(HTTPCORE_EXC_MAP):
+                (
+                    http_version,
+                    status_code,
+                    reason_phrase,
+                    headers,
+                    stream,
+                ) = transport.request(
+                    request.method.encode(),
+                    request.url.raw,
+                    headers=request.headers.raw,
+                    stream=request.stream,
+                    timeout=timeout.as_dict(),
+                )
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
             # there'a already a request attached in the case of
@@ -1255,19 +1263,20 @@ async def send_single_request(
         transport = self.transport_for_url(request.url)
 
         try:
-            (
-                http_version,
-                status_code,
-                reason_phrase,
-                headers,
-                stream,
-            ) = await transport.request(
-                request.method.encode(),
-                request.url.raw,
-                headers=request.headers.raw,
-                stream=request.stream,
-                timeout=timeout.as_dict(),
-            )
+            with map_exceptions(HTTPCORE_EXC_MAP):
+                (
+                    http_version,
+                    status_code,
+                    reason_phrase,
+                    headers,
+                    stream,
+                ) = await transport.request(
+                    request.method.encode(),
+                    request.url.raw,
+                    headers=request.headers.raw,
+                    stream=request.stream,
+                    timeout=timeout.as_dict(),
+                )
         except HTTPError as exc:
             # Add the original request to any HTTPError unless
             # there'a already a request attached in the case of
diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py
--- a/httpx/_exceptions.py
+++ b/httpx/_exceptions.py
@@ -1,3 +1,4 @@
+import contextlib
 import typing
 
 import httpcore
@@ -28,25 +29,87 @@ def request(self) -> "Request":
 
 # Timeout exceptions...
 
-ConnectTimeout = httpcore.ConnectTimeout
-ReadTimeout = httpcore.ReadTimeout
-WriteTimeout = httpcore.WriteTimeout
-PoolTimeout = httpcore.PoolTimeout
+
+class TimeoutException(HTTPError):
+    """
+    The base class for timeout errors.
+
+    An operation has timed out.
+    """
+
+
+class ConnectTimeout(TimeoutException):
+    """
+    Timed out while connecting to the host.
+    """
+
+
+class ReadTimeout(TimeoutException):
+    """
+    Timed out while receiving data from the host.
+    """
+
+
+class WriteTimeout(TimeoutException):
+    """
+    Timed out while sending data to the host.
+    """
+
+
+class PoolTimeout(TimeoutException):
+    """
+    Timed out waiting to acquire a connection from the pool.
+    """
 
 
 # Core networking exceptions...
 
-NetworkError = httpcore.NetworkError
-ReadError = httpcore.ReadError
-WriteError = httpcore.WriteError
-ConnectError = httpcore.ConnectError
-CloseError = httpcore.CloseError
+
+class NetworkError(HTTPError):
+    """
+    The base class for network-related errors.
+
+    An error occurred while interacting with the network.
+    """
+
+
+class ReadError(NetworkError):
+    """
+    Failed to receive data from the network.
+    """
+
+
+class WriteError(NetworkError):
+    """
+    Failed to send data through the network.
+    """
+
+
+class ConnectError(NetworkError):
+    """
+    Failed to establish a connection.
+    """
+
+
+class CloseError(NetworkError):
+    """
+    Failed to close a connection.
+    """
 
 
 # Other transport exceptions...
 
-ProxyError = httpcore.ProxyError
-ProtocolError = httpcore.ProtocolError
+
+class ProxyError(HTTPError):
+    """
+    An error occurred while proxying a request.
+    """
+
+
+class ProtocolError(HTTPError):
+    """
+    A protocol was violated by the server.
+    """
 
 
 # HTTP exceptions...
@@ -138,3 +201,43 @@ class CookieConflict(HTTPError):
     """
     Attempted to lookup a cookie by name, but multiple cookies existed.
     """
+
+
+@contextlib.contextmanager
+def map_exceptions(
+    mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]]
+) -> typing.Iterator[None]:
+    try:
+        yield
+    except Exception as exc:
+        mapped_exc = None
+
+        for from_exc, to_exc in mapping.items():
+            if not isinstance(exc, from_exc):
+                continue
+            # We want to map to the most specific exception we can find.
+            # Eg if `exc` is an `httpcore.ReadTimeout`, we want to map to
+            # `httpx.ReadTimeout`, not just `httpx.TimeoutException`.
+            if mapped_exc is None or issubclass(to_exc, mapped_exc):
+                mapped_exc = to_exc
+
+        if mapped_exc is None:
+            raise
+
+        raise mapped_exc(exc) from None
+
+
+HTTPCORE_EXC_MAP = {
+    httpcore.TimeoutException: TimeoutException,
+    httpcore.ConnectTimeout: ConnectTimeout,
+    httpcore.ReadTimeout: ReadTimeout,
+    httpcore.WriteTimeout: WriteTimeout,
+    httpcore.PoolTimeout: PoolTimeout,
+    httpcore.NetworkError: NetworkError,
+    httpcore.ConnectError: ConnectError,
+    httpcore.ReadError: ReadError,
+    httpcore.WriteError: WriteError,
+    httpcore.CloseError: CloseError,
+    httpcore.ProxyError: ProxyError,
+    httpcore.ProtocolError: ProtocolError,
+}


Test patch:
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -1,6 +1,46 @@
+from typing import Any
+
+import httpcore
 import pytest
 
 import httpx
+from httpx._exceptions import HTTPCORE_EXC_MAP
+
+
+def test_httpcore_all_exceptions_mapped() -> None:
+    """
+    All exception classes exposed by HTTPCore are properly mapped to an HTTPX-specific
+    exception class.
+    """
+    not_mapped = [
+        value
+        for name, value in vars(httpcore).items()
+        if isinstance(value, type)
+        and issubclass(value, Exception)
+        and value not in HTTPCORE_EXC_MAP
+    ]
+
+    if not_mapped:
+        pytest.fail(f"Unmapped httpcore exceptions: {not_mapped}")
+
+
+def test_httpcore_exception_mapping() -> None:
+    """
+    HTTPCore exception mapping works as expected.
+    """
+
+    # Make sure we don't just map to `NetworkError`.
+    with pytest.raises(httpx.ConnectError):
+        httpx.get("http://doesnotexist")
+
+    # Make sure it also works with custom transports.
+    class MockTransport(httpcore.SyncHTTPTransport):
+        def request(self, *args: Any, **kwargs: Any) -> Any:
+            raise httpcore.ProtocolError()
+
+    client = httpx.Client(transport=MockTransport())
+    with pytest.raises(httpx.ProtocolError):
+        client.get("http://testserver")
 
 
 def test_httpx_exceptions_exposed() -> None: