Added base class HTTPError with request/response attribute (#162)

This commit is contained in:
halbow 2019-08-06 15:20:48 +02:00 committed by Seth Michael Larson
parent 9142a893ff
commit 92fbe5fd87
10 changed files with 70 additions and 50 deletions

View File

@ -24,6 +24,7 @@ from .exceptions import (
RedirectBodyUnavailable,
RedirectLoop,
TooManyRedirects,
HTTPError,
)
from .interfaces import AsyncDispatcher, ConcurrencyBackend, Dispatcher
from .models import (
@ -82,13 +83,13 @@ class BaseClient:
)
if dispatch is None:
async_dispatch = ConnectionPool(
async_dispatch: AsyncDispatcher = ConnectionPool(
verify=verify,
cert=cert,
timeout=timeout,
pool_limits=pool_limits,
backend=backend,
) # type: AsyncDispatcher
)
elif isinstance(dispatch, Dispatcher):
async_dispatch = ThreadedDispatcher(dispatch, backend)
else:
@ -167,13 +168,18 @@ class BaseClient:
auth = HTTPBasicAuth(username=auth[0], password=auth[1])
request = auth(request)
response = await self.send_handling_redirects(
request,
verify=verify,
cert=cert,
timeout=timeout,
allow_redirects=allow_redirects,
)
try:
response = await self.send_handling_redirects(
request,
verify=verify,
cert=cert,
timeout=timeout,
allow_redirects=allow_redirects,
)
except HTTPError as exc:
# Add the original request to any HTTPError
exc.request = request
raise
if not stream:
try:
@ -200,19 +206,20 @@ class BaseClient:
# We perform these checks here, so that calls to `response.next()`
# will raise redirect errors if appropriate.
if len(history) > self.max_redirects:
raise TooManyRedirects()
raise TooManyRedirects(response=history[-1])
if request.url in [response.url for response in history]:
raise RedirectLoop()
raise RedirectLoop(response=history[-1])
response = await self.dispatch.send(
request, verify=verify, cert=cert, timeout=timeout
)
should_close_response = True
try:
assert isinstance(response, AsyncResponse)
response.history = list(history)
self.cookies.extract_cookies(response)
history = history + [response]
history.append(response)
if allow_redirects and response.is_redirect:
request = self.build_redirect_request(request, response)
@ -249,7 +256,7 @@ class BaseClient:
method = self.redirect_method(request, response)
url = self.redirect_url(request, response)
headers = self.redirect_headers(request, url)
content = self.redirect_content(request, method)
content = self.redirect_content(request, method, response)
cookies = self.merge_cookies(request.cookies)
return AsyncRequest(
method=method, url=url, headers=headers, data=content, cookies=cookies
@ -307,14 +314,16 @@ class BaseClient:
del headers["Authorization"]
return headers
def redirect_content(self, request: AsyncRequest, method: str) -> bytes:
def redirect_content(
self, request: AsyncRequest, method: str, response: AsyncResponse
) -> bytes:
"""
Return the body that should be used for the redirect request.
"""
if method != request.method and method == "GET":
return b""
if request.is_streaming:
raise RedirectBodyUnavailable()
raise RedirectBodyUnavailable(response=response)
return request.content

View File

@ -223,7 +223,7 @@ class AsyncioBackend(ConcurrencyBackend):
writer = Writer(stream_writer=stream_writer, timeout=timeout)
protocol = Protocol.HTTP_2 if ident == "h2" else Protocol.HTTP_11
return (reader, writer, protocol)
return reader, writer, protocol
async def run_in_threadpool(
self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any

View File

@ -131,7 +131,7 @@ class HTTP11Connection:
assert isinstance(event, h11.Response)
break
http_version = "HTTP/%s" % event.http_version.decode("latin-1", errors="ignore")
return (http_version, event.status_code, event.headers)
return http_version, event.status_code, event.headers
async def _receive_response_data(
self, timeout: TimeoutConfig = None

View File

@ -133,7 +133,7 @@ class HTTP2Connection:
status_code = int(v.decode("ascii", errors="ignore"))
elif not k.startswith(b":"):
headers.append((k, v))
return (status_code, headers)
return status_code, headers
async def body_iter(
self, stream_id: int, timeout: TimeoutConfig = None

View File

@ -1,7 +1,24 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import BaseRequest, BaseResponse # pragma: nocover
class HTTPError(Exception):
"""
Base class for Httpx exception
"""
def __init__(self, request: 'BaseRequest' = None, response: 'BaseResponse' = None, *args) -> None:
self.response = response
self.request = request or getattr(self.response, "request", None)
super().__init__(*args)
# Timeout exceptions...
class Timeout(Exception):
class Timeout(HTTPError):
"""
A base class for all timeouts.
"""
@ -34,19 +51,13 @@ class PoolTimeout(Timeout):
# HTTP exceptions...
class HttpError(Exception):
"""
An HTTP error occurred.
"""
class ProtocolError(Exception):
class ProtocolError(HTTPError):
"""
Malformed HTTP.
"""
class DecodingError(Exception):
class DecodingError(HTTPError):
"""
Decoding of the response failed.
"""
@ -55,7 +66,7 @@ class DecodingError(Exception):
# Redirect exceptions...
class RedirectError(Exception):
class RedirectError(HTTPError):
"""
Base class for HTTP redirect errors.
"""
@ -83,7 +94,7 @@ class RedirectLoop(RedirectError):
# Stream exceptions...
class StreamException(Exception):
class StreamError(HTTPError):
"""
The base class for stream exceptions.
@ -92,21 +103,21 @@ class StreamException(Exception):
"""
class StreamConsumed(StreamException):
class StreamConsumed(StreamError):
"""
Attempted to read or stream response content, but the content has already
been streamed.
"""
class ResponseNotRead(StreamException):
class ResponseNotRead(StreamError):
"""
Attempted to access response content, without having called `read()`
after a streaming response.
"""
class ResponseClosed(StreamException):
class ResponseClosed(StreamError):
"""
Attempted to read or stream response content, but the request has been
closed.
@ -116,13 +127,13 @@ class ResponseClosed(StreamException):
# Other cases...
class InvalidURL(Exception):
class InvalidURL(HTTPError):
"""
URL was missing a hostname, or was not one of HTTP/HTTPS.
"""
class CookieConflict(Exception):
class CookieConflict(HTTPError):
"""
Attempted to lookup a cookie by name, but multiple cookies existed.
"""

View File

@ -26,7 +26,7 @@ class AsyncDispatcher:
"""
Base class for async dispatcher classes, that handle sending the request.
Stubs out the interface, as well as providing a `.request()` convienence
Stubs out the interface, as well as providing a `.request()` convenience
implementation, to make it easy to use or test stand-alone dispatchers,
without requiring a complete `Client` instance.
"""
@ -72,9 +72,9 @@ class AsyncDispatcher:
class Dispatcher:
"""
Base class for syncronous dispatcher classes, that handle sending the request.
Base class for synchronous dispatcher classes, that handle sending the request.
Stubs out the interface, as well as providing a `.request()` convienence
Stubs out the interface, as well as providing a `.request()` convenience
implementation, to make it easy to use or test stand-alone dispatchers,
without requiring a complete `Client` instance.
"""
@ -136,7 +136,7 @@ class BaseReader:
class BaseWriter:
"""
A stream writer. Abstracts away any asyncio-specfic interfaces
A stream writer. Abstracts away any asyncio-specific interfaces
into a more generic base class, that we can use with alternate
backend, or for stand-alone test cases.
"""
@ -155,7 +155,7 @@ class BasePoolSemaphore:
"""
A semaphore for use with connection pooling.
Abstracts away any asyncio-specfic interfaces.
Abstracts away any asyncio-specific interfaces.
"""
async def acquire(self) -> None:

View File

@ -20,7 +20,7 @@ from .decoders import (
)
from .exceptions import (
CookieConflict,
HttpError,
HTTPError,
InvalidURL,
ResponseClosed,
ResponseNotRead,
@ -528,10 +528,10 @@ class BaseRequest:
return content, content_type
def prepare(self) -> None:
content = getattr(self, "content", None) # type: bytes
content: typing.Optional[bytes] = getattr(self, "content", None)
is_streaming = getattr(self, "is_streaming", False)
auto_headers = [] # type: typing.List[typing.Tuple[bytes, bytes]]
auto_headers: typing.List[typing.Tuple[bytes, bytes]] = []
has_host = "host" in self.headers
has_user_agent = "user-agent" in self.headers
@ -687,7 +687,7 @@ class BaseResponse:
self.request = request
self.on_close = on_close
self.next = None # typing.Optional[typing.Callable]
self.next: typing.Optional[typing.Callable] = None
@property
def reason_phrase(self) -> str:
@ -776,7 +776,7 @@ class BaseResponse:
content, depending on the Content-Encoding used in the response.
"""
if not hasattr(self, "_decoder"):
decoders = [] # type: typing.List[Decoder]
decoders: typing.List[Decoder] = []
values = self.headers.getlist("content-encoding", split_commas=True)
for value in values:
value = value.strip().lower()
@ -811,9 +811,8 @@ class BaseResponse:
message = message.format(self, error_type="Server Error")
else:
message = ""
if message:
raise HttpError(message)
raise HTTPError(message, response=self)
def json(self, **kwargs: typing.Any) -> typing.Union[dict, list]:
if self.charset_encoding is None and self.content and len(self.content) > 3:

View File

@ -72,8 +72,9 @@ async def test_raise_for_status(server):
)
if 400 <= status_code < 600:
with pytest.raises(httpx.exceptions.HttpError):
with pytest.raises(httpx.exceptions.HTTPError) as exc_info:
response.raise_for_status()
assert exc_info.value.response == response
else:
assert response.raise_for_status() is None

View File

@ -95,10 +95,10 @@ def test_raise_for_status(server):
response = client.request(
"GET", "http://127.0.0.1:8000/status/{}".format(status_code)
)
if 400 <= status_code < 600:
with pytest.raises(httpx.exceptions.HttpError):
with pytest.raises(httpx.exceptions.HTTPError) as exc_info:
response.raise_for_status()
assert exc_info.value.response == response
else:
assert response.raise_for_status() is None

View File

@ -29,7 +29,7 @@ class MockHTTP2Backend(AsyncioBackend):
timeout: TimeoutConfig,
) -> typing.Tuple[BaseReader, BaseWriter, Protocol]:
self.server = MockHTTP2Server(self.app)
return (self.server, self.server, Protocol.HTTP_2)
return self.server, self.server, Protocol.HTTP_2
class MockHTTP2Server(BaseReader, BaseWriter):