Exception hierarchy (#1095)

* Exception heirachy

* Exception heirarchy

* Formatting tweaks

* Update httpx/_exceptions.py

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>

* Update httpx/_exceptions.py

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>

* Update httpx/_exceptions.py

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>

* Update httpx/_exceptions.py

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>

Co-authored-by: Florimond Manca <florimond.manca@gmail.com>
This commit is contained in:
Tom Christie 2020-07-31 12:57:49 +01:00 committed by GitHub
parent 2ba9c1ed90
commit 9409900898
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 227 additions and 117 deletions

View File

@ -19,8 +19,8 @@ from ._exceptions import (
ProxyError,
ReadError,
ReadTimeout,
RedirectError,
RequestBodyUnavailable,
RequestError,
RequestNotRead,
ResponseClosed,
ResponseNotRead,
@ -28,6 +28,7 @@ from ._exceptions import (
StreamError,
TimeoutException,
TooManyRedirects,
TransportError,
WriteError,
WriteTimeout,
)
@ -76,7 +77,7 @@ __all__ = [
"ProtocolError",
"ReadError",
"ReadTimeout",
"RedirectError",
"RequestError",
"RequestBodyUnavailable",
"ResponseClosed",
"ResponseNotRead",
@ -87,6 +88,7 @@ __all__ = [
"ProxyError",
"TimeoutException",
"TooManyRedirects",
"TransportError",
"WriteError",
"WriteTimeout",
"URL",

View File

@ -116,20 +116,24 @@ class DigestAuth(Auth):
# need to build an authenticated request.
return
header = response.headers["www-authenticate"]
challenge = self._parse_challenge(header)
challenge = self._parse_challenge(request, response)
request.headers["Authorization"] = self._build_auth_header(request, challenge)
yield request
def _parse_challenge(self, header: str) -> "_DigestAuthChallenge":
def _parse_challenge(
self, request: Request, response: Response
) -> "_DigestAuthChallenge":
"""
Returns a challenge from a Digest WWW-Authenticate header.
These take the form of:
`Digest realm="realm@host.com",qop="auth,auth-int",nonce="abc",opaque="xyz"`
"""
header = response.headers["www-authenticate"]
scheme, _, fields = header.partition(" ")
if scheme.lower() != "digest":
raise ProtocolError("Header does not start with 'Digest'")
message = "Header does not start with 'Digest'"
raise ProtocolError(message, request=request)
header_dict: typing.Dict[str, str] = {}
for field in parse_http_list(fields):
@ -146,7 +150,8 @@ class DigestAuth(Auth):
realm=realm, nonce=nonce, qop=qop, opaque=opaque, algorithm=algorithm
)
except KeyError as exc:
raise ProtocolError("Malformed Digest WWW-Authenticate header") from exc
message = "Malformed Digest WWW-Authenticate header"
raise ProtocolError(message, request=request) from exc
def _build_auth_header(
self, request: Request, challenge: "_DigestAuthChallenge"
@ -171,7 +176,7 @@ class DigestAuth(Auth):
if challenge.algorithm.lower().endswith("-sess"):
HA1 = digest(b":".join((HA1, challenge.nonce, cnonce)))
qop = self._resolve_qop(challenge.qop)
qop = self._resolve_qop(challenge.qop, request=request)
if qop is None:
digest_data = [HA1, challenge.nonce, HA2]
else:
@ -221,7 +226,9 @@ class DigestAuth(Auth):
return header_value
def _resolve_qop(self, qop: typing.Optional[bytes]) -> typing.Optional[bytes]:
def _resolve_qop(
self, qop: typing.Optional[bytes], request: Request
) -> typing.Optional[bytes]:
if qop is None:
return None
qops = re.split(b", ?", qop)
@ -231,7 +238,8 @@ class DigestAuth(Auth):
if qops == [b"auth-int"]:
raise NotImplementedError("Digest auth-int support is not yet implemented")
raise ProtocolError(f'Unexpected qop value "{qop!r}" in digest auth')
message = f'Unexpected qop value "{qop!r}" in digest auth'
raise ProtocolError(message, request=request)
class _DigestAuthChallenge:

View File

@ -324,7 +324,8 @@ class BaseClient:
# Check that we can handle the scheme
if url.scheme and url.scheme not in ("http", "https"):
raise InvalidURL(f'Scheme "{url.scheme}" not supported.')
message = f'Scheme "{url.scheme}" not supported.'
raise InvalidURL(message, request=request)
# Handle malformed 'Location' headers that are "absolute" form, have no host.
# See: https://github.com/encode/httpx/issues/771
@ -537,12 +538,13 @@ class Client(BaseClient):
http2=http2,
)
def _transport_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:
def _transport_for_url(self, request: Request) -> httpcore.SyncHTTPTransport:
"""
Returns the transport instance that should be used for a given URL.
This will either be the standard connection pool, or a proxy.
"""
enforce_http_url(url)
url = request.url
enforce_http_url(request)
if self._proxies and not should_not_be_proxied(url):
for matcher, transport in self._proxies.items():
@ -590,7 +592,8 @@ class Client(BaseClient):
timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET,
) -> Response:
if request.url.scheme not in ("http", "https"):
raise InvalidURL('URL scheme must be "http" or "https".')
message = 'URL scheme must be "http" or "https".'
raise InvalidURL(message, request=request)
timeout = self.timeout if isinstance(timeout, UnsetType) else Timeout(timeout)
@ -682,7 +685,7 @@ class Client(BaseClient):
"""
Sends a single request, without handling any redirections.
"""
transport = self._transport_for_url(request.url)
transport = self._transport_for_url(request)
with map_exceptions(HTTPCORE_EXC_MAP, request=request):
(
@ -1059,12 +1062,13 @@ class AsyncClient(BaseClient):
http2=http2,
)
def _transport_for_url(self, url: URL) -> httpcore.AsyncHTTPTransport:
def _transport_for_url(self, request: Request) -> httpcore.AsyncHTTPTransport:
"""
Returns the transport instance that should be used for a given URL.
This will either be the standard connection pool, or a proxy.
"""
enforce_http_url(url)
url = request.url
enforce_http_url(request)
if self._proxies and not should_not_be_proxied(url):
for matcher, transport in self._proxies.items():
@ -1204,7 +1208,7 @@ class AsyncClient(BaseClient):
"""
Sends a single request, without handling any redirections.
"""
transport = self._transport_for_url(request.url)
transport = self._transport_for_url(request)
with map_exceptions(HTTPCORE_EXC_MAP, request=request):
(

View File

@ -16,8 +16,14 @@ try:
except ImportError: # pragma: nocover
brotli = None
if typing.TYPE_CHECKING: # pragma: no cover
from ._models import Request
class Decoder:
def __init__(self, request: "Request") -> None:
self.request = request
def decode(self, data: bytes) -> bytes:
raise NotImplementedError() # pragma: nocover
@ -44,7 +50,8 @@ class DeflateDecoder(Decoder):
See: https://stackoverflow.com/questions/1838699
"""
def __init__(self) -> None:
def __init__(self, request: "Request") -> None:
self.request = request
self.first_attempt = True
self.decompressor = zlib.decompressobj()
@ -57,13 +64,13 @@ class DeflateDecoder(Decoder):
if was_first_attempt:
self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
return self.decode(data)
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
def flush(self) -> bytes:
try:
return self.decompressor.flush()
except zlib.error as exc: # pragma: nocover
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
class GZipDecoder(Decoder):
@ -73,20 +80,21 @@ class GZipDecoder(Decoder):
See: https://stackoverflow.com/questions/1838699
"""
def __init__(self) -> None:
def __init__(self, request: "Request") -> None:
self.request = request
self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
def decode(self, data: bytes) -> bytes:
try:
return self.decompressor.decompress(data)
except zlib.error as exc:
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
def flush(self) -> bytes:
try:
return self.decompressor.flush()
except zlib.error as exc: # pragma: nocover
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
class BrotliDecoder(Decoder):
@ -99,10 +107,11 @@ class BrotliDecoder(Decoder):
name. The top branches are for 'brotlipy' and bottom branches for 'Brotli'
"""
def __init__(self) -> None:
def __init__(self, request: "Request") -> None:
assert (
brotli is not None
), "The 'brotlipy' or 'brotli' library must be installed to use 'BrotliDecoder'"
self.request = request
self.decompressor = brotli.Decompressor()
self.seen_data = False
if hasattr(self.decompressor, "decompress"):
@ -117,7 +126,7 @@ class BrotliDecoder(Decoder):
try:
return self._decompress(data)
except brotli.error as exc:
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
def flush(self) -> bytes:
if not self.seen_data:
@ -127,7 +136,7 @@ class BrotliDecoder(Decoder):
self.decompressor.finish()
return b""
except brotli.error as exc: # pragma: nocover
raise DecodingError from exc
raise DecodingError(message=str(exc), request=self.request)
class MultiDecoder(Decoder):
@ -160,7 +169,8 @@ class TextDecoder:
Handles incrementally decoding bytes into text
"""
def __init__(self, encoding: typing.Optional[str] = None):
def __init__(self, request: "Request", encoding: typing.Optional[str] = None):
self.request = request
self.decoder: typing.Optional[codecs.IncrementalDecoder] = (
None if encoding is None else codecs.getincrementaldecoder(encoding)()
)
@ -194,8 +204,8 @@ class TextDecoder:
self.buffer = None
return text
except UnicodeDecodeError: # pragma: nocover
raise DecodingError() from None
except UnicodeDecodeError as exc: # pragma: nocover
raise DecodingError(message=str(exc), request=self.request)
def flush(self) -> str:
try:
@ -207,14 +217,15 @@ class TextDecoder:
return bytes(self.buffer).decode(self._detector_result())
return self.decoder.decode(b"", True)
except UnicodeDecodeError: # pragma: nocover
raise DecodingError() from None
except UnicodeDecodeError as exc: # pragma: nocover
raise DecodingError(message=str(exc), request=self.request)
def _detector_result(self) -> str:
self.detector.close()
result = self.detector.result["encoding"]
if not result: # pragma: nocover
raise DecodingError("Unable to determine encoding of content")
message = "Unable to determine encoding of content"
raise DecodingError(message, request=self.request)
return result

View File

@ -1,3 +1,33 @@
"""
Our exception hierarchy:
* RequestError
+ TransportError
- TimeoutException
· ConnectTimeout
· ReadTimeout
· WriteTimeout
· PoolTimeout
- NetworkError
· ConnectError
· ReadError
· WriteError
· CloseError
- ProxyError
- ProtocolError
+ DecodingError
+ TooManyRedirects
+ RequestBodyUnavailable
+ InvalidURL
* HTTPStatusError
* NotRedirectResponse
* CookieConflict
* StreamError
+ StreamConsumed
+ ResponseNotRead
+ RequestNotRead
+ ResponseClosed
"""
import contextlib
import typing
@ -7,30 +37,26 @@ if typing.TYPE_CHECKING:
from ._models import Request, Response # pragma: nocover
class HTTPError(Exception):
class RequestError(Exception):
"""
Base class for all HTTPX exceptions.
Base class for all exceptions that may occur when issuing a `.request()`.
"""
def __init__(
self, *args: typing.Any, request: "Request" = None, response: "Response" = None
) -> None:
super().__init__(*args)
self._request = request or (response.request if response is not None else None)
self.response = response
def __init__(self, message: str, *, request: "Request") -> None:
super().__init__(message)
self.request = request
@property
def request(self) -> "Request":
# NOTE: this property exists so that a `Request` is exposed to type
# checkers, instead of `Optional[Request]`.
assert self._request is not None # Populated by the client.
return self._request
class TransportError(RequestError):
"""
Base class for all exceptions that are mapped from the httpcore API.
"""
# Timeout exceptions...
class TimeoutException(HTTPError):
class TimeoutException(TransportError):
"""
The base class for timeout errors.
@ -65,7 +91,7 @@ class PoolTimeout(TimeoutException):
# Core networking exceptions...
class NetworkError(HTTPError):
class NetworkError(TransportError):
"""
The base class for network-related errors.
@ -100,63 +126,94 @@ class CloseError(NetworkError):
# Other transport exceptions...
class ProxyError(HTTPError):
class ProxyError(TransportError):
"""
An error occurred while proxying a request.
"""
class ProtocolError(HTTPError):
class ProtocolError(TransportError):
"""
A protocol was violated by the server.
"""
# HTTP exceptions...
# Other request exceptions...
class DecodingError(HTTPError):
class DecodingError(RequestError):
"""
Decoding of the response failed.
"""
class HTTPStatusError(HTTPError):
"""
Response sent an error HTTP status.
"""
def __init__(self, *args: typing.Any, response: "Response") -> None:
super().__init__(*args)
self._request = response.request
self.response = response
# Redirect exceptions...
class RedirectError(HTTPError):
"""
Base class for HTTP redirect errors.
"""
class TooManyRedirects(RedirectError):
class TooManyRedirects(RequestError):
"""
Too many redirects.
"""
class NotRedirectResponse(RedirectError):
class RequestBodyUnavailable(RequestError):
"""
Had to send the request again, but the request body was streaming, and is
no longer available.
"""
class InvalidURL(RequestError):
"""
URL was missing a hostname, or was not one of HTTP/HTTPS.
"""
# Client errors
class HTTPStatusError(Exception):
"""
Response sent an error HTTP status.
May be raised when calling `response.raise_for_status()`
"""
def __init__(
self, message: str, *, request: "Request", response: "Response"
) -> None:
super().__init__(message)
self.request = request
self.response = response
class NotRedirectResponse(Exception):
"""
Response was not a redirect response.
May be raised if `response.next()` is called without first
properly checking `response.is_redirect`.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
class CookieConflict(Exception):
"""
Attempted to lookup a cookie by name, but multiple cookies existed.
Can occur when calling `response.cookies.get(...)`.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
# Stream exceptions...
# These may occur as the result of a programming error, by accessing
# the request/response stream in an invalid manner.
class StreamError(HTTPError):
class StreamError(Exception):
"""
The base class for stream exceptions.
@ -164,12 +221,8 @@ class StreamError(HTTPError):
an invalid way.
"""
class RequestBodyUnavailable(StreamError):
"""
Had to send the request again, but the request body was streaming, and is
no longer available.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
class StreamConsumed(StreamError):
@ -178,6 +231,13 @@ class StreamConsumed(StreamError):
been streamed.
"""
def __init__(self) -> None:
message = (
"Attempted to read or stream response content, but the content has "
"already been streamed."
)
super().__init__(message)
class ResponseNotRead(StreamError):
"""
@ -185,12 +245,23 @@ class ResponseNotRead(StreamError):
after a streaming response.
"""
def __init__(self) -> None:
message = (
"Attempted to access response content, without having called `read()` "
"after a streaming response."
)
super().__init__(message)
class RequestNotRead(StreamError):
"""
Attempted to access request content, without having called `read()`.
"""
def __init__(self) -> None:
message = "Attempted to access request content, without having called `read()`."
super().__init__(message)
class ResponseClosed(StreamError):
"""
@ -198,20 +269,17 @@ class ResponseClosed(StreamError):
closed.
"""
# Other cases...
def __init__(self) -> None:
message = (
"Attempted to read or stream response content, but the request has "
"been closed."
)
super().__init__(message)
class InvalidURL(HTTPError):
"""
URL was missing a hostname, or was not one of HTTP/HTTPS.
"""
class CookieConflict(HTTPError):
"""
Attempted to lookup a cookie by name, but multiple cookies existed.
"""
# We're continuing to expose this earlier naming at the moment.
# It is due to be deprecated. Don't use it.
HTTPError = RequestError
@contextlib.contextmanager

View File

@ -798,16 +798,16 @@ class Response:
value = value.strip().lower()
try:
decoder_cls = SUPPORTED_DECODERS[value]
decoders.append(decoder_cls())
decoders.append(decoder_cls(request=self.request))
except KeyError:
continue
if len(decoders) == 1:
self._decoder = decoders[0]
elif len(decoders) > 1:
self._decoder = MultiDecoder(decoders)
self._decoder = MultiDecoder(children=decoders)
else:
self._decoder = IdentityDecoder()
self._decoder = IdentityDecoder(request=self.request)
return self._decoder
@ -830,10 +830,10 @@ class Response:
if codes.is_client_error(self.status_code):
message = message.format(self, error_type="Client Error")
raise HTTPStatusError(message, response=self)
raise HTTPStatusError(message, request=self.request, response=self)
elif codes.is_server_error(self.status_code):
message = message.format(self, error_type="Server Error")
raise HTTPStatusError(message, response=self)
raise HTTPStatusError(message, request=self.request, response=self)
def json(self, **kwargs: typing.Any) -> typing.Any:
if self.charset_encoding is None and self.content and len(self.content) > 3:
@ -895,7 +895,7 @@ class Response:
that handles both gzip, deflate, etc but also detects the content's
string encoding.
"""
decoder = TextDecoder(encoding=self.charset_encoding)
decoder = TextDecoder(request=self.request, encoding=self.charset_encoding)
for chunk in self.iter_bytes():
yield decoder.decode(chunk)
yield decoder.flush()
@ -927,7 +927,11 @@ class Response:
Get the next response from a redirect response.
"""
if not self.is_redirect:
raise NotRedirectResponse()
message = (
"Called .next(), but the response was not a redirect. "
"Calling code should check `response.is_redirect` first."
)
raise NotRedirectResponse(message)
assert self.call_next is not None
return self.call_next()
@ -968,7 +972,7 @@ class Response:
that handles both gzip, deflate, etc but also detects the content's
string encoding.
"""
decoder = TextDecoder(encoding=self.charset_encoding)
decoder = TextDecoder(request=self.request, encoding=self.charset_encoding)
async for chunk in self.aiter_bytes():
yield decoder.decode(chunk)
yield decoder.flush()
@ -1000,7 +1004,10 @@ class Response:
Get the next response from a redirect response.
"""
if not self.is_redirect:
raise NotRedirectResponse()
raise NotRedirectResponse(
"Called .anext(), but the response was not a redirect. "
"Calling code should check `response.is_redirect` first."
)
assert self.call_next is not None
return await self.call_next()

View File

@ -18,7 +18,7 @@ from ._exceptions import InvalidURL
from ._types import PrimitiveData
if typing.TYPE_CHECKING: # pragma: no cover
from ._models import URL
from ._models import URL, Request
_HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"}
@ -265,16 +265,21 @@ def get_logger(name: str) -> Logger:
return typing.cast(Logger, logger)
def enforce_http_url(url: "URL") -> None:
def enforce_http_url(request: "Request") -> None:
"""
Raise an appropriate InvalidURL for any non-HTTP URLs.
"""
url = request.url
if not url.scheme:
raise InvalidURL("No scheme included in URL.")
message = "No scheme included in URL."
raise InvalidURL(message, request=request)
if not url.host:
raise InvalidURL("No host included in URL.")
message = "No host included in URL."
raise InvalidURL(message, request=request)
if url.scheme not in ("http", "https"):
raise InvalidURL('URL scheme must be "http" or "https".')
message = 'URL scheme must be "http" or "https".'
raise InvalidURL(message, request=request)
def port_or_default(url: "URL") -> typing.Optional[int]:

View File

@ -94,7 +94,8 @@ PROXY_URL = "http://[::1]"
)
def test_transport_for_request(url, proxies, expected):
client = httpx.AsyncClient(proxies=proxies)
transport = client._transport_for_url(httpx.URL(url))
request = httpx.Request(method="GET", url=url)
transport = client._transport_for_url(request)
if expected is None:
assert transport is client._transport
@ -141,7 +142,8 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected):
monkeypatch.setenv(name, value)
client = client_class()
transport = client._transport_for_url(httpx.URL(url))
request = httpx.Request(method="GET", url=url)
transport = client._transport_for_url(request)
if expected is None:
assert transport == client._transport

View File

@ -66,8 +66,8 @@ async def test_asgi_exc():
@pytest.mark.usefixtures("async_environment")
async def test_asgi_http_error():
client = httpx.AsyncClient(app=partial(raise_exc, exc=httpx.HTTPError))
with pytest.raises(httpx.HTTPError):
client = httpx.AsyncClient(app=partial(raise_exc, exc=RuntimeError))
with pytest.raises(RuntimeError):
await client.get("http://www.example.org/")

View File

@ -135,7 +135,8 @@ def test_empty_content(header_value):
"decoder", (BrotliDecoder, DeflateDecoder, GZipDecoder, IdentityDecoder)
)
def test_decoders_empty_cases(decoder):
instance = decoder()
request = httpx.Request(method="GET", url="https://www.example.com")
instance = decoder(request)
assert instance.decode(b"") == b""
assert instance.flush() == b""
@ -206,10 +207,12 @@ async def test_text_decoder_known_encoding():
def test_text_decoder_empty_cases():
decoder = TextDecoder()
request = httpx.Request(method="GET", url="https://www.example.com")
decoder = TextDecoder(request=request)
assert decoder.flush() == ""
decoder = TextDecoder()
decoder = TextDecoder(request=request)
assert decoder.decode(b"") == ""
assert decoder.flush() == ""

View File

@ -97,8 +97,8 @@ def test_wsgi_exc():
def test_wsgi_http_error():
client = httpx.Client(app=partial(raise_exc, exc=httpx.HTTPError))
with pytest.raises(httpx.HTTPError):
client = httpx.Client(app=partial(raise_exc, exc=RuntimeError))
with pytest.raises(RuntimeError):
client.get("http://www.example.org/")