Repository: encode/httpx

Issue:
How about attaching original traceback to exceptions raised in `map_exceptions()` ?
Currently we use `map_exceptions()` to covert underlying library exceptions to `httpx` exceptions, when captured an expected exception, we raise a new exception from `None`:  
https://github.com/encode/httpx/blob/655773e1c1b75895eda927d5a9d22a3b5b8f572d/httpx/_exceptions.py#L336-L359

This causes a traceback like this:
```
In [30]: httpx.get('http://notexisthost')
Traceback (most recent call last):
  File "<ipython-input-30-7c88a36ce394>", line 1, in <module>
    httpx.get('http://notexisthost')
  File "D:\programs\anaconda3\lib\site-packages\httpx\_api.py", line 170, in get
    trust_env=trust_env,
  File "D:\programs\anaconda3\lib\site-packages\httpx\_api.py", line 96, in request
    allow_redirects=allow_redirects,
  File "D:\programs\anaconda3\lib\site-packages\httpx\_client.py", line 601, in request
    request, auth=auth, allow_redirects=allow_redirects, timeout=timeout,
  File "D:\programs\anaconda3\lib\site-packages\httpx\_client.py", line 621, in send
    request, auth=auth, timeout=timeout, allow_redirects=allow_redirects,
  File "D:\programs\anaconda3\lib\site-packages\httpx\_client.py", line 648, in send_handling_redirects
    request, auth=auth, timeout=timeout, history=history
  File "D:\programs\anaconda3\lib\site-packages\httpx\_client.py", line 684, in send_handling_auth
    response = self.send_single_request(request, timeout)
  File "D:\programs\anaconda3\lib\site-packages\httpx\_client.py", line 719, in send_single_request
    timeout=timeout.as_dict(),
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\http_proxy.py", line 99, in request
    method, url, headers=headers, stream=stream, timeout=timeout
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\http_proxy.py", line 150, in _forward_request
    method, url, headers=headers, stream=stream, timeout=timeout
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\connection.py", line 78, in request
    return self.connection.request(method, url, headers, stream, timeout)
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\http11.py", line 62, in request
    ) = self._receive_response(timeout)
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\http11.py", line 115, in _receive_response
    event = self._receive_event(timeout)
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_sync\http11.py", line 142, in _receive_event
    event = self.h11_state.next_event()
  File "D:\programs\anaconda3\lib\contextlib.py", line 130, in __exit__
    self.gen.throw(type, value, traceback)
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_exceptions.py", line 12, in map_exceptions
    raise to_exc(exc) from None
ProtocolError: can't handle event type ConnectionClosed when role=SERVER and state=SEND_RESPONSE
```

Notice the last two frames from the traceback:
```
File "D:\programs\anaconda3\lib\contextlib.py", line 130, in __exit__
    self.gen.throw(type, value, traceback)
  File "D:\programs\anaconda3\lib\site-packages\httpcore\_exceptions.py", line 12, in map_exceptions
    raise to_exc(exc) from None
```
I find that It's not obvious to figure out where's the root cause. If we attach the original traceback to the newly raised exception like this:
```py
@contextlib.contextmanager
def map_exceptions(
    mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]],
    **kwargs: typing.Any,
) -> 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

        message = str(exc)
        raise mapped_exc(message, **kwargs).with_traceback(exc.__traceback__) from None  # type: ignore
```
the traceback becomes:
```
In [4]: httpx.get('http://doestnotexist')
Traceback (most recent call last):
  File "<ipython-input-4-74881dccb3f2>", line 1, in <module>
    httpx.get('http://doestnotexist')
  File "e:\projects\pycharm\httpx\httpx\_api.py", line 170, in get
    trust_env=trust_env,
  File "e:\projects\pycharm\httpx\httpx\_api.py", line 96, in request
    allow_redirects=allow_redirects,
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 643, in request
    request, auth=auth, allow_redirects=allow_redirects, timeout=timeout,
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 673, in send
    request, auth=auth, timeout=timeout, allow_redirects=allow_redirects,
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 702, in _send_handling_redirects
    request, auth=auth, timeout=timeout, history=history
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 738, in _send_handling_auth
    response = self._send_single_request(request, timeout)
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 772, in _send_single_request
    timeout=timeout.as_dict(),
  File "D:\programs\anaconda3\lib\contextlib.py", line 130, in __exit__
    self.gen.throw(type, value, traceback)
  File "e:\projects\pycharm\httpx\httpx\_exceptions.py", line 359, in map_exceptions
    raise mapped_exc(message, **kwargs).with_traceback(exc.__traceback__) from None  # type: ignore
  File "e:\projects\pycharm\httpx\httpx\_exceptions.py", line 342, in map_exceptions
    yield
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 772, in _send_single_request
    timeout=timeout.as_dict(),
  File "E:\projects\pycharm\httpcore\httpcore\_sync\http_proxy.py", line 102, in request
    method, url, headers=headers, stream=stream, timeout=timeout
  File "E:\projects\pycharm\httpcore\httpcore\_sync\http_proxy.py", line 159, in _forward_request
    method, url, headers=headers, stream=stream, timeout=timeout
  File "E:\projects\pycharm\httpcore\httpcore\_sync\connection.py", line 96, in request
    return self.connection.request(method, url, headers, stream, timeout)
  File "E:\projects\pycharm\httpcore\httpcore\_sync\http11.py", line 73, in request
    ) = self._receive_response(timeout)
  File "E:\projects\pycharm\httpcore\httpcore\_sync\http11.py", line 130, in _receive_response
    event = self._receive_event(timeout)
  File "E:\projects\pycharm\httpcore\httpcore\_sync\http11.py", line 160, in _receive_event
    data = self.socket.read(self.READ_NUM_BYTES, timeout)
  File "E:\projects\pycharm\httpcore\httpcore\_backends\sync.py", line 64, in read
    raise ReadError("Server disconnected while attempting read")
ReadError: Server disconnected while attempting read
```
May be it will be easier to realize the really problems?


Discussion:
Just noticed that this tweak is not good enough, as there are some repeat frames in the traceback…
```
File "e:\projects\pycharm\httpx\httpx\_client.py", line 772, in _send_single_request
    timeout=timeout.as_dict(),
  File "D:\programs\anaconda3\lib\contextlib.py", line 130, in __exit__
    self.gen.throw(type, value, traceback)
  File "e:\projects\pycharm\httpx\httpx\_exceptions.py", line 359, in map_exceptions
    raise mapped_exc(message, **kwargs).with_traceback(exc.__traceback__) from None  # type: ignore
  File "e:\projects\pycharm\httpx\httpx\_exceptions.py", line 342, in map_exceptions
    yield
  File "e:\projects\pycharm\httpx\httpx\_client.py", line 772, in _send_single_request
    timeout=timeout.as_dict(),
```
I think this issue is a really important one yup.

At the very *least* we should drop `from None` on our `map_exceptions` and *always* allow visibility onto the underlying `httpcore` exception.

Resolving patch:
diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py
--- a/httpx/_exceptions.py
+++ b/httpx/_exceptions.py
@@ -356,7 +356,7 @@ def map_exceptions(
             raise
 
         message = str(exc)
-        raise mapped_exc(message, **kwargs) from None  # type: ignore
+        raise mapped_exc(message, **kwargs) from exc  # type: ignore
 
 
 HTTPCORE_EXC_MAP = {


Test patch: