From: halbow <39669025+halbow@users.noreply.github.com> Date: Tue, 6 Aug 2019 13:20:48 +0000 (+0200) Subject: Added base class HTTPError with request/response attribute (#162) X-Git-Tag: 0.7.0~26 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=92fbe5fd87143e67cc22a54d69a5d3b3e75abea3;p=thirdparty%2Fhttpx.git Added base class HTTPError with request/response attribute (#162) --- diff --git a/httpx/client.py b/httpx/client.py index 5e814b38..e22d1a9f 100644 --- a/httpx/client.py +++ b/httpx/client.py @@ -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 diff --git a/httpx/concurrency.py b/httpx/concurrency.py index 2ee45a85..f07eef4c 100644 --- a/httpx/concurrency.py +++ b/httpx/concurrency.py @@ -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 diff --git a/httpx/dispatch/http11.py b/httpx/dispatch/http11.py index 690db4bb..0f34191e 100644 --- a/httpx/dispatch/http11.py +++ b/httpx/dispatch/http11.py @@ -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 diff --git a/httpx/dispatch/http2.py b/httpx/dispatch/http2.py index 331f82df..980b07b2 100644 --- a/httpx/dispatch/http2.py +++ b/httpx/dispatch/http2.py @@ -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 diff --git a/httpx/exceptions.py b/httpx/exceptions.py index 19af3e6b..21dc7f4d 100644 --- a/httpx/exceptions.py +++ b/httpx/exceptions.py @@ -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. """ diff --git a/httpx/interfaces.py b/httpx/interfaces.py index f058edeb..0a17f99c 100644 --- a/httpx/interfaces.py +++ b/httpx/interfaces.py @@ -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: diff --git a/httpx/models.py b/httpx/models.py index 5e0c827e..4b9bdc5d 100644 --- a/httpx/models.py +++ b/httpx/models.py @@ -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: diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 4f79af04..b037085f 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -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 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f85fe77f..97ae0277 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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 diff --git a/tests/dispatch/utils.py b/tests/dispatch/utils.py index 8a62554b..c92fa7a3 100644 --- a/tests/dispatch/utils.py +++ b/tests/dispatch/utils.py @@ -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):